Repository navigation
lowerMcpResult throws on undefined values inside structuredContent where MCP SDK serialization drops them #44
Description
Package: @agent-bundle/rsc-runtime (lowerMcpResult), observed at pkg.pr.new preview 0.0.0-preview-4e546516 (main @ 4e546516).
What we tried
Porting 42 existing MCP tool handlers (movie-library) whose structured payloads routinely carry optional fields in the natural SDK style ({ ...report, note: maybeNote() }). Against the MCP SDK these serialize onto the wire with JSON.stringify, which silently drops undefined-valued properties. The RSC lowering throws instead:
import { Mcp, lowerMcpResult } from '@agent-bundle/rsc-runtime';
lowerMcpResult(
<Mcp.Result structuredContent={{ count: 3, note: undefined }}>
<Mcp.Text>ok</Mcp.Text>
</Mcp.Result>,
);
// throws: mcp-result structuredContent must be JSON-serializable
// SDK wire behavior for the same object: {"count":3}The strict clone (cloneJsonValue) rejects undefined anywhere: object property values, and also array elements ([1, undefined], which JSON.stringify would emit as [1,null]). _meta goes through the same path.
Why it matters
- Handlers written against SDK semantics work until some input path leaves an optional field
undefined, then fail at runtime — data-dependent breakage that neither type checks nor happy-path testing catch. - The error is generic (
jsonRecordcatches the inner reason and rethrows a fixed message), so in a deep payload there is no pointer to the offending key. - Ports must audit every handler for optional fields, or blanket-wrap results (below).
Current workaround
One JSON round-trip in each operation's execute, restoring the exact wire semantics before render/lowering:
const jsonWireClone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
// defineOperation({ execute: async (input) => jsonWireClone(await legacyHandler(input)), … })Suggested fix direction
The strictness may well be intentional — the same clone also catches cycles, non-plain objects, accessors, sparse arrays. Suggestions in preference order:
- Match SDK semantics for the
undefinedcase specifically: dropundefined-valued object properties (and lowerundefinedarray elements tonull) during the clone, exactly asJSON.stringifydoes. Everything else stays strict. - If strict-on-
undefinedis deliberate, document it prominently onlowerMcpResult/Mcp.Resultas a departure from SDK wire behavior, and include the offending key path in the error message so the failing field is findable. - Optionally expose the choice (strict by default, wire-compatible opt-in) so ports can adopt incrementally.
Relevant code: packages/rsc-runtime/src/lower-mcp.ts (cloneJsonValue, jsonRecord, lowerMcpResult).
Related: #42, #43 — sibling framework gaps surfaced by the same consumer port (movie-library on preview 4e546516).
Activity
rsc-runtime: first-class MCP Apps, listing title/_meta passthrough, S…