claude-mem 中的 MCP 类型强转修复:get_observations 的 ids 序列化根因与防御式 API 设计
claude-mem 通过 MCP(Model Context Protocol)工具向各类 Agent 暴露 search、timeline、get_observations 等记忆检索能力,其中 get_observations 曾因不同 MCP 客户端对 ids 参数的序列化差异(原生数组 vs JSON 字符串 vs 逗号分隔字符串)而在 worker 端校验失败。本文基于仓库内根因修复记录 TRIAGE-02-MCP-Type-Coercion.md 展开,结合 DataRoutes.ts、validateBody.ts 与 data-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.ts 中 get_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)已经把 concepts、files、obs_type、type 等参数的逗号分隔字符串切分为数组,因此搜索链路无需重复工作。这提示了一个可复用的排查思路:同一仓库中不同端点对同类入参的处理策略可能不一致,修复前应先盘点已有的归一化实现,避免重复造轮子。
修复方案:内联强转,拒绝过度设计
修复记录给出了明确的任务清单与设计约束:
- 在
handleGetObservationsByIds的Array.isArray(ids)校验之前,先把字符串形态的ids转换为数组; - 对
handleGetSdkSessionsByIds的memorySessionIds应用同一模式; - 不创建独立的工具模块或中间件——强转内联在使用点;
- 强转之后保留原有的
Array.isArray与Number.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 端点的既有失败与本修复无关。
可复用经验:跨协议边界的入参防御清单
从这次修复中,可以提炼出在“外部客户端 → 内部服务”这类跨协议边界上做入参防御的具体做法,均能在本仓库找到对应证据:
- 先盘点既有归一化逻辑再动手。
SearchOrchestrator.normalizeParams()已覆盖搜索侧的字符串转数组,本次只补数据批量端点,避免同一问题两处实现漂移(见 SearchOrchestrator.ts); - 强转优先于拒绝,但强转后必须复验。
z.preprocess只负责“尽力解析”,最终的z.array(z.number().int())断言保证脏数据(foo,bar→NaN)仍以 400 被拦下,而不是进入 SQL 层; - 强转逻辑内联在路由文件内,不新建中间件模块。约 3 行(或一个 preprocess Schema)的修复面,比抽象层更便宜、更易审查——这是修复记录明确的反过度设计约束;
- 上游是 hook/Agent 时,写路径用“软失败”契约:
{ stored: false, reason }而非 500,保证采集链路的异常不反噬宿主会话(见 SessionRoutes.ts#L497); - 测试覆盖“形态矩阵”而非只测 happy path:原生数组、JSON 字符串、逗号字符串、空白填充、非法值、缺失、空数组七种形态各一例,是这类强转逻辑的最小完备验证集(见 data-routes-coercion.test.ts)。
这套模式对任何“通过 MCP、插件或 HTTP 网关把用户/模型生成的参数转发到内部 REST API”的系统都适用:边界处的类型假设永远比内部便宜一个数量级地容易失效,而 preprocess-then-assert 的强转策略让每一层只承担一种职责。
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 StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00