From 5828cc96bb50a37b1bfcd74b0d63eabf5c10b226 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 06:57:42 +0000 Subject: [PATCH 1/2] Fix route-mode defects found while re-porting movie-library (#380, #381, #383) - #380: a `mcp.servers.` block for a route-generated server augments it (env, args, targets, apps, transport: 'stdio') instead of failing AB4304/AB4322; redeclaring entry/command/url under an explicit generated mode is the new AB4340. - #381: bundle the TypeScript parser (devDependency, pinned 5.9.3) so an npm install of agent-bundle never links a `tsc` bin over the consumer's own TypeScript; packed-tarball proof added. The emitted chunk gets an import.meta.url-derived __filename/__dirname shim for the parser's eager getNodeSystem(). - #383: `Agent.Result metadata` projects to `CallToolResult._meta` (object only; a non-object fails closed with McpProjectionError('invalid-result-metadata')). - Generated tools advertise `outputSchema` only when `resultSchema` describes an object, so text-only routes need no `structuredContent`. - Document the `[] ` error text form on the MCP wire. --- .changeset/movie-library-port-findings.md | 24 ++++ docs/diagnostics.md | 16 ++- docs/entry-conventions.md | 20 +++ docs/framework-mode.md | 13 ++ .../src/mcp/harness/tools/strict-report.tsx | 5 +- packages/agent-bundle/package.json | 2 +- packages/agent-bundle/rslib.config.ts | 47 ++++++- packages/agent-bundle/src/config/validate.ts | 69 ++++++++++- .../agent-bundle/src/mcp-server-runtime.ts | 57 ++++++++- packages/agent-bundle/src/test/mcp.ts | 5 +- .../tests/generated-route-server.test.ts | 117 ++++++++++++++++++ .../tests/mcp-server-runtime.test.ts | 40 ++++++ .../tests/packed-consumer-typescript.test.ts | 90 ++++++++++++++ .../tests/projection/mcp-in-memory.test.ts | 15 +++ .../agent-bundle/tests/route-graph.test.ts | 86 +++++++++++++ packages/rsc-runtime/src/elements.ts | 5 + packages/rsc-runtime/src/project-mcp.ts | 24 +++- .../rsc-runtime/tests/mcp-projector.test.ts | 36 ++++++ pnpm-lock.yaml | 13 +- rstest.integration-tests.ts | 1 + 20 files changed, 670 insertions(+), 15 deletions(-) create mode 100644 .changeset/movie-library-port-findings.md create mode 100644 packages/agent-bundle/tests/mcp-server-runtime.test.ts create mode 100644 packages/agent-bundle/tests/packed-consumer-typescript.test.ts diff --git a/.changeset/movie-library-port-findings.md b/.changeset/movie-library-port-findings.md new file mode 100644 index 000000000..45dfbfeae --- /dev/null +++ b/.changeset/movie-library-port-findings.md @@ -0,0 +1,24 @@ +--- +"@agent-bundle/runtime": minor +"agent-bundle": minor +--- + +Fixes found while re-porting a real external plugin onto route mode +(#380, #381, #383): + +- A `mcp.servers.` declaration for a route-generated server now + **augments** that server — `env`, `args`, `targets`, `apps`, and + `transport: 'stdio'` apply — instead of failing `AB4304`/`AB4322`. Redeclaring + `entry`, `command`, or `url` beside `routes.servers.: 'generated'` is + the new precise `AB4340` error; without an explicit mode it stays `AB4800`. +- `Agent.Result metadata` projects to `CallToolResult._meta` (an object, + JSON-snapshotted like `structuredContent`; a non-object fails the projection + closed with `McpProjectionError('invalid-result-metadata')`). The + `mcp-in-memory` harness result exposes `_meta`. +- Generated tools advertise `outputSchema` only when the route's + `resultSchema` describes an object; text-only routes (for example + `resultSchema = z.undefined()`) advertise none and return no + `structuredContent`, as the MCP specification requires. +- The `typescript-5` parser alias is bundled into the package instead of + shipped as a dependency, so `npm install agent-bundle` never links a `tsc` + bin over the consumer's own TypeScript. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 358a8a660..0623c48c0 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -14,7 +14,7 @@ gate a build, a validation, or a dev rebuild. | `AB40xx` | Plugin metadata and Skill source validation (`AB4000`/`AB4001`: name/version; `AB4002`–`AB4007`: Skill fields; `AB4008`–`AB4011` and `AB4013`: release identity, see below; `AB4012`: declared `plugin.logo` is missing, not a file, or outside the project). | | `AB41xx` | Normalized model invariants (unknown targets, duplicate IDs and outputs). | | `AB42xx` | Hook configuration and native hook sources. | -| `AB43xx` | MCP server and MCP App configuration. | +| `AB43xx` | MCP server and MCP App configuration (`AB4340`: a declaration for a route-generated server redeclares `entry`/`command`/`url`; see below). | | `AB44xx` | Script configuration. | | `AB4500` | Registered config extensions (strict finite JSON). | | `AB46xx` | Assets and the generated-runtime floor. | @@ -289,6 +289,20 @@ simply not been built yet is a validation **warning** that only | `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. | | `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. | +## Config beside a route-generated MCP server (`AB4340`) + +A `mcp.servers.` block for a server the route graph compiles in +`generated` mode augments that server (`env`, `args`, `targets`, `apps`, +`transport: 'stdio'`) — see the precedence table in +[Entry conventions](entry-conventions.md#config-beside-a-route-generated-mcp-server). +The local-entry field rules apply to it unchanged (`AB4305`, `AB4308`–`AB4312`, +`AB432x`), and it never triggers `AB4304` or `AB4322`: the route modules are +its entry. + +| Code | Severity | Trigger | +| --- | --- | --- | +| `AB4340` | error | A declaration for a route-generated server sets `entry`, `command`, or `url` while `routes.servers.` is `generated`. The routes already compile this server, so a second entry claim has no reading the compiler could honor. Remove the field to keep the generated server (the other fields still apply), or set the mode to `custom`, `command`, or `remote` to serve the declared entry and omit the routes. Without an explicit mode the same collision is `AB4800`. | + ## Conventional host components: rules and commands (`AB4900`–`AB4906`, `AB4920`–`AB4926`) Conventional `src/rules/*.mdc` documents compile to the Rule IR (closed diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 867641713..3bc81d539 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -87,6 +87,26 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. Route and package entry conventions match `.ts` and `.tsx` files exactly; the state convention is specifically `src/state.ts`. +### Config beside a route-generated MCP server + +A `mcp.servers.` block whose `` the route graph compiles in +`generated` mode does not redeclare the server — its entry is the route +modules — it **augments** it. This is the precedence table for one generated +server (config wins, conventions fill): + +| Field | Source of truth | Config declaration | +| --- | --- | --- | +| Entry, transport (`stdio`), `cwd` (plugin root) | `src/mcp//{tools,resources,prompts}/*` and the generated stdio shell | `entry`, `command`, or `url` is `AB4340` under `routes.servers.: 'generated'` and `AB4800` without an explicit mode; `transport: 'stdio'` is accepted, any other transport is `AB4308`; `cwd` is `AB4309`; `headers` is `AB4310`. | +| `env` | — | Applied verbatim beneath the injected plugin-root anchor (`AB4312` shape rules). | +| `args` | The content-hashed entry path | Appended after the entry path (`AB4311` shape rules). | +| `targets` | The project's selected targets | Replaces the default selection (`AB4305` shape rules). | +| `apps` | `src/mcp//apps/*` routes | Config-side Apps are compiled and registered on the generated server beside the route-declared ones (`AB432x` rules; `AB4334` checks App targets against the declared server targets). | + +Provenance stays `conventional` (the first route module) because the routes +supply the entry; `inspect` shows the merged `env`, `args`, and `targets`. +Setting `routes.servers.` to `custom`, `command`, or `remote` turns the +same block back into an ordinary server declaration and omits the routes. + ### Generated state mounting The compiler parses `src/state.ts` without executing it and requires one diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 88e68d942..15a160228 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -106,6 +106,19 @@ stay `unknown`. Route-unit and CLI-dispatch tests inject fixture values through `renderRoute(id, { context: { providers: { library } } })`; the harness never executes provider modules on a test's behalf. +### What reaches the MCP wire + +The final Agent Document of a tool route lowers to one `CallToolResult`: + +| Route surface | Wire effect | +| --- | --- | +| `Agent.Text`, `Agent.Markdown`, `Agent.Context`, `Agent.Json` children | Ordered `content` text blocks (`Agent.Json` as its JSON text). | +| `Agent.Image`, `Agent.Audio`, `Agent.Resource` | Native `image`, `audio`, and `resource_link` blocks; a host without that capability fails the projection closed unless a text fallback is selected. | +| `Agent.Result value` | `structuredContent` when the value is a JSON object; a non-object value emits none and is never wrapped. | +| `Agent.Result metadata` | `CallToolResult._meta`. It must be a JSON object (snapshotted through the same wire boundary as `structuredContent`); anything else fails the projection closed with `McpProjectionError('invalid-result-metadata')`. Listing-level `_meta` still comes from static `config._meta`, so the MCP Apps convention stamps `_meta.ui.resourceUri` on both halves. | +| `Agent.Error code message` | `isError: true` plus one text block `[] `. The wire has no error-code field, so the code is deliberately kept in the text (the routed CLI prints the same `**[code]** message` form); choose codes that read well to the model. | +| `resultSchema` | `outputSchema` in `tools/list` **only when the schema describes an object** (`z.object`, `z.record`, a discriminated union of objects). The MCP specification requires every result of a tool that declares `outputSchema` to carry `structuredContent`, so a text-only route declares `resultSchema = z.undefined()` (or any non-object schema), advertises no `outputSchema`, and returns no `structuredContent`. An object schema keeps the SDK's fail-closed output validation on every call. | + Everything else is power-tier reference: custom/remote server modes and collision recovery are in [Entry conventions](entry-conventions.md); accepted static metadata, generated `.agent-bundle/routes.d.ts`, and diagnostics are in diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/strict-report.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/strict-report.tsx index 32a5306e1..f78ea19da 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/strict-report.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/strict-report.tsx @@ -2,6 +2,7 @@ import { Agent } from '@agent-bundle/runtime'; import { z } from 'zod'; export const config = { + _meta: { ui: { resourceUri: 'ui://route-harness/panel.html' } }, description: 'Returns a closed-object report that rejects unknown serialized keys.', title: 'Strict report', }; @@ -16,8 +17,10 @@ export const resultSchema = z.strictObject({ export default async function StrictReport({ input }: { readonly input: z.infer }) { const reportId = input.reportId ?? 'report-1'; const value = { reportId, summary: `summary for ${reportId}` }; + // The MCP Apps convention stamps the App resource on every result as well + // as on the listing; `metadata` is the result half (`CallToolResult._meta`). return ( - + {value.summary} ); diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 0e85ee25e..b45c2c690 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -102,7 +102,6 @@ "ignore": "7.0.7", "jiti": "2.7.0", "open": "11.0.2", - "typescript-5": "npm:typescript@5.6.1-rc", "ws": "8.21.3", "yaml": "2.9.0" }, @@ -112,6 +111,7 @@ "@types/ws": "8.18.1", "effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f", "react": "19.2.8", + "typescript-5": "npm:typescript@5.9.3", "zod": "4.5.4" }, "peerDependencies": { diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 0cc8c0e87..ad240c93c 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -1,9 +1,37 @@ import { resolve } from 'node:path'; -import { defineConfig } from '@rslib/core'; +import { defineConfig, type Rspack, type rspack as RspackInstance } from '@rslib/core'; import { pluginPublint } from 'rsbuild-plugin-publint'; import packageManifest from './package.json' with { type: 'json' }; +const esmNodeGlobalsShim = [ + '// agent-bundle ESM shims for the bundled TypeScript parser', + "const __filename = process.getBuiltinModule('node:url').fileURLToPath(import.meta.url);", + "const __dirname = process.getBuiltinModule('node:path').dirname(__filename);", + '', +].join('\n'); + +/** + * Prepends the `__filename`/`__dirname` shim to every emitted ESM chunk that + * still references those CommonJS globals (the bundled TypeScript parser's + * eager `getNodeSystem()`); every other chunk is left untouched. + */ +const esmNodeGlobalsPlugin = (rspack: typeof RspackInstance): Rspack.RspackPluginInstance => ({ + apply(compiler: Rspack.Compiler) { + compiler.hooks.thisCompilation.tap('agent-bundle:esm-node-globals', (compilation: Rspack.Compilation) => { + compilation.hooks.processAssets.tap({ + name: 'agent-bundle:esm-node-globals', + stage: rspack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, + }, (assets: Rspack.Assets) => { + for (const [name, asset] of Object.entries(assets)) { + if (!name.endsWith('.js') || !/\b__(?:filename|dirname)\b/u.test(asset.source().toString())) continue; + compilation.updateAsset(name, new rspack.sources.ConcatSource(esmNodeGlobalsShim, asset)); + } + }); + }); + }, +}); + export default defineConfig({ lib: [ { @@ -25,6 +53,23 @@ export default defineConfig({ // Suggestions stay informational; errors and warnings block publishing. plugins: [pluginPublint({ throwOn: 'warning' })], root: import.meta.dirname, + tools: { + // The TypeScript 5 parser is bundled (a devDependency, #381) so consumers + // never receive its `tsc` bin beside their own TypeScript. + rspack: (config, { rspack }) => { + // Its `sys.tryEnableSourceMapsForHost` requires `source-map-support` + // inside a try/catch for the tsc CLI only; the static route-config + // extractor never reaches it. + config.ignoreWarnings = [...(config.ignoreWarnings ?? []), /Can't resolve 'source-map-support'/u]; + // Its eager `getNodeSystem()` reads the CommonJS `__filename`/`__dirname` + // globals, which the ESM output does not define and which Rspack's + // `node-module` rewrite leaves untouched inside that module. The chunk + // that carries them gets a module-scoped shim derived from its own + // `import.meta.url` (`process.getBuiltinModule` is Node >= 22.3). + config.node = { ...(typeof config.node === 'object' ? config.node : {}), __dirname: false, __filename: false }; + config.plugins = [...(config.plugins ?? []), esmNodeGlobalsPlugin(rspack)]; + }, + }, source: { tsconfigPath: './tsconfig.build.json', define: { diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 41f780e84..12cb5181d 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -477,10 +477,12 @@ const validateMcpApps = ( loaded: LoadedConfig, seenApps: Map, seenUris: Map, + options: { readonly generated?: boolean } = {}, ): Diagnostic[] => { if (server.apps === undefined) return []; const diagnostics: Diagnostic[] = []; - const hasLocalEntry = server.entry !== undefined || ( + // A route-generated server always has a compiled local entry (#380). + const hasLocalEntry = options.generated === true || server.entry !== undefined || ( server.command === undefined && server.url === undefined && conventionalMcpEntrySource(loaded.context.projectRoot, name) !== undefined ); @@ -750,6 +752,55 @@ const validateMcpServer = ( return diagnostics; }; +/** + * The declaration a route-generated server accepts (#380). Config wins and + * conventions fill: the `src/mcp//` route modules supply the entry, so + * a `mcp.servers.` block *augments* that server — `env`, `args`, + * `targets`, `apps`, and `transport: 'stdio'` — and never redeclares it. + * `entry`, `command`, and `url` are a precise error rather than a silently + * ignored field: the route graph already compiles this server, so a second + * entry claim has no reading the compiler could honor. + */ +const validateGeneratedMcpServerDeclaration = ( + name: string, + value: unknown, + loaded: LoadedConfig, +): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + if (!nonemptyString(name)) { + diagnostics.push(sourceDiagnostic('AB4302', 'MCP server names must be nonempty.', loaded.configPath)); + } + if (!isRecord(value)) { + diagnostics.push(sourceDiagnostic('AB4303', `MCP server ${JSON.stringify(name)} must be an object.`, loaded.configPath)); + return diagnostics; + } + const server = value as AgentBundleMcpServer; + const claims = (['entry', 'command', 'url'] as const).filter((key) => server[key] !== undefined); + if (claims.length > 0) { + diagnostics.push({ + code: 'AB4340', + message: `MCP server ${JSON.stringify(name)} is compiled from src/mcp/${name}/ route modules, so its declaration cannot set ${claims.join(', ')}; a config declaration for a generated server only augments it.`, + recovery: `Remove ${claims.join(', ')} to keep the generated server (env, args, targets, and apps still apply), or set routes.servers.${name} to custom, command, or remote to serve the declared entry instead of the route modules.`, + severity: 'error', + sourcePath: loaded.configPath, + }); + return diagnostics; + } + diagnostics.push(...validateStringList(server.targets, 'targets', 'AB4305', loaded)); + if (server.transport !== undefined && server.transport !== 'stdio') { + diagnostics.push(sourceDiagnostic('AB4308', `MCP server ${JSON.stringify(name)} entry must use stdio transport.`, loaded.configPath)); + } + if (server.cwd !== undefined) { + diagnostics.push(sourceDiagnostic('AB4309', `MCP server ${JSON.stringify(name)} local entry cannot set cwd.`, loaded.configPath)); + } + if (server.headers !== undefined) { + diagnostics.push(sourceDiagnostic('AB4310', `MCP server ${JSON.stringify(name)} stdio server cannot set headers.`, loaded.configPath)); + } + diagnostics.push(...validateStringList(server.args, 'args', 'AB4311', loaded)); + diagnostics.push(...validateStringRecord(server.env, 'env', 'AB4312', loaded)); + return diagnostics; +}; + const validatePluginLogo = ( loaded: LoadedConfig, pluginRecord: Record | undefined, @@ -840,6 +891,7 @@ const validateRuntime = (loaded: LoadedConfig): Diagnostic[] => { const validateMcp = ( loaded: LoadedConfig, + discovered: DiscoveredProject, registry: NormalizationTargetRegistry, payloads: readonly DeclaredPayload[], ): Diagnostic[] => { @@ -851,12 +903,21 @@ const validateMcp = ( if (!isRecord(mcp.servers)) { return [sourceDiagnostic('AB4301', 'MCP configuration must define a servers object.', loaded.configPath)]; } + // The same judgment normalization applies: a server the route graph + // compiles in generated mode is declared by its route modules, and a config + // block for it augments rather than redeclares (#380). + const generated = new Set((discovered.routeGraph?.servers ?? []) + .filter((server) => server.mode === 'generated' && server.routes.length > 0) + .map((server) => server.name)); const names = new Map(); const uris = new Map(); return Object.entries(mcp.servers).flatMap(([name, server]) => { - const diagnostics = validateMcpServer(name, server, loaded, registry, payloads); + const isGenerated = generated.has(name); + const diagnostics = isGenerated + ? validateGeneratedMcpServerDeclaration(name, server, loaded) + : validateMcpServer(name, server, loaded, registry, payloads); return isRecord(server) - ? [...diagnostics, ...validateMcpApps(name, server as AgentBundleMcpServer, loaded, names, uris)] + ? [...diagnostics, ...validateMcpApps(name, server as AgentBundleMcpServer, loaded, names, uris, { generated: isGenerated })] : diagnostics; }); }; @@ -1855,7 +1916,7 @@ export const validateSource = ( diagnostics.push(...validateBin(loaded)); diagnostics.push(...validateHooks(loaded, registry, payloads)); diagnostics.push(...validateLib(loaded)); - diagnostics.push(...validateMcp(loaded, registry, payloads)); + diagnostics.push(...validateMcp(loaded, discovered, registry, payloads)); diagnostics.push(...validateOutput(loaded)); diagnostics.push(...validatePayload(loaded, registry, options?.payloadFreshness !== false)); diagnostics.push(...validateRuntime(loaded)); diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 78baad62a..3d5660739 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -182,6 +182,57 @@ const selectedConfig = ( keys.filter((key) => config[key] !== undefined).map((key) => [key, config[key]]), ); +/** The JSON Schema draft the MCP SDK targets when it advertises a Standard Schema. */ +const JSON_SCHEMA_TARGET = 'draft-2020-12'; + +interface StandardJsonSchemaSource { + readonly '~standard'?: { + readonly jsonSchema?: { + readonly output?: (options: { readonly target: string }) => unknown; + }; + }; +} + +const isRecord = (value: unknown): value is Readonly> => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * A typeless JSON Schema root is object-shaped when it carries object keywords + * or every member of its `oneOf`/`anyOf`/`allOf` composition is — the same + * judgment the MCP SDK applies before stamping `type: "object"`. + */ +const objectRootedJsonSchema = (schema: unknown): boolean => { + if (!isRecord(schema)) return false; + if (schema['type'] !== undefined) return schema['type'] === 'object'; + if (['properties', 'patternProperties', 'additionalProperties', 'required'].some((key) => key in schema)) return true; + return ['oneOf', 'anyOf', 'allOf'].some((key) => { + const members = schema[key]; + return Array.isArray(members) && members.length > 0 && members.every(objectRootedJsonSchema); + }); +}; + +/** + * Advertises a tool `outputSchema` only when the route's `resultSchema` + * describes an object: the MCP specification requires every result of a tool + * that declares `outputSchema` to carry `structuredContent`, and the + * projection emits `structuredContent` only for object-valued documents. A + * text-only route (`z.undefined()`, `z.string()`, an array schema) therefore + * advertises none, while an object schema keeps the SDK's fail-closed output + * validation. A schema that cannot describe itself as JSON Schema is handed + * to the SDK unchanged so its own conversion decides. + */ +export const advertisedOutputSchema = (schema: unknown): unknown => { + const toJsonSchema = (schema as StandardJsonSchemaSource | null | undefined)?.['~standard']?.jsonSchema?.output; + if (typeof toJsonSchema !== 'function') return schema; + let jsonSchema: unknown; + try { + jsonSchema = toJsonSchema({ target: JSON_SCHEMA_TARGET }); + } catch { + return undefined; + } + return objectRootedJsonSchema(jsonSchema) ? schema : undefined; +}; + /** Registers the compiled MCP routes on a server, keyed by route kind. */ export const registerGeneratedRoutes = ( server: McpServer, @@ -191,11 +242,12 @@ export const registerGeneratedRoutes = ( ): void => { for (const route of Object.values(routes)) { switch (route.kind) { - case 'tool': + case 'tool': { + const outputSchema = advertisedOutputSchema(route.module.resultSchema); server.registerTool(route.name, { ...selectedConfig(route.config, ['_meta', 'annotations', 'description', 'icons', 'title']), inputSchema: route.module.inputSchema, - outputSchema: route.module.resultSchema, + ...(outputSchema === undefined ? {} : { outputSchema }), } as never, (async (input: unknown, context: GeneratedRouteRequestContext) => { const clientName = server.server.getClientVersion()?.name; const rendered = await renderGeneratedRoute( @@ -209,6 +261,7 @@ export const registerGeneratedRoutes = ( return attachMcpStructuredContent(rendered.toolResult, rendered.result); }) as never); break; + } case 'resource': { const uri = route.config['uri']; if (typeof uri !== 'string' || uri.trim() === '') { diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index 009f5aa79..ece401ff4 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -49,6 +49,8 @@ export interface McpContentBlock { } export interface McpToolInvocation { + /** Result-level `CallToolResult._meta`, projected from `Agent.Result metadata`. */ + readonly _meta?: Readonly>; readonly content: readonly McpContentBlock[]; readonly isError: boolean; readonly provenance: McpProjectionProvenance; @@ -417,8 +419,9 @@ export const invokeMcpTool = async ( const result = await session.client.callTool({ arguments: (options.input ?? {}) as Record, name: tool, - }) as { content?: unknown; isError?: boolean; structuredContent?: unknown }; + }) as { _meta?: Readonly>; content?: unknown; isError?: boolean; structuredContent?: unknown }; return Object.freeze({ + ...(result._meta === undefined ? {} : { _meta: result._meta }), content: asContentBlocks(result.content), isError: result.isError === true, provenance: session.provenance, diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index 7ac933d32..028d175be 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -283,6 +283,123 @@ const expectFailClosed = (outcome: unknown, message: RegExp): void => { expect(JSON.stringify(outcome)).toMatch(message); }; +it('augments a generated server from config and projects result _meta and text-only tools to the wire', { retry: 2, timeout: 60_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-augment-')); + roots.push(root); + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + '@modelcontextprotocol/server': '2.0.0', + react: '19.2.8', + zod: '4.4.3', + }, + name: 'generated-augment-fixture', + type: 'module', + version: '1.0.0', + })), + // #380: the config block augments the route-generated server (env, args, + // targets, a config-side App) without redeclaring its entry. + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + 'export default defineConfig({', + ' mcp: { servers: { curator: {', + " apps: { panel: { entry: './views/panel.ts', resourceUri: 'ui://generated-augment-fixture/panel.html' } },", + " args: ['--strict'],", + " env: { CURATOR_MODE: 'strict' },", + ' } } },', + " plugin: { name: 'generated-augment-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '});', + '', + ].join('\n')), + writeProjectFile(root, 'views/panel.ts', "document.body.textContent = 'Curator panel';\n"), + // #383: `Agent.Result metadata` is the result-level `_meta`. + writeProjectFile(root, 'src/mcp/curator/tools/annotated.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + "export const config = { _meta: { ui: { resourceUri: 'ui://generated-augment-fixture/panel.html' } }, description: 'Annotated result.' };", + 'export const inputSchema = z.object({}).strict();', + "export const resultSchema = z.object({ status: z.literal('ready') }).strict();", + 'export default async function Annotated() {', + " return createElement(Agent.Result, { metadata: { ui: { resourceUri: 'ui://generated-augment-fixture/panel.html' } }, value: { status: 'ready' } }, createElement(Agent.Text, null, 'ready'));", + '}', + '', + ].join('\n')), + // A text-only tool declares no object result, so it advertises no + // outputSchema and returns no structuredContent. + writeProjectFile(root, 'src/mcp/curator/tools/plain.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + "export const config = { description: 'Text only.' };", + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.undefined();', + 'export default async function Plain() {', + " return createElement(Agent.Result, null, createElement(Agent.Text, null, 'plain text'));", + '}', + '', + ].join('\n')), + ]); + + const output = join(root, 'artifact'); + const compiled = await build({ output, root, targets: ['portable'] }); + const server = compiled.model.mcpServers[0]; + expect(server).toMatchObject({ + args: [expect.stringMatching(/^mcp\/mcp-curator-[0-9a-f]+\.mjs$/u), '--strict'], + env: { CURATOR_MODE: 'strict' }, + id: 'mcp:curator', + }); + expect(compiled.model.mcpApps?.map((app) => app.id)).toEqual(['mcp-app:curator:panel']); + const manifest = JSON.parse(await readFile(join(output, 'portable', 'mcp.json'), 'utf8')) as { + readonly mcpServers: { readonly curator: { readonly args: readonly string[]; readonly env: Readonly> } }; + }; + expect(manifest.mcpServers.curator.args[1]).toBe('--strict'); + expect(manifest.mcpServers.curator.env).toMatchObject({ CURATOR_MODE: 'strict' }); + + const client = new Client({ name: 'generated-augment-test', version: '0.0.0' }); + const transport = new StdioClientTransport({ + args: [join(output, 'portable', server!.args![0]!)], + command: process.execPath, + stderr: 'pipe', + }); + let diagnostics = ''; + transport.stderr?.on('data', (chunk) => { diagnostics += String(chunk); }); + try { + try { + await client.connect(transport); + } catch (error) { + throw new Error(`Generated route server failed to connect: ${diagnostics}`, { cause: error }); + } + const listed = await client.listTools(); + const annotated = listed.tools.find((tool) => tool.name === 'annotated'); + const plain = listed.tools.find((tool) => tool.name === 'plain'); + expect(annotated).toMatchObject({ + _meta: { ui: { resourceUri: 'ui://generated-augment-fixture/panel.html' } }, + outputSchema: { type: 'object' }, + }); + expect(plain).toMatchObject({ description: 'Text only.' }); + expect(plain).not.toHaveProperty('outputSchema'); + + const annotatedResult = await client.callTool({ arguments: {}, name: 'annotated' }, { signal: AbortSignal.timeout(10_000) }); + expect(annotatedResult).toEqual({ + _meta: { ui: { resourceUri: 'ui://generated-augment-fixture/panel.html' } }, + content: [{ text: 'ready', type: 'text' }], + structuredContent: { status: 'ready' }, + }); + const plainResult = await client.callTool({ arguments: {}, name: 'plain' }, { signal: AbortSignal.timeout(10_000) }); + expect(plainResult).toEqual({ content: [{ text: 'plain text', type: 'text' }] }); + + await expect(client.readResource({ uri: 'ui://generated-augment-fixture/panel.html' })).resolves.toMatchObject({ + contents: [{ text: expect.stringContaining('Curator panel'), uri: 'ui://generated-augment-fixture/panel.html' }], + }); + } finally { + await client.close(); + } +}); + it('observes one process-lifetime provider across consecutive generated tool calls', { retry: 2, timeout: 60_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-warm-')); roots.push(root); diff --git a/packages/agent-bundle/tests/mcp-server-runtime.test.ts b/packages/agent-bundle/tests/mcp-server-runtime.test.ts new file mode 100644 index 000000000..a8c6b4fed --- /dev/null +++ b/packages/agent-bundle/tests/mcp-server-runtime.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from '@rstest/core'; +import { z } from 'zod'; + +import { advertisedOutputSchema } from '../src/mcp-server-runtime.ts'; + +/** + * The MCP specification requires every result of a tool that declares + * `outputSchema` to carry `structuredContent`, and the projection emits + * `structuredContent` only for object-valued documents. So the generated + * server advertises `outputSchema` exactly when the route's `resultSchema` + * describes an object. + */ +describe('advertisedOutputSchema', () => { + it('advertises object-rooted result schemas unchanged', () => { + const plain = z.object({ status: z.literal('ready') }).strict(); + const record = z.record(z.string(), z.unknown()); + const union = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('a'), value: z.string() }), + z.object({ kind: z.literal('b'), count: z.number() }), + ]); + + expect(advertisedOutputSchema(plain)).toBe(plain); + expect(advertisedOutputSchema(record)).toBe(record); + expect(advertisedOutputSchema(union)).toBe(union); + }); + + it('advertises nothing for text-only and non-object result schemas', () => { + expect(advertisedOutputSchema(z.undefined())).toBeUndefined(); + expect(advertisedOutputSchema(z.void())).toBeUndefined(); + expect(advertisedOutputSchema(z.string())).toBeUndefined(); + expect(advertisedOutputSchema(z.number())).toBeUndefined(); + expect(advertisedOutputSchema(z.array(z.object({ id: z.string() })))).toBeUndefined(); + expect(advertisedOutputSchema(z.union([z.string(), z.object({ id: z.string() })]))).toBeUndefined(); + }); + + it('hands a schema that cannot describe itself to the SDK unchanged', () => { + const opaque = { parse: (value: unknown) => value }; + expect(advertisedOutputSchema(opaque)).toBe(opaque); + }); +}); diff --git a/packages/agent-bundle/tests/packed-consumer-typescript.test.ts b/packages/agent-bundle/tests/packed-consumer-typescript.test.ts new file mode 100644 index 000000000..610bb4ceb --- /dev/null +++ b/packages/agent-bundle/tests/packed-consumer-typescript.test.ts @@ -0,0 +1,90 @@ +import { execFile as executeFile } from 'node:child_process'; +import { access, mkdir, mkdtemp, readFile, readlink, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; + +import { expect, it } from '@rstest/core'; + +import { isolatedCommandEnvironment } from '../../../rstest.worker-isolation.ts'; +import { cachedNpmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts'; + +const execFile = promisify(executeFile); +const workspaceRoot = process.cwd(); + +const workspaceTypeScriptVersion = async (): Promise => { + const manifest = JSON.parse(await readFile(join(workspaceRoot, 'package.json'), 'utf8')) as { + readonly devDependencies: Readonly>; + }; + return manifest.devDependencies['typescript']!; +}; + +/** + * #381: the route-config parser ships bundled inside the package, so an npm + * consumer that installs agent-bundle beside its own `typescript` keeps its + * own `tsc` bin. Under npm an aliased dependency still contributes its `bin` + * entries and wins the `.bin/tsc` link race, which silently ran an old + * TypeScript over the consumer's tsconfig. + */ +it('never shadows the consumer\'s tsc bin from a packed npm install', async () => { + const { tarball } = await sharedPackedTarball('agent-bundle'); + const typescriptVersion = await workspaceTypeScriptVersion(); + + const consumerRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-tsc-')); + try { + await writeFile(join(consumerRoot, 'package.json'), '{"name":"packed-tsc-consumer","private":true,"type":"module"}\n'); + await execFile( + 'npm', + ['install', ...cachedNpmInstallArguments, '--save-dev', `typescript@${typescriptVersion}`, tarball], + { cwd: consumerRoot, env: isolatedCommandEnvironment() }, + ); + + // The bin link is the consumer's own TypeScript, and no aliased copy was hoisted beside it. + const tscLink = await readlink(join(consumerRoot, 'node_modules', '.bin', 'tsc')); + expect(tscLink.replaceAll('\\', '/')).toBe('../typescript/bin/tsc'); + await expect(access(join(consumerRoot, 'node_modules', 'typescript-5'))).rejects.toThrow(); + const installedTypeScript = JSON.parse( + await readFile(join(consumerRoot, 'node_modules', 'typescript', 'package.json'), 'utf8'), + ) as { readonly version: string }; + expect(installedTypeScript.version).toBe(typescriptVersion); + const { stdout: reportedVersion } = await execFile( + join(consumerRoot, 'node_modules', '.bin', 'tsc'), + ['--version'], + { cwd: consumerRoot, env: isolatedCommandEnvironment() }, + ); + expect(reportedVersion).toContain(typescriptVersion); + + // The packed package still parses route config statically, so the bundled + // parser — not a consumer-visible dependency — is what does that work. + const installedManifest = JSON.parse( + await readFile(join(consumerRoot, 'node_modules', 'agent-bundle', 'package.json'), 'utf8'), + ) as { readonly dependencies: Readonly> }; + expect(Object.keys(installedManifest.dependencies)).not.toContain('typescript-5'); + const projectRoot = join(consumerRoot, 'project'); + const routePath = join(projectRoot, 'src', 'mcp', 'demo', 'tools', 'status.ts'); + await mkdir(dirname(routePath), { recursive: true }); + await Promise.all([ + writeFile(join(projectRoot, 'package.json'), '{"name":"packed-tsc-project","private":true,"type":"module","version":"1.0.0"}\n'), + writeFile(join(projectRoot, 'agent-bundle.config.ts'), [ + "export default { plugin: { name: 'packed-tsc-project' }, targets: ['portable'] };", + '', + ].join('\n')), + writeFile(routePath, [ + "export const config = { annotations: { readOnlyHint: true }, description: 'Read status.' } satisfies { description: string };", + 'export const inputSchema = {};', + 'export const resultSchema = {};', + 'export default async () => undefined;', + '', + ].join('\n')), + ]); + const { stdout: inspected } = await execFile( + join(consumerRoot, 'node_modules', '.bin', 'agent-bundle'), + ['inspect', '--routes', '--json', '--root', projectRoot], + { cwd: projectRoot, env: isolatedCommandEnvironment() }, + ); + expect(JSON.stringify(JSON.parse(inspected))).toContain('"tool:demo/status"'); + expect(inspected).toContain('Read status.'); + } finally { + await rm(consumerRoot, { force: true, recursive: true }); + } +}, 120_000); diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index 55ad6a33d..a3b760a85 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -95,6 +95,21 @@ describe('the in-memory MCP projection level', () => { }); }); + it('projects Agent.Result metadata to the result _meta beside the listing _meta', async () => { + await using session = await openInMemoryMcpServer(); + + const listed = await session.client.listTools(); + expect(listed.tools.find((tool) => tool.name === 'strict-report')).toMatchObject({ + _meta: { ui: { resourceUri: 'ui://route-harness/panel.html' } }, + outputSchema: { type: 'object' }, + }); + const invocation = await invokeMcpTool('strict-report', { input: { reportId: 'meta-1' } }); + expect(invocation._meta).toEqual({ ui: { resourceUri: 'ui://route-harness/panel.html' } }); + expect(invocation.structuredContent).toEqual({ reportId: 'meta-1', summary: 'summary for meta-1' }); + const echo = await invokeMcpTool('echo', { input: { message: 'no metadata' } }); + expect(echo._meta).toBeUndefined(); + }); + it('carries a represented error to the protocol as isError rather than a transport failure', async () => { const invocation = await invokeMcpTool('unavailable'); diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 176e083b5..61c4b9476 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -304,6 +304,92 @@ it('keeps routes and silences AB4800 under an explicit generated mode', async () expect(graph.servers[0]!.routes.map((route) => route.id)).toEqual(['tool:curator/inspect']); }); +it('accepts a config declaration that augments a route-generated server with env, args, targets, and apps (#380)', async () => { + const project = await createInspectProject({ + 'agent-bundle.config.ts': [ + 'export default {', + ' mcp: { servers: { curator: {', + " apps: { panel: { entry: './views/panel.ts', resourceUri: 'ui://routes-fixture/panel.html' } },", + " args: ['--strict'],", + " env: { CURATOR_MODE: 'strict' },", + " targets: ['portable'],", + " transport: 'stdio',", + ' } } },', + " plugin: { name: 'routes-fixture', version: '1.0.0' },", + " targets: ['portable', 'claude'],", + '};', + '', + ].join('\n'), + 'src/mcp/curator/tools/inspect.ts': moduleSource, + 'views/panel.ts': "document.body.textContent = 'panel';\n", + }); + + const validation = await validate({ root: project }); + expect(validation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + + const result = await inspect({ root: project }); + expect(result.state).toBe('ready'); + const ready = result as ReadyInspectResult; + expect(ready.model.mcpServers).toHaveLength(1); + expect(ready.model.mcpServers[0]).toMatchObject({ + args: [expect.stringMatching(/^mcp\/mcp-curator-[0-9a-f]+\.mjs$/u), '--strict'], + env: { CURATOR_MODE: 'strict' }, + id: 'mcp:curator', + provenance: { kind: 'conventional' }, + targets: ['portable'], + transport: 'stdio', + }); + expect(ready.model.mcpServers[0]!.generatedRoutes?.map((route) => route.id)).toEqual(['tool:curator/inspect']); + expect(ready.model.mcpApps?.map((app) => ({ id: app.id, provenance: app.provenance.kind, targets: app.targets }))).toEqual([ + { id: 'mcp-app:curator:panel', provenance: 'config', targets: ['portable'] }, + ]); +}); + +it('errors with AB4340 when a declaration for a route-generated server redeclares its entry', async () => { + const project = await createInspectProject({ + 'agent-bundle.config.ts': [ + 'export default {', + " mcp: { servers: { curator: { entry: './src/mcp/curator.ts', env: { CURATOR_MODE: 'strict' } } } },", + " plugin: { name: 'routes-fixture', version: '1.0.0' },", + " routes: { servers: { curator: 'generated' } },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + 'src/mcp/curator.ts': moduleSource, + 'src/mcp/curator/tools/inspect.ts': moduleSource, + }); + + const validation = await validate({ root: project }); + const errors = validation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); + expect(codesOf(errors)).toEqual(['AB4340']); + expect(errors[0]!.message).toContain('cannot set entry'); + expect(errors[0]!.recovery).toContain('routes.servers.curator'); + expect(codesOf(validation.diagnostics)).not.toContain('AB4304'); + expect(codesOf(validation.diagnostics)).not.toContain('AB4800'); +}); + +it('applies the local-entry field rules to an augmenting declaration', async () => { + const project = await createInspectProject({ + 'agent-bundle.config.ts': [ + 'export default {', + " mcp: { servers: { curator: { cwd: './elsewhere', headers: { a: 'b' }, transport: 'streamable-http' } } },", + " plugin: { name: 'routes-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + 'src/mcp/curator/tools/inspect.ts': moduleSource, + }); + + const validation = await validate({ root: project }); + expect(codesOf(validation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).sort()).toEqual([ + 'AB4308', + 'AB4309', + 'AB4310', + ]); +}); + it('omits a server\'s routes and silences AB4800 under an explicit custom mode', async () => { const root = await createRoot(); await writeTree(root, { diff --git a/packages/rsc-runtime/src/elements.ts b/packages/rsc-runtime/src/elements.ts index 2542bf033..cc6a26614 100644 --- a/packages/rsc-runtime/src/elements.ts +++ b/packages/rsc-runtime/src/elements.ts @@ -3,7 +3,12 @@ import { createElement, type PropsWithChildren, type ReactElement } from 'react' import type { JsonValue } from './lower-mcp.js'; export interface AgentResultProps extends PropsWithChildren { + /** + * Result-level metadata. On MCP it is the `CallToolResult._meta` object, + * so it must be a JSON object there; the projection fails closed otherwise. + */ readonly metadata?: JsonValue; + /** The document value; on MCP it is `structuredContent` when it is a JSON object. */ readonly value?: JsonValue; } diff --git a/packages/rsc-runtime/src/project-mcp.ts b/packages/rsc-runtime/src/project-mcp.ts index b635d49fc..68d8fdb4f 100644 --- a/packages/rsc-runtime/src/project-mcp.ts +++ b/packages/rsc-runtime/src/project-mcp.ts @@ -35,7 +35,7 @@ export const DEFAULT_MCP_RICH_CONTENT_CAPABILITIES: McpRichContentCapabilities = export type McpRichContentFallback = 'text' | 'fail'; -export type McpProjectionErrorCode = 'unsupported-rich-content'; +export type McpProjectionErrorCode = 'unsupported-rich-content' | 'invalid-result-metadata'; export type McpRichContentKind = 'audio' | 'image' | 'resource'; @@ -167,6 +167,26 @@ const objectStructuredContent = (value: unknown): JsonObject | undefined => { return isJsonObject(snapshot) ? snapshot : undefined; }; +/** + * `Agent.Result metadata` is the route's result-level `CallToolResult._meta` + * (#383). MCP `_meta` is an object, so anything else fails the projection + * closed instead of being dropped or coerced: a route that renders scalar + * metadata has said something the wire cannot carry. + */ +const resultMetadata = (document: AgentDocument): JsonObject | undefined => { + if (document.root.kind !== 'result') return undefined; + const metadata = document.root.metadata; + if (metadata === undefined) return undefined; + const snapshot = snapshotJsonValue(metadata, 'MCP result _meta must be JSON-serializable'); + if (!isJsonObject(snapshot)) { + throw new McpProjectionError( + 'invalid-result-metadata', + 'MCP result _meta must be a JSON object; Agent.Result metadata projects to CallToolResult._meta', + ); + } + return snapshot; +}; + export const documentToCallToolResult = ( document: AgentDocument, options: Pick = {}, @@ -179,7 +199,9 @@ export const documentToCallToolResult = ( options.richContentFallback ?? 'fail', ); const structured = objectStructuredContent(options.structuredContent ?? document.value); + const metadata = resultMetadata(document); return { + ...(metadata === undefined ? {} : { _meta: metadata }), content, ...(document.status === 'success' ? {} : { isError: true }), ...(structured === undefined ? {} : { structuredContent: structured }), diff --git a/packages/rsc-runtime/tests/mcp-projector.test.ts b/packages/rsc-runtime/tests/mcp-projector.test.ts index f5b3d80ea..4c4f1dcf4 100644 --- a/packages/rsc-runtime/tests/mcp-projector.test.ts +++ b/packages/rsc-runtime/tests/mcp-projector.test.ts @@ -197,6 +197,42 @@ describe('projectMcpRenderStream', () => { }); }); + it('projects result metadata to _meta and fails closed on a non-object', async () => { + const withMetadata = await projectMcpRenderStream(eventsOf([{ + document: document({ + root: { + children: [{ kind: 'text', text: 'Ready.' }], + kind: 'result', + metadata: { ui: { resourceUri: 'ui://demo/panel.html' }, 'vendor/trace': ['a', 1, null] }, + }, + }), + sequence: 0, + type: 'complete', + }])); + expect(withMetadata.result).toEqual({ + _meta: { ui: { resourceUri: 'ui://demo/panel.html' }, 'vendor/trace': ['a', 1, null] }, + content: [{ text: 'Ready.', type: 'text' }], + structuredContent: { ok: true }, + }); + expect(Object.isFrozen(withMetadata.result._meta)).toBe(true); + + const scalar = projectMcpRenderStream(eventsOf([{ + document: document({ + root: { children: [], kind: 'result', metadata: 'not an object' }, + }), + sequence: 0, + type: 'complete', + }])); + await expect(scalar).rejects.toBeInstanceOf(McpProjectionError); + await expect(projectMcpRenderStream(eventsOf([{ + document: document({ + root: { children: [], kind: 'result', metadata: ['not', 'an', 'object'] }, + }), + sequence: 0, + type: 'complete', + }]))).rejects.toMatchObject({ code: 'invalid-result-metadata' }); + }); + it('omits non-object structured content instead of fabricating an object', async () => { const projected = await projectMcpRenderStream(eventsOf([{ document: document({ value: ['not', 'an', 'object'] }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef303c2ce..cd744bc4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,9 +290,6 @@ importers: open: specifier: 11.0.2 version: 11.0.2 - typescript-5: - specifier: npm:typescript@5.6.1-rc - version: typescript@5.6.1-rc ws: specifier: 8.21.3 version: 8.21.3 @@ -312,6 +309,9 @@ importers: react: specifier: 19.2.8 version: 19.2.8 + typescript-5: + specifier: npm:typescript@5.9.3 + version: typescript@5.9.3 zod: specifier: 4.5.4 version: 4.5.4 @@ -2610,6 +2610,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -5054,6 +5059,8 @@ snapshots: typescript@5.6.1-rc: {} + typescript@5.9.3: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 6ec14e2a6..e2169e90d 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -129,6 +129,7 @@ export const mcpConformanceTestFiles: readonly string[] = [ export const packedTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/dev-workbench-packaging.test.ts', 'packages/agent-bundle/tests/packed-consumer.test.ts', + 'packages/agent-bundle/tests/packed-consumer-typescript.test.ts', 'packages/agent-bundle/tests/packed-host-install-proof.test.ts', 'packages/agent-bundle/tests/packed-native-smoke.test.ts', 'packages/agent-bundle/tests/packed-stdio-projection.test.ts', From 4cb9b5d38f9e7afb0666c1ac302219d48aad5834 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 07:47:15 +0000 Subject: [PATCH 2/2] fix(config): check an augmenting declaration's Apps against the server's route-declared Apps A config App beside a route-generated server now shares the AB4325 name and AB4330 resourceUri collision checks with the src/mcp//apps/* routes, so the same resource URI can no longer reach the generated server twice. --- docs/entry-conventions.md | 2 +- packages/agent-bundle/src/config/validate.ts | 20 +++++++-- .../agent-bundle/tests/route-graph.test.ts | 42 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 3bc81d539..83214820f 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -100,7 +100,7 @@ server (config wins, conventions fill): | `env` | — | Applied verbatim beneath the injected plugin-root anchor (`AB4312` shape rules). | | `args` | The content-hashed entry path | Appended after the entry path (`AB4311` shape rules). | | `targets` | The project's selected targets | Replaces the default selection (`AB4305` shape rules). | -| `apps` | `src/mcp//apps/*` routes | Config-side Apps are compiled and registered on the generated server beside the route-declared ones (`AB432x` rules; `AB4334` checks App targets against the declared server targets). | +| `apps` | `src/mcp//apps/*` routes | Config-side Apps are compiled and registered on the generated server beside the route-declared ones (`AB432x` rules; `AB4334` checks App targets against the declared server targets). The route-declared Apps take part in the collision checks: reusing a route App's name is `AB4325`, reusing its `resourceUri` under another name is `AB4330`. | Provenance stays `conventional` (the first route module) because the routes supply the entry; `inspect` shows the merged `env`, `args`, and `targets`. diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 12cb5181d..3361ed54d 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -906,11 +906,25 @@ const validateMcp = ( // The same judgment normalization applies: a server the route graph // compiles in generated mode is declared by its route modules, and a config // block for it augments rather than redeclares (#380). - const generated = new Set((discovered.routeGraph?.servers ?? []) - .filter((server) => server.mode === 'generated' && server.routes.length > 0) - .map((server) => server.name)); + const generatedServers = (discovered.routeGraph?.servers ?? []) + .filter((server) => server.mode === 'generated' && server.routes.length > 0); + const generated = new Set(generatedServers.map((server) => server.name)); + // Route-declared Apps (`src/mcp//apps/*`) take part in the same + // name and resourceUri collision checks as configured ones: a config App + // that reuses a route App's name is AB4325 (a route module is never an + // identical config declaration) and one that reuses its resourceUri under + // another name is AB4330, instead of both Apps reaching the generated server. const names = new Map(); const uris = new Map(); + for (const server of generatedServers) { + for (const route of server.routes) { + if (route.kind !== 'app') continue; + const appName = route.id.slice(route.id.lastIndexOf('/') + 1); + if (!names.has(appName)) names.set(appName, undefined); + const resourceUri = route.config['resourceUri']; + if (typeof resourceUri === 'string' && !uris.has(resourceUri)) uris.set(resourceUri, appName); + } + } return Object.entries(mcp.servers).flatMap(([name, server]) => { const isGenerated = generated.has(name); const diagnostics = isGenerated diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 61c4b9476..4367e7a17 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -369,6 +369,48 @@ it('errors with AB4340 when a declaration for a route-generated server redeclare expect(codesOf(validation.diagnostics)).not.toContain('AB4800'); }); +it('checks an augmenting declaration\'s Apps against the route-declared Apps of the same server', async () => { + const configWithApps = (apps: string): string => [ + 'export default {', + ` mcp: { servers: { curator: { apps: { ${apps} } } } },`, + " plugin: { name: 'routes-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'); + const routes = { + 'src/mcp/curator/apps/dashboard.tsx': `export const config = { resourceUri: 'ui://curator/dashboard.html' }; ${moduleSource}`, + 'src/mcp/curator/tools/inspect.ts': moduleSource, + 'views/panel.ts': "document.body.textContent = 'panel';\n", + }; + + // Same resourceUri under another name: AB4330, not two Apps on one URI. + const sameUri = await createInspectProject({ + ...routes, + 'agent-bundle.config.ts': configWithApps("panel: { entry: './views/panel.ts', resourceUri: 'ui://curator/dashboard.html' }"), + }); + const sameUriErrors = (await validate({ root: sameUri })).diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); + expect(codesOf(sameUriErrors)).toEqual(['AB4330']); + + // Same name as a route App: AB4325 from validation, before the duplicate ID would surface as AB4101. + const sameName = await createInspectProject({ + ...routes, + 'agent-bundle.config.ts': configWithApps("dashboard: { entry: './views/panel.ts', resourceUri: 'ui://curator/panel.html' }"), + }); + const sameNameErrors = (await validate({ root: sameName })).diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); + expect(codesOf(sameNameErrors)).toEqual(['AB4325']); + + // A distinct name and URI still augments cleanly beside the route App. + const distinct = await createInspectProject({ + ...routes, + 'agent-bundle.config.ts': configWithApps("panel: { entry: './views/panel.ts', resourceUri: 'ui://curator/panel.html' }"), + }); + expect((await validate({ root: distinct })).diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + const ready = (await inspect({ root: distinct })) as ReadyInspectResult; + expect(ready.state).toBe('ready'); + expect(ready.model.mcpApps?.map((app) => app.id)).toEqual(['mcp-app:curator:dashboard', 'mcp-app:curator:panel']); +}); + it('applies the local-entry field rules to an augmenting declaration', async () => { const project = await createInspectProject({ 'agent-bundle.config.ts': [