首页
/ claude-mem 中的 MCP 类型强转修复:get_observations 的 ids 序列化根因与防御式 API 设计

claude-mem 中的 MCP 类型强转修复:get_observations 的 ids 序列化根因与防御式 API 设计

2026-09-06 09:22:21作者:郦嵘贵Just

claude-mem 通过 MCP(Model Context Protocol)工具向各类 Agent 暴露 searchtimelineget_observations 等记忆检索能力,其中 get_observations 曾因不同 MCP 客户端对 ids 参数的序列化差异(原生数组 vs JSON 字符串 vs 逗号分隔字符串)而在 worker 端校验失败。本文基于仓库内根因修复记录 TRIAGE-02-MCP-Type-Coercion.md 展开,结合 DataRoutes.tsvalidateBody.tsdata-routes-coercion.test.ts 的实际源码,完整还原“问题定位 — 修复方案 — 测试验证”的全过程,读完后可掌握跨协议边界(API client/server 之间)的参数类型强转设计模式。

问题背景:两个 Issue 指向同一个失败面

修复记录明确标注了本阶段解决的问题范围:

  • Issue #1172:get_observations 工具调用失败;
  • Issue #1091:worker 端出现持续性 500 错误。

两个 Issue 的表象不同,但根因都在 worker 的 HTTP 数据路由层。文档给出的核心判断是:这是每个端点约 3 行的强转修复,而不是需要新建中间件模块的问题。这一判断决定了后续方案的设计取向——就地内联强转,保持修复的最小侵入性。

根因验证:序列化链路逐层排查

MCP 服务端:参数如何到达 worker

从源码看,MCP 服务端 mcp-server.tsget_observations 工具的输入 Schema 声明 ids 为数字数组(mcp-server.ts#L543-L560):

{
  name: 'get_observations',
  description: 'Step 3: Fetch full details for filtered IDs. Params: ids (array of observation IDs, required), orderBy, limit, project',
  inputSchema: {
    type: 'object',
    properties: {
      ids: {
        type: 'array',
        items: { type: 'number' },
        description: 'Array of observation IDs to fetch (required)'
      }
    },
    required: ['ids'],
    additionalProperties: true
  },
  handler: async (args: any) => {
    return await callWorker('/api/observations/batch', { body: args });
  }
},

工具 handler 将 args 交给 callWorker,后者以 POST + JSON.stringify(body) 的方式把请求体发往 worker 的 /api/observations/batch 端点(mcp-server.ts#L72-L120)。如果 MCP SDK 忠实传递了原生数组,JSON 序列化/反序列化往返后 ids 应当仍是数组。

真正的故障点:客户端对 arguments 字段的序列化差异

修复记录的根因验证结论是:bug 很可能出在部分 MCP 客户端对 arguments 字段的序列化上——它们发出的是 ids: "[1,2,3]"(字符串)而非 ids: [1,2,3](数组)。此时 worker 端 handleGetObservationsByIds 中紧随其后的 Array.isArray(ids) 校验必然失败,请求以 400 被拒,工具调用报错。

对照当前仓库,DataRoutes.ts 中该路由注册为:

app.post('/api/observations/batch', validateBody(observationsBatchSchema), this.handleGetObservationsByIds.bind(this));
// [DataRoutes.ts#L89](https://gitcode.com/GitHub_Trending/cl/claude-mem/blob/be44b6c8e238a7e2bc5b3403c05afac071a59ead/src/services/worker/http/routes/DataRoutes.ts?utm_source=gitcode_repo_files#L89)
app.post('/api/sdk-sessions/batch', validateBody(sdkSessionsBatchSchema), this.handleGetSdkSessionsByIds.bind(this));
// [DataRoutes.ts#L91](https://gitcode.com/GitHub_Trending/cl/claude-mem/blob/be44b6c8e238a7e2bc5b3403c05afac071a59ead/src/services/worker/http/routes/DataRoutes.ts?utm_source=gitcode_repo_files#L91)

文档同时确认了另一个相关事实:搜索侧的字符串到数组强转此前已修复SearchOrchestrator.normalizeParams()(见 SearchOrchestrator.ts)已经把 conceptsfilesobs_typetype 等参数的逗号分隔字符串切分为数组,因此搜索链路无需重复工作。这提示了一个可复用的排查思路:同一仓库中不同端点对同类入参的处理策略可能不一致,修复前应先盘点已有的归一化实现,避免重复造轮子。

修复方案:内联强转,拒绝过度设计

修复记录给出了明确的任务清单与设计约束:

  1. handleGetObservationsByIdsArray.isArray(ids) 校验之前,先把字符串形态的 ids 转换为数组;
  2. handleGetSdkSessionsByIdsmemorySessionIds 应用同一模式;
  3. 不创建独立的工具模块或中间件——强转内联在使用点;
  4. 强转之后保留原有的 Array.isArrayNumber.isInteger 校验。

文档建议的原始实现是一个约 3 行的 try/catch 片段:

let { ids, orderBy, limit, project } = req.body;
if (typeof ids === 'string') {
  try { ids = JSON.parse(ids); } catch { ids = ids.split(',').map(Number); }
}

这个片段体现了“双重尝试”策略:优先按 JSON 解析(覆盖 "[1,2,3]" 形态),失败后再退化为逗号切分(覆盖 "1,2,3" 形态),两种客户端序列化形态都被兜住。

仓库中的最终落地:zod preprocess 模式

对比当前源码可以发现,最终实现把文档建议的内联 try/catch 演进为同文件顶部的 zod preprocess Schema(DataRoutes.ts#L22-L48),既保留了“内联在使用文件内、不新增模块”的约束,又让强转逻辑成为 Schema 的一部分:

const integerArrayLike = z.preprocess((value) => {
  if (Array.isArray(value)) return value;
  if (typeof value === 'string') {
    try {
      const parsed = JSON.parse(value);
      if (Array.isArray(parsed)) return parsed;
    } catch {
      // not JSON, fall through to comma split
    }
    return value.split(',').map((part) => Number(part.trim()));
  }
  return value;
}, z.array(z.number().int()));

const stringArrayLike = z.preprocess((value) => {
  if (Array.isArray(value)) return value;
  if (typeof value === 'string') {
    try {
      const parsed = JSON.parse(value);
      if (Array.isArray(parsed)) return parsed;
    } catch {
      // not JSON, fall through to comma split
    }
    return value.split(',').map((part) => part.trim()).filter(Boolean);
  }
  return value;
}, z.array(z.string()));

两个 preprocess 的差异值得注意:

  • integerArrayLike:对逗号切分后的每段执行 Number(part.trim()),最终断言为 z.array(z.number().int())——非数字会在这里被校验拒绝;
  • stringArrayLike:对每段执行 trim()filter(Boolean) 去掉空段,最终断言为 z.array(z.string())

这两个 preprocess Schema 再被组合进两个批量接口的请求体 Schema(DataRoutes.ts#L50-L61):

const observationsBatchSchema = z.object({
  ids: integerArrayLike,
  orderBy: z.enum(['date_desc', 'date_asc']).optional(),
  limit: z.number().int().positive().optional(),
  project: z.string().optional(),
  platformSource: z.string().optional(),
  platform_source: z.string().optional(),
}).passthrough();

const sdkSessionsBatchSchema = z.object({
  memorySessionIds: stringArrayLike,
}).passthrough();

强转发生在 validateBody 中间件里。该中间件对 req.body 执行 schema.safeParse,校验失败时返回 400 并附带结构化的 issues 列表;成功时执行 req.body = result.data 再调用 next()——也就是说,handler 拿到的 req.body 已经是强转后的干净数据:

export const validateBody = <S extends ZodTypeAny>(schema: S): RequestHandler =>
  (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      res.status(400).json({
        error: 'ValidationError',
        issues: result.error.issues.map(i => ({
          path: i.path, message: i.message, code: i.code,
        })),
      });
      return;
    }
    req.body = result.data;
    next();
  };

handler 本体随后可以安全地按原生数组消费数据(DataRoutes.ts#L164-L177):

private handleGetObservationsByIds = this.wrapHandler((req: Request, res: Response): void => {
  const { ids, orderBy, limit, project } = req.body as z.infer<typeof observationsBatchSchema>;

  if (ids.length === 0) {
    res.json([]);
    return;
  }

  const store = this.dbManager.getSessionStore();
  const platformSource = this.getOptionalPlatformSourceFromRequest(req);
  const observations = store.getObservationsByIds(ids, { orderBy, limit, project, platformSource });

  res.json(observations);
});

从源码结构看,这种“preprocess + 严格 Schema”的组合等价于文档要求的顺序:先强转(JSON 或逗号切分),再执行严格的数组/整型断言——"foo,bar" 会被切分为 [NaN, NaN] 并在 z.number().int() 处被拒,最终由 validateBody 以 400 返回,而不是带着脏数据流入存储层。

Issue #1091 的另一半:observation 写入路径的防御式错误处理

修复记录的第二项任务是针对 #1091 的持续性 500:500 错误可能来自 observation 存储路径上未被捕获的异常。文档的约束是:

worker 的 500 不应该打断 hook——hook 本身已经会处理非 ok 响应。

对应的实现要求是:处理来自 hooks 的 observation 存储 POST 端点(handleObservationsByClaudeId,位于 SessionRoutes.ts)时,把存储路径包进 try/catch,在可恢复错误下返回 200 + { stored: false, reason: '...' },而不是让统一错误包装返回 500。当前仓库中该契约确实存在,SessionRoutes.ts 中有如下响应形态(SessionRoutes.ts#L497):

res.status(result.status ?? 500).json({ stored: false, reason: result.reason });

这条设计原则可以单独提炼为一条 API 约定:面向调用链上游(此处为 hooks)的写接口,应区分“业务上未存储成功”与“服务不可用”。前者用带 reason 的 2xx/软失败响应表达,让上游静默降级并继续会话;后者才占用 5xx。这与 MCP 工具侧的处理风格一致——callWorker 在 catch 分支中不抛异常,而是返回 { content: [...], isError: true } 的结构化错误文本(mcp-server.ts#L110-L119),保证 Agent 侧拿到的是可读诊断信息而非裸协议错误。

测试验证:11 个用例覆盖全部强转形态

修复记录声明新增了 11 个测试,位于 data-routes-coercion.test.ts(基于 bun:test 运行器)。测试通过 mock Express app 捕获 /api/observations/batch/api/sdk-sessions/batch 的“中间件 + handler”调用链,再对 SessionStore 的两个查询方法打桩,从而只验证路由层的强转与校验行为。用例矩阵如下:

handleGetObservationsByIds — ids 强转(6 例):

输入 期望行为
ids: [1, 2, 3] 原生数组 原样透传,调用 getObservationsByIds([1, 2, 3], ...)
ids: '[1,2,3]' JSON 字符串 强转为 [1, 2, 3]
ids: '1,2,3' 逗号字符串 强转为 [1, 2, 3]
ids: 'foo,bar' 非法值 400 拒绝
缺失 ids 400 拒绝
ids: [] 空数组 直接返回 [](短路,不查库)

handleGetSdkSessionsByIds — memorySessionIds 强转(5 例):

输入 期望行为
['abc', 'def'] 原生字符串数组 原样透传
'["abc","def"]' JSON 字符串 强转为 ['abc', 'def']
'abc,def' 逗号字符串 强转为 ['abc', 'def']
'abc, def , ghi' 带空白 逐段 trim 后强转为 ['abc', 'def', 'ghi']
42 非数组非字符串 400 拒绝

测试文件里还有一个工程细节值得一提:它在使用 mock.module 注入 paths.js / worker-utils.js 桩之前,先对真实模块做了快照并在 afterAll 中恢复——因为 bun 的 mock.module 是进程级全局的,mock.restore() 不会撤销它,不恢复会污染同一次 bun test 运行中的其他测试文件(data-routes-coercion.test.ts#L6-L25)。修复记录同时声明:全量运行后 211 个测试通过、0 失败,integration/chroma-vector-sync 与 server health 端点的既有失败与本修复无关。

可复用经验:跨协议边界的入参防御清单

从这次修复中,可以提炼出在“外部客户端 → 内部服务”这类跨协议边界上做入参防御的具体做法,均能在本仓库找到对应证据:

  1. 先盘点既有归一化逻辑再动手SearchOrchestrator.normalizeParams() 已覆盖搜索侧的字符串转数组,本次只补数据批量端点,避免同一问题两处实现漂移(见 SearchOrchestrator.ts);
  2. 强转优先于拒绝,但强转后必须复验z.preprocess 只负责“尽力解析”,最终的 z.array(z.number().int()) 断言保证脏数据(foo,barNaN)仍以 400 被拦下,而不是进入 SQL 层;
  3. 强转逻辑内联在路由文件内,不新建中间件模块。约 3 行(或一个 preprocess Schema)的修复面,比抽象层更便宜、更易审查——这是修复记录明确的反过度设计约束;
  4. 上游是 hook/Agent 时,写路径用“软失败”契约:{ stored: false, reason } 而非 500,保证采集链路的异常不反噬宿主会话(见 SessionRoutes.ts#L497);
  5. 测试覆盖“形态矩阵”而非只测 happy path:原生数组、JSON 字符串、逗号字符串、空白填充、非法值、缺失、空数组七种形态各一例,是这类强转逻辑的最小完备验证集(见 data-routes-coercion.test.ts)。

这套模式对任何“通过 MCP、插件或 HTTP 网关把用户/模型生成的参数转发到内部 REST API”的系统都适用:边界处的类型假设永远比内部便宜一个数量级地容易失效,而 preprocess-then-assert 的强转策略让每一层只承担一种职责。

登录后查看全文
热门项目推荐
相关项目推荐