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 (
jsonRecord catches 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
undefined case specifically: drop undefined-valued object properties (and lower undefined array elements to null) during the clone, exactly as JSON.stringify does. Everything else stays strict.
- If strict-on-
undefined is deliberate, document it prominently on lowerMcpResult / Mcp.Result as 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).
Package:
@agent-bundle/rsc-runtime(lowerMcpResult), observed at pkg.pr.new preview0.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 withJSON.stringify, which silently dropsundefined-valued properties. The RSC lowering throws instead:The strict clone (
cloneJsonValue) rejectsundefinedanywhere: object property values, and also array elements ([1, undefined], whichJSON.stringifywould emit as[1,null])._metagoes through the same path.Why it matters
undefined, then fail at runtime — data-dependent breakage that neither type checks nor happy-path testing catch.jsonRecordcatches the inner reason and rethrows a fixed message), so in a deep payload there is no pointer to the offending key.Current workaround
One JSON round-trip in each operation's
execute, restoring the exact wire semantics before render/lowering: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:
undefinedcase specifically: dropundefined-valued object properties (and lowerundefinedarray elements tonull) during the clone, exactly asJSON.stringifydoes. Everything else stays strict.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.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).