From 3e8fb2fcdd6c7e7da7661925138d735eaa3378a1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 30 Aug 2026 13:05:18 +0000 Subject: [PATCH 1/4] fix(rsc-runtime): match MCP SDK wire semantics for undefined in lowerMcpResult (#44) Object properties whose value is undefined are dropped and undefined array elements lower to null, exactly as JSON.stringify serializes them, so handlers written against SDK serialization stop failing when an optional field stays undefined. Cycles, accessors, sparse arrays, non-finite numbers, and non-plain objects are still rejected, and the JSON-boundary error now names the offending key path. --- .../lower-mcp-undefined-wire-semantics.md | 13 ++++ packages/rsc-runtime/README.md | 5 ++ packages/rsc-runtime/src/lower-mcp.ts | 42 +++++++----- packages/rsc-runtime/tests/runtime.test.ts | 64 +++++++++++++++++++ 4 files changed, 107 insertions(+), 17 deletions(-) create mode 100644 .changeset/lower-mcp-undefined-wire-semantics.md diff --git a/.changeset/lower-mcp-undefined-wire-semantics.md b/.changeset/lower-mcp-undefined-wire-semantics.md new file mode 100644 index 000000000..e37818d33 --- /dev/null +++ b/.changeset/lower-mcp-undefined-wire-semantics.md @@ -0,0 +1,13 @@ +--- +"@agent-bundle/rsc-runtime": patch +--- + +`lowerMcpResult` now follows MCP SDK wire semantics for `undefined` inside +`structuredContent` and `_meta`: object properties whose value is `undefined` +are dropped and `undefined` array elements lower to `null`, exactly as +`JSON.stringify` serializes them (#44). Handlers written against SDK +serialization no longer fail at runtime when an optional field stays +`undefined` on some input path. Every other strict rejection — cycles, +accessors, sparse arrays, non-finite numbers, non-plain objects — is +preserved, and the JSON-boundary error now names the offending key path +instead of a fixed message. diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 0c4a158aa..c0129a698 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -21,6 +21,11 @@ transport, persistence, or host packaging. React 19 is a peer dependency and Nod Structured MCP metadata and content are copied through a strict finite-JSON boundary before being returned, so later caller mutations do not alter a result. +The copy follows MCP SDK wire semantics for `undefined`: object properties whose +value is `undefined` are dropped and `undefined` array elements lower to `null`, +exactly as `JSON.stringify` serializes them. Values that cannot round-trip as +JSON — cycles, accessors, sparse arrays, non-finite numbers, non-plain objects — +are still rejected, and the error names the offending key path. ## Complete plugin applications diff --git a/packages/rsc-runtime/src/lower-mcp.ts b/packages/rsc-runtime/src/lower-mcp.ts index 46fd9728e..c28221920 100644 --- a/packages/rsc-runtime/src/lower-mcp.ts +++ b/packages/rsc-runtime/src/lower-mcp.ts @@ -53,14 +53,17 @@ const isArrayIndex = (key: string, length: number): boolean => { return Number.isSafeInteger(index) && index < length; }; -const cloneJsonValue = (value: unknown, ancestors: Set): JsonValue => { +const jsonPathError = (reason: string, path: string): Error => + new Error(path === '' ? reason : `${reason} at ${path}`); + +const cloneJsonValue = (value: unknown, ancestors: Set, path: string): JsonValue => { if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; if (typeof value === 'number') { - if (!Number.isFinite(value)) throw new Error('non-finite number'); + if (!Number.isFinite(value)) throw jsonPathError('non-finite number', path); return value; } - if (typeof value !== 'object') throw new Error('non-JSON value'); - if (ancestors.has(value)) throw new Error('cyclic value'); + if (typeof value !== 'object') throw jsonPathError('non-JSON value', path); + if (ancestors.has(value)) throw jsonPathError('cyclic value', path); ancestors.add(value); try { @@ -70,29 +73,34 @@ const cloneJsonValue = (value: unknown, ancestors: Set): JsonValue => { keys.length !== value.length + 1 || keys.some((key) => key !== 'length' && (typeof key !== 'string' || !isArrayIndex(key, value.length))) ) { - throw new Error('sparse or decorated array'); + throw jsonPathError('sparse or decorated array', path); } const clone: JsonValue[] = []; for (let index = 0; index < value.length; index += 1) { - if (!Object.hasOwn(value, index)) throw new Error('sparse array'); + const elementPath = `${path}[${index}]`; + if (!Object.hasOwn(value, index)) throw jsonPathError('sparse array', elementPath); const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if (descriptor === undefined || !('value' in descriptor)) throw new Error('array accessor'); - clone.push(cloneJsonValue(descriptor.value, ancestors)); + if (descriptor === undefined || !('value' in descriptor)) throw jsonPathError('array accessor', elementPath); + // JSON.stringify serializes undefined array elements as null; match the SDK wire shape. + clone.push(descriptor.value === undefined ? null : cloneJsonValue(descriptor.value, ancestors, elementPath)); } return clone; } const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) throw new Error('non-plain object'); + if (prototype !== Object.prototype && prototype !== null) throw jsonPathError('non-plain object', path); const clone: { [key: string]: JsonValue } = Object.create(null) as { [key: string]: JsonValue }; for (const key of Reflect.ownKeys(value)) { - if (typeof key !== 'string') throw new Error('symbol key'); + if (typeof key !== 'string') throw jsonPathError('symbol key', path); + const propertyPath = path === '' ? key : `${path}.${key}`; const descriptor = Object.getOwnPropertyDescriptor(value, key); if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { - throw new Error('non-enumerable or accessor property'); + throw jsonPathError('non-enumerable or accessor property', propertyPath); } - clone[key] = cloneJsonValue(descriptor.value, ancestors); + // JSON.stringify drops undefined-valued properties; match the SDK wire shape. + if (descriptor.value === undefined) continue; + clone[key] = cloneJsonValue(descriptor.value, ancestors, propertyPath); } return clone; } finally { @@ -103,15 +111,15 @@ const cloneJsonValue = (value: unknown, ancestors: Set): JsonValue => { const jsonRecord = (value: unknown, message: string): Record => { try { if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('structured content must be an object'); + throw new Error('not a plain object'); } - const clone = cloneJsonValue(value, new Set()); + const clone = cloneJsonValue(value, new Set(), ''); if (Array.isArray(clone) || clone === null || typeof clone !== 'object') { - throw new Error('structured content must be an object'); + throw new Error('not a plain object'); } return clone; - } catch { - throw new Error(message); + } catch (error) { + throw new Error(`${message} (${error instanceof Error ? error.message : String(error)})`); } }; diff --git a/packages/rsc-runtime/tests/runtime.test.ts b/packages/rsc-runtime/tests/runtime.test.ts index f604aa972..8a132511c 100644 --- a/packages/rsc-runtime/tests/runtime.test.ts +++ b/packages/rsc-runtime/tests/runtime.test.ts @@ -34,6 +34,70 @@ describe('@agent-bundle/rsc-runtime', () => { }); }); + it('follows SDK wire semantics for undefined inside structured content and _meta', () => { + // Repro from issue #44: handlers written against SDK serialization leave + // optional fields undefined; JSON.stringify drops them from objects and + // lowers them to null inside arrays. + const structuredContent = { + count: 3, + items: [1, undefined, 'x', { keep: true, nested: undefined }], + note: undefined, + report: { deep: { drop: undefined }, keep: 'y' }, + }; + const result = lowerMcpResult(createElement( + Mcp.Result, + { + _meta: { note: undefined, ui: { resourceUri: 'ui://demo/widget.html', subtitle: undefined } }, + structuredContent, + }, + createElement(Mcp.Text, null, 'ok'), + )); + + expect(result.structuredContent).toEqual(JSON.parse(JSON.stringify(structuredContent))); + expect(JSON.stringify(result.structuredContent)).toBe( + '{"count":3,"items":[1,null,"x",{"keep":true}],"report":{"deep":{},"keep":"y"}}', + ); + expect(Object.hasOwn(result.structuredContent!, 'note')).toBe(false); + expect(result._meta).toEqual({ ui: { resourceUri: 'ui://demo/widget.html' } }); + expect(Object.hasOwn(result._meta!, 'note')).toBe(false); + }); + + it('keeps an all-undefined structured content object as an empty record', () => { + const result = lowerMcpResult(createElement( + Mcp.Result, + { structuredContent: { note: undefined } }, + createElement(Mcp.Text, null, 'ok'), + )); + expect(JSON.stringify(result.structuredContent)).toBe('{}'); + }); + + it('still rejects non-wire JSON shapes and points at the offending key path', () => { + const lower = (structuredContent: unknown) => () => lowerMcpResult( + createElement(Mcp.Result, { structuredContent }, createElement(Mcp.Text, null, 'ok')), + ); + + const cyclic: Record = { name: 'loop' }; + cyclic.self = { inner: cyclic }; + expect(lower(cyclic)).toThrow( + 'mcp-result structuredContent must be JSON-serializable (cyclic value at self.inner)', + ); + expect(lower({ report: { get bad() { return 1; } } })).toThrow( + 'mcp-result structuredContent must be JSON-serializable (non-enumerable or accessor property at report.bad)', + ); + expect(lower({ items: Object.assign([], { 0: 1, 2: 2, length: 3 }) })).toThrow( + 'mcp-result structuredContent must be JSON-serializable (sparse or decorated array at items)', + ); + expect(lower({ callback: () => undefined })).toThrow( + 'mcp-result structuredContent must be JSON-serializable (non-JSON value at callback)', + ); + expect(lower({ ratio: Number.POSITIVE_INFINITY })).toThrow( + 'mcp-result structuredContent must be JSON-serializable (non-finite number at ratio)', + ); + expect(lower({ wrapped: new Map() })).toThrow( + 'mcp-result structuredContent must be JSON-serializable (non-plain object at wrapped)', + ); + }); + it('resolves synchronous server components around MCP protocol elements', () => { const Status = ({ value }: { readonly value: string }) => createElement( Mcp.Result, From 568c5b60ef79444aa957cb49ce2da712766e8b94 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 30 Aug 2026 13:09:30 +0000 Subject: [PATCH 2/4] feat(rsc-runtime): carry listing-level title and _meta through createRscMcpServer, annotations verbatim (#43) RscMcpDefinition gains optional title and _meta; defineOperation preserves them through the JSON wire boundary (deep-frozen) and createRscMcpServer forwards both into registerTool, so MCP Apps hosts can bind widgets via _meta.ui.resourceUri. The factory also stops synthesizing annotation defaults - only declared hints reach the wire, where absent hints keep MCP-spec default semantics. Wire-level regression test taps the transport send to assert the serialized listing byte shape. --- .changeset/rsc-mcp-listing-title-meta.md | 13 ++ packages/rsc-runtime/README.md | 9 + packages/rsc-runtime/package.json | 1 + packages/rsc-runtime/src/lower-mcp.ts | 19 ++ packages/rsc-runtime/src/mcp-server.ts | 11 +- packages/rsc-runtime/src/operation.ts | 10 ++ .../rsc-runtime/tests/mcp-server-wire.test.ts | 166 ++++++++++++++++++ packages/rsc-runtime/tests/plugin-app.test.ts | 32 ++++ pnpm-lock.yaml | 3 + 9 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 .changeset/rsc-mcp-listing-title-meta.md create mode 100644 packages/rsc-runtime/tests/mcp-server-wire.test.ts diff --git a/.changeset/rsc-mcp-listing-title-meta.md b/.changeset/rsc-mcp-listing-title-meta.md new file mode 100644 index 000000000..2741cf87b --- /dev/null +++ b/.changeset/rsc-mcp-listing-title-meta.md @@ -0,0 +1,13 @@ +--- +"@agent-bundle/rsc-runtime": minor +--- + +`RscMcpDefinition` gains optional listing-level `title` and `_meta` slots; +`defineOperation` preserves them (with the same JSON wire-boundary +validation as result lowering, deep-frozen) and `createRscMcpServer` +forwards both verbatim into tool registration, so MCP Apps hosts can bind +widgets through `_meta.ui.resourceUri` (#43). The server factory also stops +synthesizing annotation defaults: it emits exactly the hints an operation +declares (`readOnly`, plus `destructive` / `idempotent` / `openWorld` when +present), because an absent hint carries MCP-spec default semantics on the +wire that a synthesized `false` silently rewrote. diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index c0129a698..b9ae10e7b 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -60,10 +60,12 @@ const status = defineOperation({ id: 'status', inputSchema, mcp: { + _meta: { ui: { resourceUri: 'ui://example/status.html' } }, description: 'Read status.', name: 'runtime_status', readOnly: true, server: 'runtime', + title: 'Runtime status', }, render: (result) => ( @@ -90,5 +92,12 @@ implementations, output validation, and result renderers cannot drift between th two surfaces. Definition lowering rejects duplicate ownership and references to undeclared MCP servers before Agent Bundle compilation begins. +The optional `mcp.title` and `mcp._meta` ride the tool listing verbatim — +`_meta: { ui: { resourceUri } }` is how MCP Apps hosts bind a tool to its +widget. `createRscMcpServer` registers exactly the annotation hints an +operation declares (`readOnly`, plus `destructive` / `idempotent` / +`openWorld` when present); absent hints stay absent on the wire, where they +keep their MCP-spec default semantics. + This layer intentionally does not own transport persistence or application state. Those remain explicit dependencies of operation implementations. diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index 134149de6..19820b476 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -54,6 +54,7 @@ "zod": "4.4.3" }, "devDependencies": { + "@modelcontextprotocol/client": "2.0.0", "@rslib/core": "0.23.2", "@rstest/core": "0.11.10", "@types/react": "19.2.18", diff --git a/packages/rsc-runtime/src/lower-mcp.ts b/packages/rsc-runtime/src/lower-mcp.ts index c28221920..f2239c4e7 100644 --- a/packages/rsc-runtime/src/lower-mcp.ts +++ b/packages/rsc-runtime/src/lower-mcp.ts @@ -123,6 +123,25 @@ const jsonRecord = (value: unknown, message: string): Record } }; +const deepFreezeJson = (value: JsonValue): JsonValue => { + if (typeof value === 'object' && value !== null) { + for (const child of Object.values(value)) deepFreezeJson(child); + Object.freeze(value); + } + return value; +}; + +/** + * Copies declaration-level metadata through the same JSON wire boundary as + * MCP results and deep-freezes the copy, so frozen definitions cannot be + * mutated through their metadata after the fact. + */ +export const frozenJsonRecord = (value: unknown, message: string): Readonly> => { + const clone = jsonRecord(value, message); + deepFreezeJson(clone); + return clone; +}; + const lowerContent = (node: ReactNode): CallToolResult['content'][number] => { const element = asMcpElement(node); const { props } = element; diff --git a/packages/rsc-runtime/src/mcp-server.ts b/packages/rsc-runtime/src/mcp-server.ts index 0de998a8d..5c5cb5d6f 100644 --- a/packages/rsc-runtime/src/mcp-server.ts +++ b/packages/rsc-runtime/src/mcp-server.ts @@ -16,14 +16,19 @@ export const createRscMcpServer = ( for (const operation of application.operations) { if (operation.mcp?.server !== serverName) continue; server.registerTool(operation.mcp.name, { + ...(operation.mcp._meta === undefined ? {} : { _meta: operation.mcp._meta }), + // Emit exactly the hints the author declared: an absent hint carries + // MCP-spec default semantics on the wire, so synthesizing values here + // would rewrite the author's contract. annotations: { - destructiveHint: operation.mcp.destructive ?? false, - idempotentHint: operation.mcp.idempotent ?? operation.mcp.readOnly, - openWorldHint: operation.mcp.openWorld ?? false, + ...(operation.mcp.destructive === undefined ? {} : { destructiveHint: operation.mcp.destructive }), + ...(operation.mcp.idempotent === undefined ? {} : { idempotentHint: operation.mcp.idempotent }), + ...(operation.mcp.openWorld === undefined ? {} : { openWorldHint: operation.mcp.openWorld }), readOnlyHint: operation.mcp.readOnly, }, description: operation.mcp.description, inputSchema: operation.inputSchema, + ...(operation.mcp.title === undefined ? {} : { title: operation.mcp.title }), }, async (input, context) => { const result = await operation.execute(input, { signal: context.mcpReq.signal }); return lowerMcpResult(operation.render(result)); diff --git a/packages/rsc-runtime/src/operation.ts b/packages/rsc-runtime/src/operation.ts index b968fdda0..1c2fe80e8 100644 --- a/packages/rsc-runtime/src/operation.ts +++ b/packages/rsc-runtime/src/operation.ts @@ -1,6 +1,8 @@ import type { ReactNode } from 'react'; import type { ZodType } from 'zod'; +import { frozenJsonRecord } from './lower-mcp.js'; + export interface RscOperationContext { readonly signal: AbortSignal; } @@ -14,6 +16,8 @@ export interface RscCliDefinition { } export interface RscMcpDefinition { + /** Listing-level metadata forwarded verbatim to tool registration, e.g. `{ ui: { resourceUri } }` for MCP Apps widget binding. */ + readonly _meta?: Readonly>; readonly destructive?: boolean; readonly description: string; readonly idempotent?: boolean; @@ -21,6 +25,8 @@ export interface RscMcpDefinition { readonly openWorld?: boolean; readonly readOnly: boolean; readonly server: string; + /** Human-readable tool listing title. */ + readonly title?: string; } export interface RscOperationInput { @@ -79,6 +85,9 @@ export const defineOperation = ( const mcp = input.mcp === undefined ? undefined : Object.freeze({ + ...(input.mcp._meta === undefined + ? {} + : { _meta: frozenJsonRecord(input.mcp._meta, `Operation ${id} MCP _meta must be JSON-serializable`) }), ...(input.mcp.destructive === undefined ? {} : { destructive: input.mcp.destructive }), description: requireText(input.mcp.description, `Operation ${id} MCP description`), ...(input.mcp.idempotent === undefined ? {} : { idempotent: input.mcp.idempotent }), @@ -86,6 +95,7 @@ export const defineOperation = ( ...(input.mcp.openWorld === undefined ? {} : { openWorld: input.mcp.openWorld }), readOnly: input.mcp.readOnly, server: requireName(input.mcp.server, `Operation ${id} MCP server`), + ...(input.mcp.title === undefined ? {} : { title: requireText(input.mcp.title, `Operation ${id} MCP title`) }), }); return Object.freeze({ diff --git a/packages/rsc-runtime/tests/mcp-server-wire.test.ts b/packages/rsc-runtime/tests/mcp-server-wire.test.ts new file mode 100644 index 000000000..726a9f699 --- /dev/null +++ b/packages/rsc-runtime/tests/mcp-server-wire.test.ts @@ -0,0 +1,166 @@ +// Wire-level regression coverage for issue #43: a real MCP client connects +// over an in-memory transport and the test taps the server transport's send +// to capture the serialized JSON-RPC payload. Listing-level `title` and +// `_meta` must survive registration verbatim, and `annotations` must carry +// exactly the hints the author declared — absent hints stay absent because +// they carry MCP-spec default semantics on the wire. The client-side parsed +// objects rehydrate optional keys as undefined, so only the serialized +// payload proves the byte shape. +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { afterAll, describe, expect, it } from '@rstest/core'; +import { createElement } from 'react'; +import { z } from 'zod'; + +import { + AgentBundle, + McpServer, + Mcp, + Operation, + createRscMcpServer, + defineOperation, + defineRscAgentBundle, +} from '../src/index.js'; + +const widgetResourceUri = 'ui://demo/widget.html'; + +const searchOperation = defineOperation({ + execute: async () => ({ count: 3, note: undefined }), + id: 'search', + inputSchema: z.object({}).strict(), + mcp: { + _meta: { ui: { resourceUri: widgetResourceUri } }, + description: 'Unified search across indexers.', + name: 'search', + readOnly: true, + server: 'demo', + title: 'Search', + }, + render: (result) => createElement( + Mcp.Result, + { structuredContent: result }, + createElement(Mcp.Text, null, `${result.count} results`), + ), + resultSchema: z.object({ count: z.number(), note: z.string().optional() }).strict(), +}); + +const removeOperation = defineOperation({ + execute: async () => ({ removed: true }), + id: 'remove', + inputSchema: z.object({}).strict(), + mcp: { + description: 'Remove one entry.', + destructive: true, + name: 'remove', + openWorld: false, + readOnly: false, + server: 'demo', + }, + render: (result) => createElement( + Mcp.Result, + { structuredContent: result }, + createElement(Mcp.Text, null, 'removed'), + ), + resultSchema: z.object({ removed: z.boolean() }).strict(), +}); + +const application = defineRscAgentBundle(createElement( + AgentBundle, + { name: 'wire-demo', targets: ['claude'], version: '1.0.0' }, + createElement(McpServer, { entry: './src/mcp-server.ts', name: 'demo' }), + createElement(Operation, { definition: searchOperation }), + createElement(Operation, { definition: removeOperation }), +)); + +interface WireTool { + readonly _meta?: Record; + readonly annotations?: Record; + readonly name: string; + readonly title?: string; +} + +const openClients: Client[] = []; + +/** + * Connects a real client and returns the serialized JSON-RPC responses the + * server put on the wire, keyed by request id. Serialization through + * JSON.stringify mirrors what every real transport does to a message, so + * undefined-valued keys disappear here exactly as they would on stdio. + */ +const connectClient = async (): Promise<{ + readonly client: Client; + readonly wireResults: Map>; +}> => { + const server = createRscMcpServer(application, 'demo'); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const wireResults = new Map>(); + const originalSend = serverTransport.send.bind(serverTransport); + serverTransport.send = async (message, options) => { + const serialized = JSON.parse(JSON.stringify(message)) as Record; + if (serialized.id !== undefined && serialized.result !== undefined) { + wireResults.set(serialized.id as number | string, serialized.result as Record); + } + return originalSend(message, options); + }; + const client = new Client({ name: 'wire-listing-test', version: '0.0.0' }); + openClients.push(client); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { client, wireResults }; +}; + +const wireToolListing = async (): Promise> => { + const { client, wireResults } = await connectClient(); + const parsed = await client.listTools(); + expect(parsed.tools).toHaveLength(2); + const listing = [...wireResults.values()].find((result) => Array.isArray(result.tools)); + const tools = (listing?.tools ?? []) as readonly WireTool[]; + return new Map(tools.map((tool) => [tool.name, tool])); +}; + +afterAll(async () => { + await Promise.allSettled(openClients.map((client) => client.close())); +}); + +describe('createRscMcpServer wire listing', () => { + it('serves listing title and _meta verbatim on the wire', async () => { + const tools = await wireToolListing(); + + const search = tools.get('search'); + expect(search?.title).toBe('Search'); + expect(search?._meta).toEqual({ ui: { resourceUri: widgetResourceUri } }); + + const remove = tools.get('remove'); + expect(remove).toBeDefined(); + expect(Object.hasOwn(remove!, 'title')).toBe(false); + expect(Object.hasOwn(remove!, '_meta')).toBe(false); + }); + + it('emits exactly the annotation hints the author declared — absent stays absent', async () => { + const tools = await wireToolListing(); + + // The author declared ONLY readOnly — no synthesized destructiveHint / + // idempotentHint / openWorldHint may appear on the wire. + expect(tools.get('search')?.annotations).toEqual({ readOnlyHint: true }); + expect(Object.keys(tools.get('search')?.annotations ?? {})).toEqual(['readOnlyHint']); + + expect(tools.get('remove')?.annotations).toEqual({ + destructiveHint: true, + openWorldHint: false, + readOnlyHint: false, + }); + expect(Object.keys(tools.get('remove')?.annotations ?? {}).sort()).toEqual([ + 'destructiveHint', + 'openWorldHint', + 'readOnlyHint', + ]); + }); + + it('drops undefined structured content fields on the wire, matching SDK serialization', async () => { + const { client, wireResults } = await connectClient(); + const parsed = await client.callTool({ arguments: {}, name: 'search' }); + expect(parsed.structuredContent).toEqual({ count: 3 }); + const wireCall = [...wireResults.values()].find((result) => result.structuredContent !== undefined); + expect(wireCall?.structuredContent).toEqual({ count: 3 }); + expect(Object.hasOwn(wireCall?.structuredContent as object, 'note')).toBe(false); + }); +}); diff --git a/packages/rsc-runtime/tests/plugin-app.test.ts b/packages/rsc-runtime/tests/plugin-app.test.ts index 3afd6a14a..8f64a6878 100644 --- a/packages/rsc-runtime/tests/plugin-app.test.ts +++ b/packages/rsc-runtime/tests/plugin-app.test.ts @@ -126,6 +126,38 @@ describe('RSC plugin applications', () => { } }); + it('preserves listing title and _meta on the frozen MCP definition without sharing the caller object', () => { + const metadata: Record = { ui: { resourceUri: 'ui://curator/widget.html' } }; + const withExtras = (mcp: Record) => defineOperation({ + execute: async () => ({ ok: true }), + id: 'extras', + inputSchema: z.object({}).strict(), + mcp: { + description: 'Extras probe.', + name: 'extras', + readOnly: true, + server: 'curator', + ...mcp, + }, + render: () => createElement('mcp-result', null, createElement('mcp-text', null, 'ok')), + resultSchema: z.object({ ok: z.boolean() }).strict(), + }); + + const operation = withExtras({ _meta: metadata, title: 'Extras' }); + (metadata.ui as Record).resourceUri = 'ui://curator/changed.html'; + expect(operation.mcp?.title).toBe('Extras'); + expect(operation.mcp?._meta).toEqual({ ui: { resourceUri: 'ui://curator/widget.html' } }); + expect(Object.isFrozen(operation.mcp?._meta)).toBe(true); + expect(Object.isFrozen(operation.mcp?._meta?.ui)).toBe(true); + + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(() => withExtras({ _meta: cyclic })).toThrow( + 'Operation extras MCP _meta must be JSON-serializable (cyclic value at self)', + ); + expect(() => withExtras({ title: ' ' })).toThrow('Operation extras MCP title must be non-empty and bounded'); + }); + it('rejects operations whose MCP owner does not exist', () => { expect(() => defineRscAgentBundle( createElement( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20faab0d0..9304e3f3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,9 @@ importers: specifier: 4.4.3 version: 4.4.3 devDependencies: + '@modelcontextprotocol/client': + specifier: 2.0.0 + version: 2.0.0 '@rslib/core': specifier: 0.23.2 version: 0.23.2(typescript@7.0.2) From e55384db0df515a26c33625b9b1fd1078b43df98 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 30 Aug 2026 15:09:24 +0000 Subject: [PATCH 3/4] feat: declare MCP Apps in the RSC element tree and share one app across servers (#42) McpApp children of McpServer lower into the owning server's apps record, so defineRscAgentBundle stays the single source of truth for widget-bearing plugins instead of a config-side splice. Lowering validates names, paths, ui:// resource URIs, target subsets, and JSON _meta, and admits the same app on several servers only as one identical shared declaration. The compiler now supports that shared case end to end: identical same-name declarations compile once into one mcp-apps/.html output whose registry entry reaches every declaring server's agent-bundle/mcp-apps virtual module (CompiledMcpApp.serverId becomes serverIds), and source validation flags only conflicting redeclarations (AB4325) or resource URIs spread across app names (AB4330). --- .changeset/mcp-apps-shared-across-servers.md | 12 ++ .changeset/rsc-mcp-app-element.md | 14 ++ README.md | 2 +- packages/agent-bundle/src/build/entries.ts | 4 +- packages/agent-bundle/src/build/mcp-apps.ts | 71 ++++++---- packages/agent-bundle/src/config/validate.ts | 44 +++++-- packages/agent-bundle/tests/mcp.test.ts | 121 +++++++++++++++++- packages/rsc-runtime/README.md | 22 +++- packages/rsc-runtime/src/lower-mcp.ts | 2 +- packages/rsc-runtime/src/plugin-definition.ts | 93 +++++++++++++- packages/rsc-runtime/src/plugin-elements.ts | 17 ++- packages/rsc-runtime/src/plugin.ts | 3 +- packages/rsc-runtime/tests/plugin-app.test.ts | 106 +++++++++++++++ 13 files changed, 463 insertions(+), 48 deletions(-) create mode 100644 .changeset/mcp-apps-shared-across-servers.md create mode 100644 .changeset/rsc-mcp-app-element.md diff --git a/.changeset/mcp-apps-shared-across-servers.md b/.changeset/mcp-apps-shared-across-servers.md new file mode 100644 index 000000000..07e828007 --- /dev/null +++ b/.changeset/mcp-apps-shared-across-servers.md @@ -0,0 +1,12 @@ +--- +"agent-bundle": minor +--- + +One MCP App can now be served by several local servers (#42): declaring the +same app name with an identical definition (`entry`, `resourceUri`, +`template`, `_meta`; per-server `targets` may differ) under multiple servers +compiles the view once into one `mcp-apps/.html` output and includes +it in every declaring server's `agent-bundle/mcp-apps` registry, instead of +failing as a duplicate compiled destination. Validation now flags only +conflicting redeclarations of an app name (AB4325) and resource URIs spread +across different app names (AB4330); identical shared declarations pass. diff --git a/.changeset/rsc-mcp-app-element.md b/.changeset/rsc-mcp-app-element.md new file mode 100644 index 000000000..0806c918f --- /dev/null +++ b/.changeset/rsc-mcp-app-element.md @@ -0,0 +1,14 @@ +--- +"@agent-bundle/rsc-runtime": minor +--- + +`defineRscAgentBundle` element trees can declare MCP Apps first-class: +`` children of `` lower into the owning server's +`mcp.servers[].apps` record (#42), so `application.config` stays the +single source of truth for widget-bearing plugins instead of a config-side +splice. App names, entries, templates, `ui://` resource URIs, target +subsets, and JSON `_meta` are validated during lowering; app `targets` +default to the owning server's targets. The same `` may be declared +on several servers when the definitions are identical — the shared-app case +the compiler now supports — while conflicting redeclarations and resource +URIs spread across different app names are rejected. diff --git a/README.md b/README.md index d2f8e40de..423518c22 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ export default defineConfig({ `scripts` is a record: its key is the stable output name and each value is either an entry path or `{ entry, targets? }`. JavaScript-family entries (`.js`, `.jsx`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, `.cts`) are bundled. Shell and Python entries (`.sh`, `.bash`, `.py`) are copied byte-for-byte and keep the source permission mode. Every target receives selected scripts at `scripts/.mjs` for bundled entries or `scripts/` for copied entries. -Skills follow the Agent Skills directory layout and may contain references and binary assets. Local MCP server entries and hook handlers are bundled. A local MCP App may import its generated browser resource list with `import apps from 'agent-bundle/mcp-apps'`; the generated resource uses the configured `resourceUri` and metadata. +Skills follow the Agent Skills directory layout and may contain references and binary assets. Local MCP server entries and hook handlers are bundled. A local MCP App may import its generated browser resource list with `import apps from 'agent-bundle/mcp-apps'`; the generated resource uses the configured `resourceUri` and metadata. Several local servers may serve one shared app by declaring the same app name with an identical definition (`entry`, `resourceUri`, `template`, `_meta`; `targets` may differ per server): the view compiles into one `mcp-apps/.html` output and every declaring server's registry includes it. Conflicting redeclarations of an app name, or one `resourceUri` spread across different app names, stay rejected. The compiler rejects unsafe output names, unsupported extensions, nonexistent or escaping source paths, unknown targets, and output collisions before it stages an artifact. It does not call Codex, Claude, or another host CLI, and it does not require API keys. diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 9773d99a3..193965029 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -152,7 +152,7 @@ export const compileMcpEntries = async ( const compiled = planCompiledMcpEntries(servers, options); const virtualSources = await Promise.all(compiled.map(async (entry) => { const records = await Promise.all((options.apps ?? []) - .filter((app) => app.serverId === entry.id) + .filter((app) => app.serverIds.includes(entry.id)) .map(async (app) => ({ ...(app._meta === undefined ? {} : { _meta: app._meta }), html: await readFile(app.output, 'utf8'), @@ -176,7 +176,7 @@ export const compileMcpEntries = async ( sourceInputs: Object.freeze([ ...sourceInputs, ...(options.apps ?? []) - .filter((app) => app.serverId === id) + .filter((app) => app.serverIds.includes(id)) .flatMap((app) => app.sourceInputs), ]), virtualModules: [{ diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index 89a4a145a..03b7b2094 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises'; import { extname, resolve } from 'node:path'; import type { NormalizedMcpApp } from '../core/types.ts'; +import { stableJson } from '../core/digest.ts'; import { listArtifactFiles, resolveArtifactDestination } from './emit.ts'; import { collectBundledOutputEvidence } from './provenance.ts'; @@ -16,7 +17,8 @@ export interface CompiledMcpApp { readonly name: string; readonly output: string; readonly resourceUri: string; - readonly serverId: string; + /** Every server serving this compiled app; several when servers share one identical declaration. */ + readonly serverIds: readonly string[]; readonly source: string; readonly sourceInputs: readonly string[]; readonly target: string; @@ -74,35 +76,54 @@ const assertSelfContainedViews = async ( } }; +/** + * The compile-relevant identity of an app declaration. Server declarations + * that agree on it describe one shared app compiled into one output; targets + * may differ because each server selects its own hosts. + */ +const appIdentity = (app: NormalizedMcpApp): string => stableJson({ + ...(app._meta === undefined ? {} : { _meta: app._meta }), + resourceUri: app.resourceUri, + source: app.source, + ...(app.template === undefined ? {} : { template: app.template }), +}); + export const planCompiledMcpApps = ( apps: readonly NormalizedMcpApp[], options: { readonly outDir: string; readonly target: string }, ): readonly CompiledMcpApp[] => { - const names = new Set(); - return Object.freeze(apps - .filter((app) => app.targets.includes(options.target)) - .map((app) => { - if (names.has(app.name)) { - throw new Error(`Duplicate compiled MCP App destination ${JSON.stringify(`mcp-apps/${app.name}.html`)}.`); + const planned = new Map(); + for (const app of apps.filter((candidate) => candidate.targets.includes(options.target))) { + const identity = appIdentity(app); + const existing = planned.get(app.name); + if (existing !== undefined) { + if (existing.identity !== identity) { + throw new Error( + `Duplicate compiled MCP App destination ${JSON.stringify(`mcp-apps/${app.name}.html`)}; ` + + 'servers may share an app name only with an identical declaration.', + ); } - names.add(app.name); - return Object.freeze({ - ...(app._meta === undefined ? {} : { _meta: app._meta }), - id: app.id, - mimeType: mcpAppMimeType, - name: app.name, - output: resolveArtifactDestination(resolve(options.outDir, 'mcp-apps'), `${app.name}.html`), - resourceUri: app.resourceUri, - serverId: app.serverId, - source: app.source, - sourceInputs: Object.freeze([ - app.provenance.sourcePath, - app.source, - ...(app.template === undefined ? [] : [app.template]), - ]), - target: options.target, - }); - })); + if (!existing.serverIds.includes(app.serverId)) existing.serverIds.push(app.serverId); + continue; + } + planned.set(app.name, { app, identity, serverIds: [app.serverId] }); + } + return Object.freeze([...planned.values()].map(({ app, serverIds }) => Object.freeze({ + ...(app._meta === undefined ? {} : { _meta: app._meta }), + id: app.id, + mimeType: mcpAppMimeType, + name: app.name, + output: resolveArtifactDestination(resolve(options.outDir, 'mcp-apps'), `${app.name}.html`), + resourceUri: app.resourceUri, + serverIds: Object.freeze([...serverIds].sort((left, right) => left.localeCompare(right))), + source: app.source, + sourceInputs: Object.freeze([ + app.provenance.sourcePath, + app.source, + ...(app.template === undefined ? [] : [app.template]), + ]), + target: options.target, + }))); }; export const compileMcpApps = async ( diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index d2e2a49c4..851216245 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -2,6 +2,7 @@ import { existsSync, realpathSync, statSync } from 'node:fs'; import { basename, extname, isAbsolute, posix, relative, resolve, sep } from 'node:path'; import type { Diagnostic } from '../core/diagnostics.ts'; +import { stableJson } from '../core/digest.ts'; import { unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts'; import { defaultGeneratedRuntime, @@ -382,12 +383,31 @@ const validUiUri = (value: string): boolean => { } }; +/** + * The declaration identity that lets several servers share one app name as + * one compiled app. Targets stay out of it: each declaring server selects + * its own hosts for the shared output. + */ +const mcpAppIdentity = (app: AgentBundleMcpApp): string | undefined => { + try { + return stableJson({ + ...(app._meta === undefined ? {} : { _meta: app._meta }), + entry: app.entry, + resourceUri: app.resourceUri, + ...(app.template === undefined ? {} : { template: app.template }), + }); + } catch { + // Non-JSON _meta is separately rejected by AB4338; never treat it as shareable. + return undefined; + } +}; + const validateMcpApps = ( name: string, server: AgentBundleMcpServer, loaded: LoadedConfig, - seenNames: Set, - seenUris: Set, + seenApps: Map, + seenUris: Map, ): Diagnostic[] => { if (server.apps === undefined) return []; const diagnostics: Diagnostic[] = []; @@ -408,20 +428,22 @@ const validateMcpApps = ( return diagnostics; } for (const [appName, value] of Object.entries(server.apps)) { + const identity = isRecord(value) ? mcpAppIdentity(value as AgentBundleMcpApp) : undefined; if (!/^[a-z][a-z0-9-]*$/u.test(appName)) { diagnostics.push(sourceDiagnostic( 'AB4324', `MCP App name ${JSON.stringify(appName)} must use stable lowercase kebab-case.`, loaded.configPath, )); - } else if (seenNames.has(appName)) { + } else if (seenApps.has(appName) && (identity === undefined || seenApps.get(appName) !== identity)) { diagnostics.push(sourceDiagnostic( 'AB4325', - `MCP App name ${JSON.stringify(appName)} is duplicated.`, + `MCP App name ${JSON.stringify(appName)} is duplicated with a conflicting definition; ` + + 'servers may share an app name only with an identical declaration.', loaded.configPath, )); } - seenNames.add(appName); + if (!seenApps.has(appName)) seenApps.set(appName, identity); if (!isRecord(value)) { diagnostics.push(sourceDiagnostic( 'AB4326', @@ -450,14 +472,16 @@ const validateMcpApps = ( `MCP App ${JSON.stringify(appName)} resourceUri must use ui:// with a nonempty host.`, loaded.configPath, )); - } else if (seenUris.has(app.resourceUri)) { + } else if (seenUris.has(app.resourceUri) && seenUris.get(app.resourceUri) !== appName) { diagnostics.push(sourceDiagnostic( 'AB4330', - `MCP App resourceUri ${JSON.stringify(app.resourceUri)} is duplicated.`, + `MCP App resourceUri ${JSON.stringify(app.resourceUri)} is declared by more than one app name.`, loaded.configPath, )); } - if (typeof app.resourceUri === 'string') seenUris.add(app.resourceUri); + if (typeof app.resourceUri === 'string' && !seenUris.has(app.resourceUri)) { + seenUris.set(app.resourceUri, appName); + } if (app.template !== undefined) { if (!nonemptyString(app.template)) { diagnostics.push(sourceDiagnostic( @@ -649,8 +673,8 @@ const validateMcp = (loaded: LoadedConfig): Diagnostic[] => { if (!isRecord(mcp.servers)) { return [sourceDiagnostic('AB4301', 'MCP configuration must define a servers object.', loaded.configPath)]; } - const names = new Set(); - const uris = new Set(); + const names = new Map(); + const uris = new Map(); return Object.entries(mcp.servers).flatMap(([name, server]) => { const diagnostics = validateMcpServer(name, server, loaded); return isRecord(server) diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index e74e662ea..bb28cb4e7 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -353,6 +353,7 @@ it('rejects unsafe, duplicate, and nonlocal MCP App declarations before browser await writeFile(join(root, 'src', 'server.ts'), 'export {};\n'); await writeFile(join(root, 'src', 'other.ts'), 'export {};\n'); await writeFile(join(root, 'views', 'dashboard.ts'), 'document.body.textContent = "dashboard";\n'); + await writeFile(join(root, 'views', 'other-dashboard.ts'), 'document.body.textContent = "other";\n'); const malformed = { mcp: { @@ -377,10 +378,16 @@ it('rejects unsafe, duplicate, and nonlocal MCP App declarations before browser }, other: { apps: { - dashboard: { + // AB4330: the resource URI already belongs to app "dashboard". + copycat: { entry: './views/dashboard.ts', resourceUri: 'ui://agent-bundle/dashboard.html', }, + // AB4325: same app name as on "fixture" with a conflicting definition. + dashboard: { + entry: './views/other-dashboard.ts', + resourceUri: 'ui://agent-bundle/dashboard.html', + }, }, entry: './src/other.ts', }, @@ -742,7 +749,7 @@ it('builds one deterministic self-contained MCP App view and injects it through name: 'dashboard', output: join(outputRoot, target, 'mcp-apps', 'dashboard.html'), resourceUri: 'ui://agent-bundle/dashboard.html', - serverId: 'mcp:fixture', + serverIds: ['mcp:fixture'], source: join(root, 'views', 'dashboard.ts'), sourceInputs, target, @@ -790,6 +797,116 @@ it('builds one deterministic self-contained MCP App view and injects it through } }, 30_000); +it('compiles one shared MCP App once and serves it from every identically declaring server', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-app-shared-')); + try { + await mkdir(join(root, 'src'), { recursive: true }); + await mkdir(join(root, 'views'), { recursive: true }); + await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'); + await symlink(workbenchNodeModules, join(root, 'node_modules'), 'dir'); + const serverSource = [ + "import apps from 'agent-bundle/mcp-apps';", + 'export const bundledApps = apps;', + '', + ].join('\n'); + await writeFile(join(root, 'src', 'library.ts'), serverSource); + await writeFile(join(root, 'src', 'public.ts'), serverSource); + await writeFile(join(root, 'views', 'widget.ts'), 'document.body.textContent = "widget-ready";\n'); + + const widget = { + _meta: { ui: { prefersBorder: true } }, + entry: './views/widget.ts', + resourceUri: 'ui://agent-bundle/widget.html', + }; + const config = { + mcp: { + servers: { + library: { apps: { widget }, entry: './src/library.ts' }, + public: { apps: { widget: { ...widget } }, entry: './src/public.ts' }, + }, + }, + plugin: { name: 'mcp-app-shared', version: '1.0.0' }, + targets: ['portable'], + }; + expect(validateSource(loadedProject(root, config), { skills: [] }, registry)).toEqual([]); + + const model = await normalizeProject(loadedProject(root, config), { skills: [] }, registry); + const outputRoot = join(root, 'dist'); + const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + + const compiled = (result as unknown as { + readonly compiledMcpApps: readonly { readonly name: string; readonly serverIds: readonly string[] }[]; + }).compiledMcpApps; + expect(compiled).toEqual([expect.objectContaining({ + name: 'widget', + resourceUri: 'ui://agent-bundle/widget.html', + serverIds: ['mcp:library', 'mcp:public'], + })]); + expect(await readdir(join(outputRoot, 'portable', 'mcp-apps'))).toEqual(['widget.html']); + + const bundleNames = await readdir(join(outputRoot, 'portable', 'mcp')); + for (const serverName of ['library', 'public']) { + const bundleName = bundleNames.find((entry) => entry.startsWith(`mcp-${serverName}-`)); + expect(bundleName).toBeDefined(); + const bundle = await readFile(join(outputRoot, 'portable', 'mcp', bundleName!), 'utf8'); + expect(bundle).toContain('ui://agent-bundle/widget.html'); + expect(bundle).toContain('widget-ready'); + expect(bundle).toContain('prefersBorder'); + } + expect(await validateArtifact({ artifactRoot: outputRoot })).toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 30_000); + +it('rejects conflicting same-name MCP App declarations at compilation planning', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-app-conflict-')); + try { + await mkdir(join(root, 'src'), { recursive: true }); + await mkdir(join(root, 'views'), { recursive: true }); + await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'); + await writeFile(join(root, 'src', 'library.ts'), 'export {};\n'); + await writeFile(join(root, 'src', 'public.ts'), 'export {};\n'); + await writeFile(join(root, 'views', 'widget.ts'), 'export {};\n'); + await writeFile(join(root, 'views', 'other.ts'), 'export {};\n'); + + const model = await normalizeProject( + loadedProject(root, { + mcp: { + servers: { + library: { + apps: { + widget: { entry: './views/widget.ts', resourceUri: 'ui://agent-bundle/widget.html' }, + }, + entry: './src/library.ts', + }, + public: { + apps: { + widget: { entry: './views/other.ts', resourceUri: 'ui://agent-bundle/widget.html' }, + }, + entry: './src/public.ts', + }, + }, + }, + plugin: { name: 'mcp-app-conflict', version: '1.0.0' }, + targets: ['portable'], + }), + { skills: [] }, + registry, + ); + await expect(build({ + model, + outputRoot: join(root, 'dist'), + projectRoot: root, + registry: createDefaultRegistry(), + })).rejects.toThrow( + 'Duplicate compiled MCP App destination "mcp-apps/widget.html"; servers may share an app name only with an identical declaration.', + ); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 30_000); + it('rejects the MCP Apps virtual module outside Agent Bundle compilation', async () => { await expect(import('../src/mcp-apps.ts')).rejects.toThrow( 'agent-bundle/mcp-apps is available only while Agent Bundle compiles a local MCP server.', diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index b9ae10e7b..5565e2b0c 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -36,6 +36,7 @@ from the same typed operation registry: ```tsx import { AgentBundle, + McpApp, McpServer, Operation, Script, @@ -79,7 +80,14 @@ export const application = defineRscAgentBundle(