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/.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/.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/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/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx b/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx index bbdce24ad..b33e7cda8 100644 --- a/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx +++ b/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx @@ -95,6 +95,19 @@ test('rejects malformed or nested MCP protocol result trees', () => { ).toThrow('mcp-result structuredContent must be JSON-serializable'); }); +test('follows JSON wire semantics for undefined instead of rejecting it', () => { + // Matches MCP SDK serialization: JSON.stringify drops undefined-valued + // properties and lowers undefined array elements to null. + const result = lowerMcpResult( + + ok + , + ); + + expect(result.structuredContent).toEqual({ edits: [null, 'recorded'], stateVersion: 2 }); + expect(Object.hasOwn(result.structuredContent as object, 'note')).toBe(false); +}); + test('rejects non-JSON structured content instead of normalizing it', () => { const cyclic: Record = {}; cyclic.self = cyclic; @@ -102,7 +115,6 @@ test('rejects non-JSON structured content instead of normalizing it', () => { sparse[1] = 'present'; for (const value of [ - undefined, () => undefined, Symbol('value'), Number.NaN, @@ -110,7 +122,6 @@ test('rejects non-JSON structured content instead of normalizing it', () => { new Date('2026-08-14T00:00:00.000Z'), new Map(), sparse, - [undefined], cyclic, ]) { expect(() => 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 0c4a158aa..5565e2b0c 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 @@ -31,6 +36,7 @@ from the same typed operation registry: ```tsx import { AgentBundle, + McpApp, McpServer, Operation, Script, @@ -55,10 +61,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) => ( @@ -72,7 +80,14 @@ export const application = defineRscAgentBundle(