MCP 工具参数校验失败(Schema Validation Error)排查与修复
快速答案
- 核心结论:MCP 工具参数校验失败通常是因为工具实现中的校验规则与模型实际生成的参数不匹配,或校验逻辑本身存在缺陷(如可选字段被误设为必填、正则过于严格等)。
- 首要检查:确认工具参数定义(如
query至少 1 字符、limit在 1-100 之间)与调用方传入的实际值是否一致;检查是否使用了safeParse(Zod)或catch ValidationError(Pydantic)来捕获校验错误而非直接抛出异常。 - 最小修复:使用
npm install --save-dev jest @types/jest zod安装测试与校验依赖,为每个参数编写单元测试;在工具 handler 中返回isError: true并携带结构化错误信息,避免服务崩溃。 - 适用边界:本方案适用于 TypeScript/JavaScript 生态的 MCP 服务(配合 Zod + Jest),Python 生态可类比使用 Pydantic + pytest;校验规则应平衡安全性与可用性,避免过度限制合法输入。
问题复现:校验通过但运行时失败
最常见的 MCP 工具参数校验失败场景是:Schema 校验通过了,但运行时却因缺少校验规则而崩溃。例如:
- 某个字段在 Schema 中标记为可选(
required: false),但工具实现中却假设它一定存在,导致undefined访问报错。 - 文件路径校验只检查了字符串类型,未限制目录范围,导致路径遍历攻击。
- 数据库查询的
where_clause只做了字符串长度检查,未过滤DROP等危险 SQL。
复现步骤:
- 定义一个 MCP 工具,参数包含
query(必填,1-1000 字符)和limit(可选,1-100 整数,默认 10)。 - 使用 Zod 定义 Schema,但忘记对
limit做整数范围校验。 - 调用工具时传入
limit: "abc",Schema 校验通过(因为只检查了类型),但运行时parseInt失败。
根因分析:校验规则与实现脱节
| 根因类型 | 具体表现 | 后果 |
|---|---|---|
| 可选字段被当作必填 | Schema 中 required: false,但代码中直接访问 | 运行时 TypeError |
| 校验规则过宽 | 只检查类型,不检查范围/格式 | 非法值进入业务逻辑 |
| 校验规则过严 | 限制长度、拒绝 Unicode 字符 | 合法输入被阻断 |
| 错误处理不当 | 直接 throw 而非返回结构化错误 | MCP 服务崩溃或 LLM 无法理解错误 |
关键点:MCP 工具的参数校验是「模型生成参数 → Schema 校验 → 业务逻辑」链路中的第一道防线。校验失败不仅影响当前调用,还会让 LLM 无法根据错误信息自我修正。
修复方案:Zod + Jest 完整实践
1. 安装依赖
BASHnpm install --save-dev jest @types/jest zod
2. 定义参数 Schema(以搜索工具为例)
TYPESCRIPTimport { z } from 'zod'; // 对应参数:query(必填), filters(可选), limit(可选) const SearchParamsSchema = z.object({ query: z.string().min(1, '查询字符串至少 1 个字符').max(1000, '查询字符串最多 1000 个字符'), filters: z.array(z.string()).optional(), limit: z.number().int().min(1).max(100).default(10), }); // 对应参数:operation(必填), path(必填), content(可选), force(可选) const FileOpParamsSchema = z.object({ operation: z.enum(['read', 'write', 'delete']), path: z.string().refine((p) => { const resolved = path.resolve(p); return resolved.startsWith(ALLOWED_DIR); // 限制目录范围 }, '路径必须在允许的目录内'), content: z.string().optional(), force: z.boolean().default(false), }); // 对应参数:table(必填), columns(必填), where_clause(可选) const DbQueryParamsSchema = z.object({ table: z.string().regex(/^[a-zA-Z_]\w*$/, '表名格式不正确'), columns: z.array(z.string()).min(1, '至少需要一个列名'), where_clause: z.string().refine((s) => !/DROP|DELETE\s+FROM/i.test(s), '禁止危险 SQL').optional(), }); // 对应参数:a(必填), b(必填) const CalcParamsSchema = z.object({ a: z.number(), b: z.number(), });
3. 在工具 handler 中使用 safeParse
TYPESCRIPTimport { McpError, ErrorCode } from '@modelcontextprotocol/sdk'; export async function handleSearch(params: unknown) { const result = SearchParamsSchema.safeParse(params); if (!result.success) { // 关键:返回结构化错误,而不是 throw return { isError: true, content: [{ type: 'text', text: `参数校验失败: ${result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}` }] }; } const { query, filters, limit } = result.data; // 业务逻辑... }
4. 编写 Jest 单元测试
TYPESCRIPT// search.test.ts import { SearchParamsSchema } from './schemas'; describe('SearchParamsSchema', () => { test('接受合法参数', () => { const result = SearchParamsSchema.safeParse({ query: 'hello', limit: 5 }); expect(result.success).toBe(true); }); test('拒绝空查询字符串', () => { const result = SearchParamsSchema.safeParse({ query: '' }); expect(result.success).toBe(false); expect(result.error!.issues[0].message).toContain('至少 1 个字符'); }); test('拒绝超出范围的 limit', () => { const result = SearchParamsSchema.safeParse({ query: 'test', limit: 101 }); expect(result.success).toBe(false); }); test('limit 默认值为 10', () => { const result = SearchParamsSchema.safeParse({ query: 'test' }); expect(result.data!.limit).toBe(10); }); });
5. 集成测试:模拟 MCP 客户端调用
TYPESCRIPT// integration.test.ts import { server } from './server'; test('工具调用返回结构化错误', async () => { const response = await server.handleRequest({ method: 'tools/call', params: { name: 'search', arguments: { query: '' } // 非法参数 } }); expect(response.isError).toBe(true); expect(response.content[0].text).toContain('参数校验失败'); });
常见报错与排查
报错 1:Schema 校验通过但运行时失败
- 现象:
safeParse返回success: true,但后续代码访问不存在的属性。 - 排查:检查 Schema 中标记为
optional()的字段,确认代码中是否做了空值判断。 - 修复:使用
??或?.操作符,或为可选字段提供默认值。
报错 2:校验过严,合法输入被拒绝
- 现象:包含 Unicode 字符的姓名、超过预设长度的合法文本被拒绝。
- 排查:检查
min/max限制是否合理,正则是否过于严格。 - 修复:放宽限制,如使用
.max(1000)而非固定长度;正则允许 Unicode(如/^[\p{L}\p{N}_]+$/u)。
报错 3:错误信息不友好,LLM 无法理解
- 现象:错误信息是
"Invalid input"或堆栈跟踪。 - 排查:检查错误消息是否包含字段名和期望格式。
- 修复:自定义错误消息,如
'查询字符串最多 1000 个字符',并在测试中断言错误消息质量。
报错 4:校验错误未正确返回给客户端
- 现象:工具调用直接抛异常,MCP 服务崩溃。
- 排查:确认 handler 中是否使用了
safeParse并返回isError: true。 - 修复:参考上文 handler 示例,确保所有校验失败路径都返回结构化错误。
生产环境实践与注意事项
| 注意事项 | 具体建议 |
|---|---|
| 性能瓶颈 | 避免在热路径中使用复杂正则或递归校验;可先做轻量级预校验(如类型检查) |
| 路径遍历攻击 | 使用 path.resolve 并检查前缀,限制在允许目录内;禁止 ../ 序列 |
| SQL 注入 | where_clause 校验不能替代参数化查询;始终使用预编译语句 |
| 并发安全 | 文件操作使用 flock 锁;数据库使用连接池 |
| 错误信息泄露 | 自定义错误消息,避免暴露堆栈或内部路径 |
| 超时与重试 | 在 MCP Host 配置中设置超时(如 30s)和重试策略 |
MCP Host 配置示例(claude_desktop_config.json 或 Cursor 的 MCP 配置):
JSON{ "mcpServers": { "validation-server": { "command": "node", "args": ["path/to/server.js"], "env": { "NODE_ENV": "production", "LOG_LEVEL": "info" } } } }
常见问题 FAQ
Q: 如何在 MCP 工具中处理校验错误而不导致服务崩溃?
A: 使用 Zod 的 safeParse(或 Pydantic 的 catch ValidationError),将校验失败转换为结构化响应:返回 isError: true 并携带包含字段名和错误说明的文本内容。这样 LLM 能根据错误信息自我修正参数,服务也不会崩溃。
Q: 校验文件路径的最佳实践是什么?
A: 至少做到三点:1) 使用 path.resolve 将相对路径转为绝对路径;2) 检查解析后的路径是否在允许的目录前缀内(如 resolved.startsWith(ALLOWED_DIR));3) 拒绝包含 ../ 或 ..\\ 的路径。更严格的做法是维护一个允许目录的白名单。
Q: 如何在 CI/CD 中测试 MCP 工具的校验规则?
A: 为每个校验规则编写独立的单元测试(Jest/Vitest 或 pytest),覆盖合法、非法、边界值(如空字符串、最大值、最小值)。再编写集成测试,模拟 MCP 客户端调用 tools/call,断言返回的 isError 标志和错误消息内容。使用覆盖率工具(如 jest --coverage)确保所有校验分支都被测试到。
相关深度解决方案
在配置当前服务时,如果您需要实现更复杂的架构或多源数据整合,建议配合参考我们整理的 Kubernetes MCP Server:用自然语言管理集群的实战配置与排坑。
在配置当前服务时,如果您需要实现更复杂的架构或多源数据整合,建议配合参考我们整理的 VS Code MCP 服务器配置:路径权限与环境变量报错排查。