Serverless Framework MCP 服务器故障排查完全指南:症状定位、根因分析与证据验证
本文以 Serverless Framework 官方 MCP 服务器故障排查参考文档 skills/serverless-mcp/references/troubleshooting.md 为骨架,结合仓库内 mcp 插件的真实实现源码,系统讲解将 MCP 服务器托管到 AWS Lambda / API Gateway 后遇到各类问题的定位思路与修复方法。无论你遇到的是端点"有响应但行为不对"、冷启动即失败、鉴权与 OAuth 发现异常、配置报错还是部署期 CLI 意外,都能按"症状 → 原因 → 修复"的路子快速收敛,并最终用真实的 JSON-RPC 结果、状态码或日志行完成复核。
文章贯穿一个核心方法论:不要凭 YAML"看起来对"或"deploy 打印出了端点"就断定服务器是好的——端点存在不等于协议可用。每种故障都必须找到可观测的真实证据再收工;如何制造这些证据(curl 最小往返、MCP Inspector、日志读取、鉴权端到端探测),在 skills/serverless-mcp/references/testing.md 中有可直接照抄的命令,本文是它的"症状侧"镜像:看到什么,意味着什么,该怎么改。
排查总纲:先读症状,后读日志,以证据裁决
整个参考文档的组织方式是"按症状检索"(Find the symptom),而非按组件检索。标准动作只有三步:
- 精确定位症状——是 HTTP 状态码(
504/401/403)、协议层错误(-32020/-32021/isError)、冷启动日志中的异常,还是配置解析阶段的MCP_*报错; - 对应到根因类别——端点边界(edge/regional 的静默上限)、模块契约(默认导出必须是
createMcpHandler()的返回值)、打包策略(devDependencies、混合构建)、IAM/KMS 授权、API Gateway 鉴权语义、OAuth 发现的 URL 解析链; - 应用修复并重新用真实证据验证——一个 JSON-RPC 返回、一个状态码、一行日志。
值得先建立的一张"心智地图"来自 skills/serverless-mcp/SKILL.md:MCP 部署被一切为二——你拥有一个模块(用官方 SDK 构建、默认导出带 web 标准 fetch 的 handler),Framework 拥有它周边的一切(HTTPS 路由、流式响应、鉴权接线、打包、state 密钥注入)。因此,几乎每个故障都可以先问一句:这个错误出在"我的模块"还是"Framework 围栏"上?下文五个症状表正好按此展开。
症状表一:端点有响应,但行为不符合预期
这是最"隐蔽"的一类问题:请求能到达、状态码是 2xx,但工具行为、协议细节或生命周期不符合预期。核心诱因集中在 API Gateway 的静默空闲边界、协议版本协商、zod/SDK 版本与注册方式上。
| 症状 | 原因 | 修复 |
|---|---|---|
工具写不出任何东西时大约 30s 出现 504,而短调用成功 |
端点是 edge-optimized(Framework 默认),从 invoke 起算,流安静约 30s 即被掐断 | 设 provider.endpointType: REGIONAL 并重新部署。提高 timeout 无用——被限制的是两次写入之间的间隔,不是总时长 |
| regional 端点上流仍在大约 5 分钟时死亡 | regional 的空闲上限被触达,因为工具长时间静默 | 让工具周期性发出进度通知——每次通知都是一次写入,会重置空闲时钟(写法见 skills/serverless-mcp/references/server-code.md 的 progress 小节) |
tools/list 返回的工具带空 input schema,而 tools/call 却正常 |
服务里装的是 zod 3;SDK 的 schema 转换需要 zod 4.2+ | npm install zod@^4.2 后重新部署 |
| 所有进度事件不是随工作发生,而是结尾一次性 flush | 链路上某处缓冲了整个响应——例如手写路由没有 response.transferMode: STREAM,或前面挂了代理 |
让 Framework 拥有这条路由;在手工写的流式函数上,每一条路由(含任何 metadata 路由)都要 STREAM |
| 向客户端要东西的工具(elicitation、sampling、roots)报 "did not declare the required capability" 或 "cannot receive server-to-client requests" | 客户端协商到了早于 2026-07-28 的协议修订版(官方 SDK 客户端默认值);按请求服务的模式无法发出 client wire 请求,而带内 input_required 流(它同样携带 elicitation/create、sampling/createMessage、roots/list)是 2026 时代才有的 |
让客户端选择现代修订版:new Client(info, { capabilities: { elicitation: { form: {} } }, versionNegotiation: { mode: 'auto' } }),并注册配套 handlers。普通工具、流与进度不需要它——只有"向客户端要东西"的工具需要 |
加了自定义域名后,原本能用的 execute-api URL 的客户端拒绝服务器 |
设置 provider.domain 后,所有对外公布的 URL(含 metadata 文档的 resource)都指向该域名;符合规范的客户端用旧 URL 访问会因 origin 不匹配而拒绝 |
让客户端指向域名。一旦域名前置,execute-api URL 就不再是可互换的别名 |
tools/call 返回 isError:Cannot read properties of undefined (reading 'mcpReq') |
工具注册时没有 inputSchema,但回调签名是 (args, ctx)——没有 schema 时 SDK 把 context 作为回调唯一参数传入,于是 ctx 为 undefined |
注册时补 inputSchema(无输入工具用 z.object({})),或把 context 作为唯一形参接收 |
对端点发 GET 返回 405 |
这是预期行为——SDK 对非 POST 动词按规范返回错误 | 无需修复 |
| 客户端挂断后工具仍在运行(并继续计费) | 此入口不传播客户端断连 | timeout 就是成本上限;把它设为你实际最长的工具。观察 ctx.mcpReq.signal,让取消在它到达的任何位置生效 |
其中前两行值得展开:在 packages/serverless/lib/plugins/aws/mcp/lib/validate.js 中可以看到默认 timeout = 60、最低 Node 20、默认运行时 nodejs24.x 等常量,而 idle 边界来自 API Gateway 本身而非函数配置——mcp 既不改也不校验 provider.endpointType,它与服务里普通 http 函数共享同一个 API。因此该设置是 API 级的:要么整条服务 REGIONAL,要么靠工具内每 <300s 至少一次的写入(进度通知)续命。这一点在 skills/serverless-mcp/references/config.md 的 "Endpoint type" 一节有同样的表述:edge 大约 30s、regional 大约 5 分钟、超过约 300s 后进度通知是唯一把调用留在空闲边界内的手段。
"注册了工具却带空 schema"其实是 zod 版本这一条的"可见形态",修复后可用 skills/serverless-mcp/references/testing.md 的"端点类型试金石"试验验证:请求一个静默约 35s、不带 progressToken 的工具——200 延迟返回 = regional、流式完好;约 30s 504 = 仍是 edge。镜像试验同样重要:约 36s 每秒发一次进度的调用,第一个事件应在前几秒落地;若它与其余事件在结尾一起到达,说明有东西缓冲了整条响应。
症状表二:冷启动失败(去函数日志里读)
这一表的所有条目都以 Lambda 冷启动为舞台。MCP 服务器函数与普通函数无异,因此用 serverless logs -f <name> 就能看到入口(entry)自己爆出的错误;而"入口"正是 packages/serverless/lib/plugins/aws/mcp/entry/index.mjs 所描述的那个预构建桥:它先解析环境(含 state 密钥),再 import 你的模块,最后经由 Hono 的 Lambda 桥把流式运行时接到 SDK handler 的 fetch 上。
| 症状 | 原因 | 修复 |
|---|---|---|
错误点名了 server: 属性和 createMcpHandler() |
模块的默认导出没有暴露 web 标准的 fetch |
写成 export default createMcpHandler(() => { … })——不是导出 McpServer 实例,也不是具名导出 |
ERR_MODULE_NOT_FOUND,针对 @modelcontextprotocol/server 或 zod |
经典打包会剥离 devDependencies;或混合服务把该 server 漏出了 bundle |
把它们移入 dependencies(或设 package.excludeDevDependencies: false);让每个 server 接受同等的构建处理 |
The MCP server module "…" is not in the deployed package |
server: 路径写错,或打包规则排除了该文件 |
修正路径,或修正把它丢掉的 package.patterns |
读 state key 报 AccessDenied,且点名一个 action 和一个 ARN |
你带来的执行角色没有被授予读权限——Framework 不能修改它不是它创建的角色 | 把消息中引用的那条语句附加到该角色的 policy 上 |
| 同一条消息,但 AWS 文本提及 KMS | 密钥由客户管理的 KMS key 加密 | 额外授予该 key 的 kms:Decrypt,并让 key 自身 policy 允许该角色使用 |
SERVERLESS_MCP_STATE_KEY_REF is empty / state key 里没有字符串值 |
引用指向不可读内容,或指向二进制 secret | state key 必须是纯文本 secret,或 String/SecureString 参数 |
报 SERVERLESS_MCP_SERVER_MODULE 未设置 |
该函数没有被作为 MCP 服务器部署 | 通过 mcp: 部署;若此前跑过 serverless dev 会话,用 serverless deploy 重新部署 |
关于"默认导出必须是 fetch"这条,模块侧契约在 skills/serverless-mcp/references/server-code.md 写得很清楚:createMcpHandler() 的工厂每个请求跑一次,模块作用域之外不共享状态;默认导出必须暴露 web 标准的 fetch——导出 McpServer 实例、transport 或具名导出正是冷启动报错点名 server: 的根因。而 state key 的时序细节藏在入口源码里:entry/index.mjs 中,入口会先把 state 密钥放进 process.env.SERVERLESS_MCP_STATE_KEY,再 import 你的模块,且该顺序被注释明确为"规范要求而非偏好"——因为模块作用域代码(例如模块级的 createRequestStateCodec({ key }))需要读它。
把 state 密钥设计成"先注入后导入"同时解释了 SERVERLESS_MCP_STATE_KEY_REF is empty 与二进制 secret 两行:Framework 把 key 引用放进函数环境,入口在运行时用 ssm:GetParameter 或 secretsmanager:GetSecretValue 解析它(见 packages/serverless/lib/plugins/aws/mcp/entry/index.mjs);引用指向空、指向二进制值时自然得不到可用字符串。
症状表三:鉴权与发现
开始这一表之前,文档给了一条关键的分流规则:Framework 从不校验 token,所以 MCP 路由上的 401/403 要么来自 API Gateway("裸"形态,invoke 之前拒绝),要么来自你自己的模块(规范形态,invoke 之后返回)——绝不是入口的。判断办法是看服务器自身的日志组有没有新增条目:有 = 函数被调用了,错误出自你的模块/网关后的逻辑;没有 = 拒绝发生在 invoke 之前,属于 API Gateway/authorizer 的语义。这也是在 skills/serverless-mcp/references/testing.md 中"鉴权端到端"一节反复强调的证据标准。
| 症状 | 原因 | 修复 |
|---|---|---|
裸 401 {"message":"Unauthorized"},且服务器与 authorizer 的日志组都无新增 |
API Gateway 在 invoke 前就拒绝:请求缺了 authorizer 的 identity source(默认是 Authorization 头),或 Cognito user pool authorizer 自行判定 token 无效 |
无 token 的调用方得到它就是设计在起作用。若合法调用方也如此,检查客户端是否发送了 identitySource 点名的那个头 |
所有调用者(含有效 token)都 401,且 authorizer 自己的日志显示它在拒绝或报错 |
字符串形式编译成 TOKEN authorizer,它只收到 event.authorizationToken,没有 event.headers;按完整请求事件写的函数在它找的地方找不到东西 |
用带 type: request、identitySource: method.request.header.Authorization 的对象形式;或改读 event.authorizationToken |
403 "User is not authorized to access this resource"(API Gateway 的 ACCESS_DENIED 响应) |
Lambda authorizer 执行后返回了 Deny policy——本行不是 aws_iam 形态(下面两行才是) |
这就是 authorizer 的裁决——检查它的 policy 逻辑。裁决会被 resultTtlInSeconds 缓存(默认 300s),所以修好的 authorizer 可能继续按旧裁决响应这么久 |
authorizer: aws_iam 服务器的 MCP 路由上 403 {"message":"Missing Authentication Token"} |
请求没有 SigV4 签名。IAM 授权的 method 从不答 401:未签名请求得到这个 403——与下面路由未命中的发现型 403 字节一致,因此要按命中哪个路由(哪个 path)来区分 |
用 AWS 凭证对请求做 SigV4 签名;不能签名的调用方需要换别的 authorizer 形态 |
403 "User: arn:… is not authorized to perform: execute-api:Invoke on resource: …" |
请求已签名,但调用者的 IAM 身份缺少该路由上的 execute-api:Invoke。裁决来自逐请求的 IAM policy 评估——不存在 authorizer 资源,也没有 resultTtlInSeconds 缓存参与 |
给调用身份授予该 API stage/route ARN 上的 execute-api:Invoke |
发现 URL 的 GET 答 403——{"message":"Missing Authentication Token"} 或 {"message":"Forbidden"} |
文字会误导:两者都与鉴权无关,都是路径未命中。Missing Authentication Token 表示请求到达了一个 API 但没有路由匹配:在 execute-api 上是路径或 stage 错了;在自定义域名上是路径匹配了 base-path 映射但背后的 API 没有该路由;或此服务器没有 oauthDiscovery,该路由从未被创建。Forbidden 是自定义域名下没有任何 base-path 映射匹配——请求从未到达任何 API。状态码永远不会替你区分——是响应体 + 被请求的 URL 在区分 |
检查 URL:在裸 execute-api 源上 stage 前缀是 URL 的一部分;在自定义域名上 domain 的 basePath 是 URL 的一部分。并确认 mcp.servers.<name>.oauthDiscovery 已设置 |
浏览器客户端 POST 发现 URL 后报不透明的 CORS 失败 |
该路由只服务 GET 和 OPTIONS;其他方法得到不带 CORS 头的 API Gateway 默认 403,浏览器只能把它报告成 CORS 错误 |
用 GET 取文档 |
| package 或 deploy 警告发现被公布在 stage URL 上 | 没有 oauthDiscovery.publicUrl 也没有单一 REST 面向的自定义域名,于是文档的 URL 解析回退到 stage URL——任何客户端常用的"相对于 origin root 探测"都落不到那里,交互式登录将无法工作 |
把 mcp.servers.<name>.oauthDiscovery.publicUrl 设为客户端真正使用的 URL,或在 provider.domain 下声明域名。在二者之一被设置之前,每次 package/deploy 都会重复该警告 |
对默认 execute-api URL 的 OAuth 发现失败——Claude Code 落在永不加载的 <origin>/authorize 登录页 |
客户端探测 well-known 路径是相对于 origin root 的;在默认端点上文档位于 stage 前缀之下,任何探测都落空;完全落空时客户端可能把服务器 origin 当成授权服务器,于是出现死登录页。(官方 SDK 客户端从裸 401 直接开始流程——不需要 challenge 头——随后探测同样的路径) |
在根路径映射处放一个自定义域名——stage 前缀消失,root 探测即命中(非 root 的 basePath 会让文档再次离开 root)。接受直接给 metadata URL 的客户端可指向 stage 感知 URL 而无需域名;Claude Code 没有此类设置 |
| 改动 issuer/URL 并部署后,发现文档仍显示旧值 | 栈更新完成后端点还会继续返回旧响应体一段时间:edge-optimized(默认)经 CloudFront 大约 50–70 秒;即便是 regional 也有 60–90 秒——此时控制面已显示新文档而端点仍答旧文档 | 最多等两分钟再重新抓取,然后再往下调试 |
| 未鉴权的洪泛在烧 invoke 次数 | 路由上没有任何 authorizer——每个请求都到达函数,模块内门禁只在付过 invoke 费之后才拒绝 |
设置 authorizer:Cognito user pool 与 aws_iam 在任何地方都不触发 invoke 就拒绝;Lambda authorizer 在无 token 时甚至不被 invoke(缺 identity source)就拒绝,坏 token 也只花费它自己远便宜得多的 invoke(以抛 401 方式拒绝时不被缓存)。无论哪种,发现路由都保持开放 |
交互式 OAuth connect 以一条裸客户端消息失败、无任何细节——Claude Code 只报 SDK auth failed: |
客户端侧表面设计上就是静默的:issuer 实际答了什么(注册被拒、端点被禁用、登录后的 policy 失败)都不会被转述 | 直接问 issuer。对 URL-only(DCR)连接:用 issuer 自己 metadata 里的注册端点跑 curl -s -X POST <registration_endpoint>,带最小请求体 {"client_name":"probe","redirect_uris":["http://localhost:8976/callback"]},会返回 issuer 的真实错误——issuer 无论是否开启注册都会公布 registration_endpoint,所以试探性 POST 才是测试,metadata 不是 |
把 TOKEN vs request 区分、aws_iam 的两种 403 形态放回源码,可以看得更实:在 packages/serverless/lib/plugins/aws/mcp/lib/validate.js 中,authorizer 的合法类型集合(token、request、cognito_user_pools、aws_iam、custom)以及编译期大写拼写(TOKEN/REQUEST/COGNITO_USER_POOLS vs 挂在已有 authorizer 上的 CUSTOM)都被显式列出,并注明与 http 事件共用同一套 API Gateway 编译机制——字符串 authorizer 正是"默认得到 TOKEN"的那条路径,这从实现上解释了为什么"字符串形式收不到 headers"。aws_iam 则是唯一"API Gateway 自行校验、不需要 authorizer 资源"的类型(validate.js 中专门处理了它与 authorizerId 的矛盾组合)。
关于发现路由不挂 authorizer的取舍:它必须让没有 token 的客户端先读到"去哪拿 token",因此 oauthDiscovery 的 GET/OPTIONS 由 API Gateway 以 MOCK 响应模板静态服务、无 Lambda 在背后(在 packages/serverless/lib/plugins/aws/mcp/index.js 的 before:package:compileEvents 钩子中通过 registerExternalHttpEvents 一次性注册 MCP 路由与发现路由),未鉴权探测不 invoke、不冷启动、每请求零成本。与之配套,发现是公告而非强制:发布文档并不保护服务器,oauthDiscovery 不带任何 authorizer 也是合法配置(模块内门禁对 Framework 不可见),部署时 --verbose 仅作提醒——详见 packages/serverless/lib/plugins/aws/mcp/index.js 的 reportDiscoveryExposure。
最后一行的 "issuer 侧静默"同样映射到客户端设计:Claude Code 这类 URL-only 客户端按 URL 识别服务器,失败时几乎不吐细节;skills/serverless-mcp/references/testing.md 对此给出同一条诊断建议——用 curl 直接 POST 注册端点拿 issuer 的真实错误。
症状表四:配置错误(MCP_* 错误码全集)
这一类有个共同的好消息:每一个都在碰到 AWS 之前被抛出——大多在配置解析期间,所以 print 和 package 就能暴露它们,不需要一次 deploy。其中 MCP_OAUTH_DISCOVERY_VTL_UNSAFE_VALUE 分两段抛:schema 拥有的值(含 Fn::Sub issuer 的字面文本)在配置解析时抛;schema 看不见的值(自定义域名、stage、服务器名)在模板编译时抛。MCP_OAUTH_DISCOVERY_ISSUER_VARIABLE_COLLISION 同理在模板编译期浮现——package 两种都能抓到。
| 错误码 | 原因 | 修复 |
|---|---|---|
MCP_AWS_PROVIDER_REQUIRED |
服务的 provider.name 不是 aws 却写了 mcp 块 |
把 provider 切到 aws,或删掉该块 |
MCP_UNSUPPORTED_NODE_RUNTIME |
provider.runtime 钉在低于 20 的 Node.js 运行时 |
设 nodejs20.x 或更新;或去掉它用默认的 nodejs24.x |
MCP_RESERVED_SERVER_NAME |
服务器名叫 well-known(会与发现路径冲突)或 __proto__ |
重命名服务器 |
MCP_FUNCTION_NAME_COLLISION |
服务器名归一化后与某个函数或另一服务器编译成同一个 CloudFormation 逻辑 ID | 重命名其一——归一化折叠大小写并去掉 _,所以 foo_bar 与 foobar 会冲突 |
MCP_INVALID_STATE_ARN |
state 是 CloudFormation intrinsic,或既非 SSM 参数也非 Secrets Manager secret 的 ARN |
把 ARN 写全,或改用 state: true |
MCP_AUTHORIZER_INVALID |
authorizer 为空、既非字符串也非对象、type 未知、没有指名 name/arn/authorizerId 之一(type: aws_iam 例外,它不需要标识符)、设了 authorizerId 却没有 API Gateway 要求同列的 type、字符串携带冒号(字符串总是指函数——ARN 放对象形式)、intrinsic arn 旁边没有 name(否则 CloudFormation 名要从尚不存在的 ARN 推导)、或把 type: aws_iam 与 authorizerId 配对(IAM 不挂 authorizer 资源,该 id 会被静默忽略) |
指名一个 authorizer 函数、aws_iam、或带标识符的 http-event 风格对象;给裸 authorizerId 配上 type,给 ARN 用对象形式,给 intrinsic arn 配 name |
MCP_AUTHORIZER_NAME_COLLISION |
两个不同 authorizer——在两个服务器上、或一个服务器加一个 http 事件上——名字编译成同一个 CloudFormation 逻辑 ID;只会创建一个 authorizer 资源,于是某条路由会静默地被另一个的 authorizer 守护 |
把其中一个改名为归一化后不同的逻辑 ID,或让两个定义完全相同以真正成为一个 authorizer |
MCP_OAUTH_DISCOVERY_ISSUER_REQUIRED |
oauthDiscovery 存在但没有 issuer——或 issuer 是对象但不在受支持的 CloudFormation intrinsic 之列(Ref、Fn::GetAtt、Fn::ImportValue、Fn::Sub、Fn::Join、Fn::Base64、Fn::ToJsonString;拼错的或不被认可的 Fn:: 键也会落到这里) |
把 oauthDiscovery.issuer 设为授权服务器的 https URL 或能解析到它的受支持 intrinsic,或删掉该块 |
MCP_OAUTH_DISCOVERY_ISSUER_NOT_HTTPS |
issuer 不是带 host 的 https URL——或含 $/#(会被文档的 Velocity 模板改写)。对 Fn::Sub issuer,检查的是 ${...} 占位符之外的字面文本,因此整体只是一个占位符的 Fn::Sub 会失败前缀检查 |
写 provider 公布的完整 https URL;在 Fn::Sub 的替换之外把 scheme 写出来;或直接用 Ref/Fn::GetAtt 指名整个 URL 值 |
MCP_OAUTH_DISCOVERY_PUBLIC_URL_NOT_HTTPS |
publicUrl 没通过同样的两项检查——或 publicUrl 是 CloudFormation intrinsic(它指本栈之外的前门,deploy 会打印它)、或带查询串(服务器路由会追加在其后,URL 中间夹查询串什么都指不到) |
写客户端使用的纯 https 基础 URL——/<name>/mcp 之前的全部,不带查询 |
MCP_OAUTH_DISCOVERY_VTL_UNSAFE_VALUE |
$ 或 # 会进入发现文档,而 Velocity 把两者都当活语法:来源是 Fn::Sub issuer 的字面文本(含 ${!Literal} 转义——它渲染为字面 ${...} 文本,在验证期被抛);或来自 schema 看不见的值——自定义域名、stage 名、服务器名(在模板编译时被抛,package 能抓到) |
从消息点名的那个值里去掉该字符 |
MCP_OAUTH_DISCOVERY_ISSUER_VARIABLE_COLLISION |
列表形式的 Fn::Sub issuer 声明了一个叫 RestApiId 的变量,而发现文档自身的渲染会用这个名字作为本服务的 REST API id |
改掉 Fn::Sub 变量映射里的那个变量名 |
MCP_SERVER_MODULE_REQUIRED |
服务器条目没有可用的 server: 路径——条目不是对象,或 server 缺失/为空 |
设 mcp.servers.<name>.server 为模块路径 |
MCP_ENTRY_STAGING_PATH_TAKEN |
服务目录里有个 serverless-mcp/ 目录放着 Framework 不拥有的文件 |
移走或改名——打包会在此暂存入口并在结束后移除 |
MCP_PREBUILT_ARTIFACT_UNSUPPORTED |
设了 package.artifact:此类 artifact 原样上传,入口永远到不了它 |
去掉 artifact 设置,或把 MCP 服务器移进自己的服务 |
MCP_ENTRY_BUNDLE_MISSING |
预构建入口缺失。发布版 CLI 里这是值得上报的 bug;源码检出里它是构建产物 | 在 packages/serverless 下运行 npm run build:mcp:entry |
MCP_API_GATEWAY_PLUGIN_NOT_FOUND |
内部错误——找不到 API Gateway 编译器 | 上报它 |
API_GATEWAY_EXTERNAL_EVENT_ROUTE_COLLISION |
你的某个 http 事件与 MCP 路由坐在同一个 API Gateway 资源上;那里的具体 method 会压过路由的 ANY 从而分流 JSON-RPC 流量 |
挪走你的事件。子路径(如 /crm/mcp/extra)没有问题 |
这份"配置错误"表与源码中的校验器一一对应。validateMcp 在 packages/serverless/lib/plugins/aws/mcp/lib/validate.js 里实现,并由插件在 initialize 钩子中调用(见 packages/serverless/lib/plugins/aws/mcp/index.js),这就是为什么 print/package 就能触发错误。几个值得强调的实现细节:
- 保留名:
well-known因与oauthDiscovery文档服务的.well-known路径冲突、__proto__因 JavaScript 原型 setter 而被禁用(validate.js 中RESERVED_SERVER_NAMES,约 L14-L23); - 函数名校验:用
naming.getNormalizedFunctionName归一化,与 CloudFormation 逻辑 ID 的实际产出保持同一套规则(validate.js 约 L591、L631-L648); - 运行时校验:
provider.runtime低于nodejs20.x即抛MCP_UNSUPPORTED_NODE_RUNTIME(validate.js 约 L593-L600); - state ARN:仅接受
ssm:parameter/*与secretsmanager:secret/*两类字面 ARN,正则见 validate.js 约 L34-L37,判定在约 L666-L686; - authorizer 的复杂规则(字符串 vs 对象、
authorizerId要配type、ARN 该进对象形式、intrinsicarn要配name、aws_iam不得配authorizerId)集中在validateAuthorizer(约 L115-L271),每条都配有解释为什么——例如裸 Cognito pool ARN 推导出的逻辑名以数字开头、会在 CloudFormation 内毙命;intrinsic ARN 上做.split(":")会 TypeError; - VTL 安全:发现文档由 API Gateway 在每次请求时以 Velocity 求值,
$开变量、#开指令,所以issuer/publicUrl中两者都被拒;Fn::Sub的${...}占位符由 CloudFormation 先解析故被遮蔽后再检查,唯独${!Literal}转义会渲染成字面${…}文本、必须拒绝(validate.js 中maskSubPlaceholders与validateIntrinsicIssuer,约 L437-L475)。
这些错误码的行为都受单元测试守护,相关覆盖见 packages/serverless/test/unit/lib/plugins/aws/mcp/validate.test.js。
症状表五:部署期与 CLI 意外
最后一类发生在 deploy/info 等 CLI 环节,多数是"框架围栏"的行为边界,不一定是配置错误。
| 症状 | 原因 | 修复 |
|---|---|---|
| 警告说 Dev Mode 不支持 MCP 服务器 | serverless dev 下打包集成让位,部署出来的函数在模块前没有入口 |
用 serverless deploy 部署来真正跑一个服务器 |
deploy function -f <name> 更新了代码却没更新环境 |
Lambda 配置更新在任一值是 CloudFormation 引用时会跳过整个 environment——而 state 正是这样传 key 的 |
环境变更请跑完整 serverless deploy |
| deploy 后紧跟一条"执行角色读不了 state key"的警告 | 部署后的模拟得到确定性的拒绝 | 附上警告引用的那条 statement |
| 没有警告,但服务器运行时在 key 上失败 | 模拟得不到裁决(例如部署凭据可能调不了 iam:SimulatePrincipalPolicy),于是选择保持沉默而非猜测 |
读冷启动错误——它点名 action 和 ARN |
deploy 或 info 摘要里没有端点行 |
没有自定义域名,且没有收集到 ServiceEndpoint 输出(栈尚不存在,或查找失败) |
栈起来后再跑一次 serverless info;--verbose 会显示调试行 |
"Dev Mode 不支持 MCP"在 packages/serverless/lib/plugins/aws/mcp/index.js 有清晰说明:Dev Mode 自己接管 artifact 与 handler,若照常暂存入口和换 handler 会被覆盖,因此整个打包集成让位,同时用一次显式警告("requests to the deployed endpoint will fail")避免"端点存在但答不出任何 MCP 内容"的隐性失败。
"deploy function 不更新环境"则源于 Framework 行为:updateFunctionConfiguration 在遇到非字符串对象(如 {Ref})时会删除整个 Environment 参数——这正是 state: true 传 key 的方式。插件选择警告而非绕过,因为绕过意味着要在已部署栈里解析引用(见 packages/serverless/lib/plugins/aws/mcp/index.js)。
最后两行对应部署后的权限模拟机制:只针对你带来的字面角色 ARN 执行(见 packages/serverless/lib/plugins/aws/mcp/lib/permission-check.js 的 byoRoleArnFor),用 iam:SimulatePrincipalPolicy 对 state key 求值——能拿到"明确拒绝"就警告,拿不到裁决就沉默(宁可不猜),把最可信的失败现场留给冷启动错误去点名 action 与 ARN。这一设计让"deploy 没警告 + 运行时报错"的组合有了解释:不是检查漏了,是模拟没能得到裁决。
收尾:把排查闭环成一个可重复的循环
回到 skills/serverless-mcp/SKILL.md 给出的操作循环:
success signal → write the module → declare it → deploy → verify a real
round trip → clean up scratch stacks
排障是这条循环的"反向行驶":用本文的症状表把现象翻译成根因,修复后用 skills/serverless-mcp/references/testing.md 里可复制的 curl(2026-07-28 修订版要求 content-type: application/json、accept: application/json, text/event-stream、mcp-method、mcp-name、params._meta envelope 等头部/信封约定)跑一次真实往返,确认 HTTP 状态、JSON-RPC 结果与日志证据三者都符合预期——顺手把两个负向用例也验一下:-32020(头与 body 不一致)与 -32021(工具索要了客户端从未声明能提供的能力),它们同时证明请求完好到达了函数内的 SDK。
修完别忘了验证端点是 mcp: crm → https://…/dev/crm/mcp 那行(单个服务器内联、多个则缩进块);为试验临时起的一整套 REST API、函数、日志组与可能的 secret,用 serverless remove --stage <stage> 清掉,随后重新部署永远是安全的。
速查:三句话记住这套排障法
- 先分清围栏内外:报错来自入口(冷启动日志)、Framework(
MCP_*/警告)还是你自己的模块/网关(authorizer、in-module gate)?看服务器日志组有没有新条目即可分流。 - 静默是头号杀手:edge 约 30s、regional 约 5 分钟的空闲上限约束的是写入间隔而非总时长;长工具要么
REGIONAL,要么发进度通知,而timeout只是成本上限。 - 文档与配置是"提前失效"的:
MCP_*配置错误不需要 deploy,print/package就报;Discovery 文档由 API Gateway 以 Velocity 模板静态服务,因此$、#、RestApiId冲突与publicUrl/issuer 的 URL 解析问题,全都能在触碰 AWS 之前或package阶段被拦下。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00