diff --git a/.changeset/public-route-compiler.md b/.changeset/public-route-compiler.md new file mode 100644 index 000000000..7f1cd2945 --- /dev/null +++ b/.changeset/public-route-compiler.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Compile generated MCP route servers through a warm final-only Flight dispatcher and emit deterministic route types. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 10c7cd13e..61625014e 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -118,7 +118,7 @@ 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. | -## Route graph (`AB4800`–`AB4809`) +## Route graph (`AB4800`–`AB4812`) The route-graph compiler discovers conventional route modules (`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, @@ -141,6 +141,10 @@ optionally wrapped in unary `+`/`-`; `true`, `false`, and `null`; and accepted form. Anything else is dynamic: the route compiles with an empty config beside a named `AB4806` error. A module without a `config` export compiles silently with an empty config. +Generated route declarations are published at `.agent-bundle/routes.d.ts` from +the same graph. Development writes a sibling temporary file and renames it over +the prior complete declaration atomically; invalid source retains the prior +last-good file, while a successful route-free preparation removes it. Conventional `src/scripts/` routes ship through the same pipeline as explicit `scripts` entries (#102 stage 1): a plain module directly under @@ -160,6 +164,9 @@ cannot ship yet are hard errors (`AB4807`–`AB4809`), never silent omissions. | `AB4807` | error | A conventional `src/scripts/` route is a rendered-script module (`.tsx`/`.jsx`); rendered scripts are not supported yet. Rename it to `.ts`, prefix a path segment with `_` to keep it private, or declare it under `scripts` in config to opt into plain bundling. | | `AB4808` | error | A conventional `src/scripts/` route nests below the scripts root; conventional scripts ship as direct children only. Move it up, prefix a path segment with `_`, or declare it under `scripts` in config with a flat name. | | `AB4809` | error | A conventional `src/scripts/` route and a configured `scripts` entry share one script identity through different files. Point the config entry at the module to claim it, or rename one of the two. | +| `AB4810` | error | A generated MCP route is missing named `inputSchema`/`resultSchema` exports or its default export is not an async function component. | +| `AB4811` | error | A generated MCP route exports `execute` or `render`; route mode accepts only the async default Server Component contract. | +| `AB4812` | error | A generated MCP App route has no non-empty static `config.resourceUri`. | ## Development package build (`AB7103`) diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index a52934984..3f556fe88 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -12,6 +12,7 @@ import { emptyCompiledRouteGraph } from './routes/graph.ts'; import { inspectRouteGraph, type RouteGraphInspection } from './routes/inspect.ts'; import { mcpServerStateDirectory, runMcpForeground } from './services/mcp-run.ts'; export { compileRouteGraph, emptyCompiledRouteGraph, isEmptyRouteGraph } from './routes/graph.ts'; +export type { AppRouteConfig, PromptConfig, ResourceConfig, RouteSchema, RouteSchemaOutput, ToolConfig, ToolRouteProps } from './routes/public.ts'; export { inspectRouteGraph } from './routes/inspect.ts'; export type { RouteGraphInspection } from './routes/inspect.ts'; export { emptyRouteConfig } from './routes/types.ts'; diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index a2a14c653..844958abb 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -193,7 +193,7 @@ const plannedDestinations = (targets: readonly StagedTarget[]): readonly string[ ...target.compiledEntries.map((entry) => entry.output), ...target.compiledHooks.map((entry) => entry.output), ...target.compiledMcpApps.map((entry) => entry.output), - ...target.compiledMcpEntries.map((entry) => entry.output), + ...target.compiledMcpEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), ]); const hookIndexSourceInputs = ( @@ -238,11 +238,15 @@ const outputCandidatesFor = (options: { path: entry.output, sourceInputs: entry.sourceInputs, })), - ...options.compiledMcpEntries.map((entry) => ({ + ...options.compiledMcpEntries.flatMap((entry) => [{ kind: 'bundle' as const, path: entry.output, sourceInputs: entry.sourceInputs, - })), + }, ...(entry.workerOutput === undefined ? [] : [{ + kind: 'bundle' as const, + path: entry.workerOutput, + sourceInputs: entry.workerSourceInputs ?? entry.sourceInputs, + }])]), { kind: 'generated' as const, path: resolveArtifactDestination(options.artifactRoot, artifactHookIndexName), @@ -358,6 +362,7 @@ export const build = async (options: BuildOptions): Promise => { apps: targetMcpApps, cwd: options.projectRoot, outDir: target.root, + plugin: { name: options.model.metadata.name, version: options.model.metadata.version }, target: target.name, ...tools, }))); @@ -437,6 +442,7 @@ export const build = async (options: BuildOptions): Promise => { compiledMcpEntries: Object.freeze(compiledMcpEntries.map((entry) => Object.freeze({ ...entry, output: publishedOutput(entry), + ...(entry.workerOutput === undefined ? {} : { workerOutput: publishedOutput({ output: entry.workerOutput }) }), }))), manifest, outputProvenance, diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index fa9f5baf9..7d7948600 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -9,6 +9,8 @@ import { emitPlanEntries, resolveArtifactDestination } from './emit.ts'; import { scanEntryExports } from './entry-exports.ts'; import { generatedExecutableEntrySource, + generatedRouteFlightWorkerSource, + generatedRouteMcpEntrySource, generatedStdioMcpEntrySource, mcpEntryRuntimePath, mcpEntryRuntimeSpecifier, @@ -41,6 +43,8 @@ export interface CompiledHookEntry extends CompiledEntry { export interface CompiledMcpEntry extends CompiledEntry { readonly id: string; + readonly workerOutput?: string; + readonly workerSourceInputs?: readonly string[]; readonly target: string; } @@ -145,14 +149,23 @@ export const planCompiledMcpEntries = ( throw new Error(`Duplicate compiled MCP destination ${JSON.stringify(`mcp/${outputName}`)}.`); } names.add(name); + const sourceInputs = Object.freeze([...new Set([ + server.provenance.sourcePath, + server.source!, + ...(server.generatedRoutes ?? []).map((route) => route.source), + ])]); return Object.freeze({ id: server.id, name, output: resolveArtifactDestination(resolve(options.outDir, 'mcp'), outputName), outputKind: 'bundle', source: server.source!, - sourceInputs: Object.freeze([server.provenance.sourcePath, server.source!]), + sourceInputs, target: options.target, + ...(server.generatedRoutes === undefined ? {} : { + workerOutput: resolveArtifactDestination(resolve(options.outDir, 'mcp'), `${name}-flight.mjs`), + workerSourceInputs: sourceInputs, + }), }); })); }; @@ -163,6 +176,7 @@ export const compileMcpEntries = async ( readonly apps?: readonly CompiledMcpApp[]; readonly cwd: string; readonly outDir: string; + readonly plugin: { readonly name: string; readonly version: string }; readonly target: string; readonly tools?: AgentBundleToolsConfig; }, @@ -185,42 +199,86 @@ export const compileMcpEntries = async ( '', ].join('\n'); })); + const routeModuleSpecifier = 'agent-bundle/generated-route-server'; + const generatedRouteSources = compiled.map((entry) => { + const server = servers.find((candidate) => candidate.id === entry.id); + return server?.generatedRoutes === undefined + ? undefined + : generatedRouteMcpEntrySource({ + plugin: options.plugin, + routes: server.generatedRoutes, + serverName: server.name, + workerFile: `${entry.name}-flight.mjs`, + }); + }); + const generatedWorkerSources = compiled.map((entry) => { + const server = servers.find((candidate) => candidate.id === entry.id); + return server?.generatedRoutes === undefined + ? undefined + : generatedRouteFlightWorkerSource({ routes: server.generatedRoutes, serverName: server.name }); + }); // Factory-exporting entries (default export) are wrapped in the framework // stdio lifecycle shell; self-connecting entries keep today's behavior byte // for byte. The shell is aliased onto the local runtime module so emitted // bundles stay self-contained (no residual `agent-bundle` import). - const entryShells = await Promise.all(compiled.map(async (entry) => - (await scanEntryExports(entry.source)).hasDefaultExport + const entryShells = await Promise.all(compiled.map(async (entry, index) => { + if (generatedRouteSources[index] !== undefined) { + return generatedStdioMcpEntrySource({ + entrySource: routeModuleSpecifier, + serverName: entry.id.startsWith('mcp:') ? entry.id.slice('mcp:'.length) : entry.name, + }); + } + return (await scanEntryExports(entry.source)).hasDefaultExport ? generatedStdioMcpEntrySource({ entrySource: entry.source, serverName: entry.id.startsWith('mcp:') ? entry.id.slice('mcp:'.length) : entry.name, }) - : undefined)); + : undefined; + })); const runtimeShell = entryShells.some((shell) => shell !== undefined) ? mcpEntryRuntimePath() : undefined; + const mainEntries = compiled.map(({ id, name, source, sourceInputs }, index) => ({ + ...(entryShells[index] === undefined || runtimeShell === undefined + ? {} + : { + aliases: { [mcpEntryRuntimeSpecifier]: runtimeShell }, + virtualSource: entryShells[index], + }), + name, + outputRelativePath: `mcp/${name}.mjs`, + ...(generatedRouteSources[index] === undefined ? {} : { rscManifest: true as const }), + source, + sourceInputs: Object.freeze([ + ...sourceInputs, + ...(options.apps ?? []) + .filter((app) => app.serverIds.includes(id)) + .flatMap((app) => app.sourceInputs), + ]), + virtualModules: [ + { name: 'agent-bundle/mcp-apps', source: virtualSources[index]! }, + ...(generatedRouteSources[index] === undefined ? [] : [{ + name: routeModuleSpecifier, + source: generatedRouteSources[index], + }]), + ], + })); + const workerEntries = compiled.flatMap((entry, index) => { + const workerSource = generatedWorkerSources[index]; + if (workerSource === undefined) return []; + return [{ + name: `${entry.name}-flight`, + outputRelativePath: `mcp/${entry.name}-flight.mjs`, + reactServer: true as const, + rscManifest: true as const, + source: entry.source, + sourceInputs: entry.sourceInputs, + virtualSource: workerSource, + }]; + }); const evidence = await buildWithRslib({ cwd: options.cwd, - entries: compiled.map(({ id, name, source, sourceInputs }, index) => ({ - ...(entryShells[index] === undefined || runtimeShell === undefined - ? {} - : { - aliases: { [mcpEntryRuntimeSpecifier]: runtimeShell }, - virtualSource: entryShells[index], - }), - name, - outputRelativePath: `mcp/${name}.mjs`, - source, - sourceInputs: Object.freeze([ - ...sourceInputs, - ...(options.apps ?? []) - .filter((app) => app.serverIds.includes(id)) - .flatMap((app) => app.sourceInputs), - ]), - virtualModules: [{ - name: 'agent-bundle/mcp-apps', - source: virtualSources[index]!, - }], - })), + entries: [...mainEntries, ...workerEntries], ...(runtimeShell === undefined ? {} : { ignoredSourcePaths: [runtimeShell] }), + logLevel: 'error', outputRoot: options.outDir, ...(options.tools === undefined ? {} : { tools: options.tools }), }); @@ -228,6 +286,9 @@ export const compileMcpEntries = async ( return Object.freeze(compiled.map((entry) => Object.freeze({ ...entry, sourceInputs: evidenceByPath.get(`mcp/${entry.name}.mjs`) ?? (() => { throw new Error(`Missing bundled MCP evidence for ${JSON.stringify(entry.name)}.`); })(), + ...(entry.workerOutput === undefined ? {} : { + workerSourceInputs: evidenceByPath.get(`mcp/${entry.name}-flight.mjs`) ?? (() => { throw new Error(`Missing bundled MCP Flight worker evidence for ${JSON.stringify(entry.name)}.`); })(), + }), }))); }; diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 6d85e00d3..55f76e5f8 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -1,6 +1,9 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { stableJson } from '../core/digest.ts'; +import type { CompiledAgentRoute } from '../routes/types.ts'; + /** * Generated-entry templates: the framework-provided entry files consumers * would otherwise write by hand (react-router's provided-entry trick). Every @@ -68,3 +71,242 @@ export const generatedExecutableEntrySource = (options: { "if (typeof code === 'number') process.exitCode = code;", '', ].join('\n'); + + +export interface GeneratedRouteMcpEntryOptions { + readonly plugin: { readonly name: string; readonly version: string }; + readonly routes: readonly CompiledAgentRoute[]; + readonly serverName: string; + readonly workerFile: string; +} + +export interface GeneratedRouteFlightWorkerOptions { + readonly routes: readonly CompiledAgentRoute[]; + readonly serverName: string; +} + +const routeProtocolName = (route: CompiledAgentRoute): string => + route.id.slice(route.id.lastIndexOf('/') + 1); + +const selectedConfig = ( + config: Readonly>, + keys: readonly string[], +): Readonly> => Object.fromEntries( + keys.filter((key) => config[key] !== undefined).map((key) => [key, config[key]]), +); + +const executableMcpRoutes = (routes: readonly CompiledAgentRoute[]): readonly CompiledAgentRoute[] => + routes.filter((route) => route.kind !== 'app'); + +const routeImports = (routes: readonly CompiledAgentRoute[]): readonly string[] => + routes.map((route, index) => `import * as route${String(index)} from ${JSON.stringify(route.source)};`); + +const routeRecords = (routes: readonly CompiledAgentRoute[]): readonly string[] => + routes.map((route, index) => + ` ${JSON.stringify(route.id)}: Object.freeze({ config: ${stableJson(route.config)}, id: ${JSON.stringify(route.id)}, kind: ${JSON.stringify(route.kind)}, module: route${String(index)}, name: ${JSON.stringify(routeProtocolName(route))} }),`); + +/** The long-lived react-server worker used by one generated MCP process. */ +export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWorkerOptions): string => { + const routes = executableMcpRoutes(options.routes); + return [ + "import { parentPort } from 'node:worker_threads';", + "import { createElement } from 'react';", + "import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';", + "import { runAgentRequest } from '@agent-bundle/runtime';", + ...routeImports(routes), + '', + '// Generated routes contain only intrinsic Agent protocol elements, so no client references exist.', + 'globalThis.__rspack_rsc_manifest__ ??= Object.freeze({ clientManifest: Object.freeze({}) });', + "if (parentPort === null) throw new Error('Generated Flight worker requires a parent port.');", + 'const routes = Object.freeze({', + ...routeRecords(routes), + '});', + 'const requests = new Map();', + '', + 'const render = async (message) => {', + ' const route = routes[message.invocation.props.operationId];', + " if (route === undefined || typeof route.module.default !== 'function') throw new TypeError('Generated MCP route must default-export an async Server Component.');", + ' const controller = new AbortController();', + ' requests.set(message.id, controller);', + ' try {', + ' const bytes = await runAgentRequest({', + ' ...(message.actor === undefined ? {} : { actor: message.actor }),', + ' invocation: { kind: \'tool\', operationId: route.id, surface: route.name },', + ' ...(message.session === undefined ? {} : { session: message.session }),', + ' signal: controller.signal,', + ' }, async () => {', + ' const flight = renderAgentFlight(createElement(route.module.default, { input: message.invocation.props.input, signal: controller.signal }), { signal: controller.signal });', + ' return new Uint8Array(await new Response(flight).arrayBuffer());', + ' });', + ' parentPort.postMessage({ bytes, id: message.id, type: \'complete\' }, [bytes.buffer]);', + ' } catch (error) {', + " parentPort.postMessage({ id: message.id, message: error instanceof Error ? error.message : String(error), type: 'error' });", + ' } finally {', + ' requests.delete(message.id);', + ' }', + '};', + '', + 'parentPort.on(\'message\', (message) => {', + " if (message.type === 'cancel') { requests.get(message.id)?.abort(); return; }", + " if (message.type === 'render') void render(message);", + '});', + '', + ].join('\n'); +}; + +const routeRegistrations = (routes: readonly CompiledAgentRoute[]): readonly string[] => { + const registrations: string[] = []; + for (const route of routes) { + const access = `routes[${JSON.stringify(route.id)}]`; + switch (route.kind) { + case 'tool': { + const config = selectedConfig(route.config, ['_meta', 'annotations', 'description', 'icons', 'title']); + registrations.push([ + ` server.registerTool(${JSON.stringify(routeProtocolName(route))}, {`, + ` ...${stableJson(config)},`, + ` inputSchema: ${access}.module.inputSchema,`, + ` outputSchema: ${access}.module.resultSchema,`, + ` }, async (input, context) => projectToolResult(await renderRoute(dispatcher, ${access}, input, context)));`, + ].join('\n')); + break; + } + case 'resource': { + const uri = route.config['uri']; + if (typeof uri !== 'string' || uri.trim() === '') { + throw new Error(`Generated resource route ${JSON.stringify(route.id)} requires a non-empty static config.uri.`); + } + const config = selectedConfig(route.config, ['_meta', 'description', 'icons', 'mimeType', 'title']); + registrations.push([ + ` server.registerResource(${JSON.stringify(routeProtocolName(route))}, ${JSON.stringify(uri)}, ${stableJson(config)}, async (uri, context) => {`, + ` const rendered = await renderRoute(dispatcher, ${access}, { uri: uri.href }, context);`, + ' return rendered.result;', + ' });', + ].join('\n')); + break; + } + case 'prompt': { + const config = selectedConfig(route.config, ['_meta', 'description', 'icons', 'title']); + registrations.push([ + ` server.registerPrompt(${JSON.stringify(routeProtocolName(route))}, {`, + ` ...${stableJson(config)},`, + ` argsSchema: ${access}.module.inputSchema,`, + ` }, async (input, context) => (await renderRoute(dispatcher, ${access}, input, context)).result);`, + ].join('\n')); + break; + } + case 'app': + break; + case 'event-route': + case 'cli': + case 'script': + throw new Error(`Generated MCP server contains non-MCP route ${JSON.stringify(route.id)}.`); + default: { + const unreachable: never = route.kind; + throw new TypeError(`Unhandled generated route kind ${String(unreachable)}.`); + } + } + } + return registrations; +}; + +/** + * The generated MCP entry owns the final-only dispatcher and one warm Flight + * worker. The worker is split only to satisfy React's react-server condition; + * it is reused for every request until the MCP server closes. + */ +export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOptions): string => { + const routes = executableMcpRoutes(options.routes); + return [ + "import { Worker } from 'node:worker_threads';", + "import { McpServer } from '@modelcontextprotocol/server';", + "import { agent, available, createAgentRenderDispatcher, runAgentRequest } from '@agent-bundle/runtime';", + "import mcpApps from 'agent-bundle/mcp-apps';", + ...routeImports(routes), + '', + 'const routes = Object.freeze({', + ...routeRecords(routes), + '});', + '', + 'const createWorkerHost = () => {', + ` const worker = new Worker(new URL(${JSON.stringify(`./${options.workerFile}`)}, import.meta.url));`, + ' const pending = new Map();', + ' let sequence = 0;', + ' const failPending = (error) => { for (const request of pending.values()) request.reject(error); pending.clear(); };', + ' worker.on(\'error\', failPending);', + ' worker.on(\'exit\', (code) => { if (code !== 0) failPending(new Error(`Generated Flight worker exited with code ${String(code)}.`)); });', + ' worker.on(\'message\', (message) => {', + ' const request = pending.get(message.id);', + ' if (request === undefined) return;', + ' pending.delete(message.id);', + ' request.signal.removeEventListener(\'abort\', request.abort);', + " if (message.type === 'error') { request.reject(new Error(message.message)); return; }", + ' request.resolve(new ReadableStream({ start(controller) { controller.enqueue(message.bytes); controller.close(); } }));', + ' });', + ' return Object.freeze({', + ' close: async () => { await worker.terminate(); },', + ' execute: async ({ invocation, signal }) => {', + ' const context = await agent();', + ' const id = ++sequence;', + ' return new Promise((resolve, reject) => {', + " const abort = () => { worker.postMessage({ id, type: 'cancel' }); pending.delete(id); reject(new DOMException('Agent render was aborted', 'AbortError')); };", + ' pending.set(id, { abort, reject, resolve, signal });', + " signal.addEventListener('abort', abort, { once: true });", + ' if (signal.aborted) { abort(); return; }', + " worker.postMessage({ actor: context.actor, id, invocation, session: context.session, type: 'render' });", + ' });', + ' },', + ' });', + '};', + '', + 'const requestIdentity = (context) => ({', + ' ...(context.http?.authInfo?.clientId === undefined ? {} : { actor: available({ id: context.http.authInfo.clientId }, \'native\') }),', + " ...(typeof context.sessionId === 'string' && context.sessionId.trim() !== '' ? { session: available({ sessionId: context.sessionId }, 'native') } : {}),", + '});', + '', + 'const renderRoute = async (dispatcher, route, input, context) => runAgentRequest({', + ' ...requestIdentity(context),', + " invocation: { kind: 'tool', operationId: route.id, surface: route.name },", + ' signal: context.mcpReq.signal,', + '}, async () => {', + ' const document = await dispatcher.dispatch({ invocation: { kind: \'tool\', props: { input, operationId: route.id } }, signal: context.mcpReq.signal });', + ' return { document, result: route.module.resultSchema.parse(document.value) };', + '});', + '', + 'const appendNode = (node, content) => {', + ' switch (node.kind) {', + " case 'result': for (const child of node.children) appendNode(child, content); break;", + " case 'markdown':", + " case 'text': content.push({ text: node.text, type: 'text' }); break;", + " case 'json': content.push({ text: JSON.stringify(node.value), type: 'text' }); break;", + " case 'progress': content.push({ text: node.message ?? `Progress: ${node.completed}${node.total === undefined ? '' : `/${node.total}`}`, type: 'text' }); break;", + " case 'image': content.push({ data: node.data, mimeType: node.mimeType, type: 'image' }); break;", + " case 'audio': content.push({ data: node.data, mimeType: node.mimeType, type: 'audio' }); break;", + " case 'resource': content.push({ ...(node.mimeType === undefined ? {} : { mimeType: node.mimeType }), name: node.name, type: 'resource_link', uri: node.uri }); break;", + " case 'error': content.push({ text: `[${node.code}] ${node.message}`, type: 'text' }); break;", + " default: throw new TypeError(`Unsupported Agent Document node: ${String(node.kind)}`);", + ' }', + '};', + '', + 'const projectToolResult = ({ document, result }) => {', + ' const content = [];', + ' appendNode(document.root, content);', + ' return { content, ...(document.status === \'represented-error\' ? { isError: true } : {}), ...(result !== null && typeof result === \'object\' && !Array.isArray(result) ? { structuredContent: result } : {}) };', + '};', + '', + 'const createGeneratedRouteServer = () => {', + ` const server = new McpServer(${stableJson(options.plugin)});`, + ' const workerHost = createWorkerHost();', + ' const dispatcher = createAgentRenderDispatcher(workerHost);', + ...routeRegistrations(routes), + ' for (const app of mcpApps) {', + ' server.registerResource(app.name, app.resourceUri, { ...(app._meta === undefined ? {} : { _meta: app._meta }), mimeType: app.mimeType }, async (uri) => ({ contents: [{ mimeType: app.mimeType, text: app.html, uri: uri.href }] }));', + ' }', + ' const close = server.close.bind(server);', + ' server.close = async () => { await workerHost.close(); await close(); };', + ' return server;', + '};', + '', + 'export default createGeneratedRouteServer;', + '', + ].join('\n'); +}; diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 0e4f183ef..9bae7ba99 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -3,6 +3,8 @@ import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts' import { scanEntryExports } from './entry-exports.ts'; import { generatedExecutableEntrySource, + generatedRouteFlightWorkerSource, + generatedRouteMcpEntrySource, generatedStdioMcpEntrySource, mcpEntryRuntimePath, mcpEntryRuntimeSpecifier, @@ -153,25 +155,44 @@ const mcpEntryEntries = async ( ): Promise => { const outputRoot = artifactOutputToken(target); const planned = planCompiledMcpEntries(model.mcpServers, { outDir: outputRoot, target }); - return Promise.all(planned.map(async (entry) => { - const wrapped = (await scanEntryExports(entry.source)).hasDefaultExport; + const entries: BundlerInspectionEntry[] = []; + for (const entry of planned) { + const server = model.mcpServers.find((candidate) => candidate.id === entry.id); const serverName = entry.id.startsWith('mcp:') ? entry.id.slice('mcp:'.length) : entry.name; - return rslibInspectionEntry({ + const generatedRoutes = server?.generatedRoutes; + const wrapped = generatedRoutes !== undefined || (await scanEntryExports(entry.source)).hasDefaultExport; + const workerFile = `${entry.name}-flight.mjs`; + const routeSource = generatedRoutes === undefined + ? undefined + : generatedRouteMcpEntrySource({ + plugin: { name: model.metadata.name, version: model.metadata.version }, + routes: generatedRoutes, + serverName, + workerFile, + }); + entries.push(rslibInspectionEntry({ entry: { ...(wrapped ? { aliases: { [mcpEntryRuntimeSpecifier]: mcpEntryRuntimePath() }, - virtualSource: generatedStdioMcpEntrySource({ entrySource: entry.source, serverName }), + virtualSource: generatedStdioMcpEntrySource({ + entrySource: routeSource === undefined ? entry.source : 'agent-bundle/generated-route-server', + serverName, + }), } : {}), name: entry.name, outputRelativePath: `mcp/${entry.name}.mjs`, + ...(routeSource === undefined ? {} : { rscManifest: true as const }), source: entry.source, sourceInputs: [], - virtualModules: [{ - name: 'agent-bundle/mcp-apps', - source: '/* The MCP App registry virtual module is generated from built app HTML at build time. */', - }], + virtualModules: [ + { + name: 'agent-bundle/mcp-apps', + source: '/* The MCP App registry virtual module is generated from built app HTML at build time. */', + }, + ...(routeSource === undefined ? [] : [{ name: 'agent-bundle/generated-route-server', source: routeSource }]), + ], }, kind: 'mcp-entry', name: serverName, @@ -180,8 +201,29 @@ const mcpEntryEntries = async ( source: entry.source, target, ...(tools === undefined ? {} : { tools }), - }); - })); + })); + if (generatedRoutes !== undefined) { + entries.push(rslibInspectionEntry({ + entry: { + name: `${entry.name}-flight`, + outputRelativePath: `mcp/${workerFile}`, + reactServer: true, + rscManifest: true, + source: entry.source, + sourceInputs: [], + virtualSource: generatedRouteFlightWorkerSource({ routes: generatedRoutes, serverName }), + }, + kind: 'mcp-entry', + name: `${serverName}:flight`, + outputPath: `${target}/mcp/${workerFile}`, + outputRoot, + source: entry.source, + target, + ...(tools === undefined ? {} : { tools }), + })); + } + } + return Object.freeze(entries); }; const hookEntries = ( diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 0e2fcc901..e00db5c71 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -31,6 +31,10 @@ export interface RslibEntry { readonly dts?: boolean; readonly name: string; readonly outputRelativePath: string; + /** Inject the intrinsic-only RSC manifest consumed by Flight client/server packages. */ + readonly rscManifest?: true; + /** Resolve React through its server condition for a generated Flight worker. */ + readonly reactServer?: true; readonly source: string; readonly sourceInputs: readonly string[]; /** TypeScript project driving declaration generation and compiler options. */ @@ -428,6 +432,15 @@ export const composeEntryLibConfig = ( ]); const enforceInvariants = (config: Rspack.Configuration): Rspack.Configuration => { config.output = { ...config.output, asyncChunks: false }; + if (entry.rscManifest === true) { + config.plugins = [ + ...(config.plugins ?? []), + new rspack.DefinePlugin({ __rspack_rsc_manifest__: JSON.stringify({ clientManifest: {}, cssLinkProps: {}, entryCssFiles: {}, entryJsFiles: [], moduleLoading: { prefix: '' }, serverConsumerModuleMap: {}, serverManifest: {} }) }), + ]; + } + if (entry.reactServer === true) { + config.resolve = { ...config.resolve, conditionNames: ['react-server', '...'] }; + } const aliasViolation = reservedAliasViolation( config.resolve?.alias as Readonly> | undefined, reserved, diff --git a/packages/agent-bundle/src/build/validate-artifact-mcp.ts b/packages/agent-bundle/src/build/validate-artifact-mcp.ts index 3d9464cad..f7247466a 100644 --- a/packages/agent-bundle/src/build/validate-artifact-mcp.ts +++ b/packages/agent-bundle/src/build/validate-artifact-mcp.ts @@ -276,6 +276,14 @@ export const validateMcpCoherence = async (options: { for (const [path, occurrences] of referenceCounts) { if (occurrences.length === 1) continue; + if (occurrences.length === 0 && path.endsWith('-flight.mjs')) { + const mainPath = path.slice(0, -'-flight.mjs'.length) + '.mjs'; + const mainReferences = referenceCounts.get(mainPath); + if (mainReferences?.length === 1) { + const mainSource = await readFile(resolve(artifactRoot, mainPath), 'utf8'); + if (mainSource.includes(`./${posix.basename(path)}`)) continue; + } + } diagnostics.push(diagnostic( 'AB6017', occurrences.length === 0 diff --git a/packages/agent-bundle/src/config/index.ts b/packages/agent-bundle/src/config/index.ts index 1a66f46ae..05e53055a 100644 --- a/packages/agent-bundle/src/config/index.ts +++ b/packages/agent-bundle/src/config/index.ts @@ -5,6 +5,7 @@ import type { AgentBundleConfig as CoreAgentBundleConfig } from '../core/types.t export { discoverProject } from './discover.ts'; export { defineConfig } from '../core/types.ts'; +export type { AppRouteConfig, PromptConfig, ResourceConfig, RouteSchema, RouteSchemaOutput, ToolConfig, ToolRouteProps } from '../routes/public.ts'; export type { AgentBundleRuntimeConfig, ConfigFactory, ConfigFactoryContext } from '../core/types.ts'; export type { DiscoveredProject } from './discover.ts'; export { loadConfig } from './load.ts'; diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 4fc0c0dc6..aaf65bdfa 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -496,25 +496,46 @@ const normalizeMcpServer = ( const normalizeMcpServers = ( loaded: LoadedConfig, + discovered: DiscoveredProject, targetNames: readonly string[], payloads: readonly NormalizedPayload[], ): readonly NormalizedMcpServer[] => { - const servers = loaded.config.mcp?.servers; - if (servers === undefined) return []; - + const configured = loaded.config.mcp?.servers ?? {}; + const generated = new Map((discovered.routeGraph?.servers ?? []) + .filter((server) => server.mode === 'generated' && server.routes.length > 0) + .map((server) => [server.name, server])); + const names = sortedUnique([...Object.keys(configured), ...generated.keys()]); const provenance: SourceProvenance = { kind: 'config', sourcePath: loaded.configPath }; - return Object.entries(servers) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([name, server]) => - normalizeMcpServer(name, server, loaded.context.projectRoot, targetNames, provenance, payloads)); + + return names.map((name) => { + const routeServer = generated.get(name); + if (routeServer === undefined) { + return normalizeMcpServer(name, configured[name]!, loaded.context.projectRoot, targetNames, provenance, payloads); + } + const declaration = configured[name] ?? {}; + const source = routeServer.routes[0]!.source; + return { + ...(declaration.env === undefined ? {} : { env: { ...declaration.env } }), + args: [`mcp/${mcpEntryName(name)}`, ...(declaration.args ?? [])], + command: 'node', + cwd: pathTokens.pluginRoot, + generatedRoutes: routeServer.routes, + id: routeServer.id, + name, + provenance: { kind: 'conventional', sourcePath: source }, + source, + targets: sortedUnique(declaration.targets ?? targetNames), + transport: 'stdio', + }; + }); }; const normalizeMcpApps = ( loaded: LoadedConfig, + discovered: DiscoveredProject, servers: readonly NormalizedMcpServer[], ): readonly NormalizedMcpApp[] => { - const configured = loaded.config.mcp?.servers; - if (configured === undefined) return []; + const configured = loaded.config.mcp?.servers ?? {}; const provenance: SourceProvenance = { kind: 'config', sourcePath: loaded.configPath }; const serverByName = new Map(servers.map((server) => [server.name, server])); const apps: NormalizedMcpApp[] = []; @@ -543,7 +564,37 @@ const normalizeMcpApps = ( } } - return apps; + for (const surface of discovered.routeGraph?.servers ?? []) { + if (surface.mode !== 'generated') continue; + const server = serverByName.get(surface.name); + if (server === undefined) continue; + for (const route of surface.routes) { + if (route.kind !== 'app') continue; + const resourceUri = route.config['resourceUri']; + if (typeof resourceUri !== 'string' || resourceUri.trim() === '') continue; + const name = route.id.slice(route.id.lastIndexOf('/') + 1); + const declaredTargets = route.config['targets']; + const targets = Array.isArray(declaredTargets) && declaredTargets.every((target) => typeof target === 'string') + ? declaredTargets + : server.targets; + const metadata = route.config['_meta']; + const template = route.config['template']; + apps.push({ + ...(isRecord(metadata) ? { _meta: structuredClone(metadata) } : {}), + id: `mcp-app:${surface.name}:${name}`, + name, + provenance: { kind: 'conventional', sourcePath: route.source }, + resourceUri, + serverId: surface.id, + serverName: surface.name, + source: route.source, + targets: sortedUnique(targets), + ...(typeof template === 'string' ? { template: resolve(loaded.context.projectRoot, template) } : {}), + }); + } + } + + return apps.sort((left, right) => left.id.localeCompare(right.id)); }; const bundleExtensions = new Set([ @@ -783,7 +834,7 @@ export const normalizeProject = async ( const packageIdentity = snapshotPackageIdentity(loaded.context.projectRoot); const nativeHooks = await normalizeNativeHooks(loaded, targetNames, registry); const payloads = normalizePayloads(loaded, discovered, targetNames); - const mcpServers = normalizeMcpServers(loaded, targetNames, payloads); + const mcpServers = normalizeMcpServers(loaded, discovered, targetNames, payloads); const scripts = normalizeScripts(loaded, discovered, targetNames); const assets = normalizeAssets(loaded, discovered, targetNames); const packageBuild = normalizePackageBuild(loaded.config, loaded.context.projectRoot, loaded.configPath); @@ -800,7 +851,7 @@ export const normalizeProject = async ( provenance: configProvenance, version: loaded.config.plugin.version, }, - mcpApps: normalizeMcpApps(loaded, mcpServers), + mcpApps: normalizeMcpApps(loaded, discovered, mcpServers), mcpServers, hooks: normalizeHooks(loaded, targetNames, registry, payloads), ...(nativeHooks.length === 0 ? {} : { nativeHooks }), diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 6e4270a06..ad44a900e 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -1,5 +1,7 @@ import type { EnvironmentConfig } from '@rsbuild/core'; +import type { CompiledAgentRoute } from '../routes/types.ts'; + export interface AgentBundlePluginConfig { description?: string; name: string; @@ -299,6 +301,8 @@ export interface NormalizedSkill { export interface NormalizedMcpServer { readonly args?: readonly string[]; + /** Filesystem routes compiled into this framework-generated server entry. */ + readonly generatedRoutes?: readonly CompiledAgentRoute[]; readonly command?: string; readonly cwd?: string; readonly env?: Readonly>; diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index 2535b1438..8ee0582eb 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -28,6 +28,8 @@ import type { NormalizedMcpServer, NormalizedPlugin, } from '../core/types.ts'; +import { emptyCompiledRouteGraph } from '../routes/graph.ts'; +import { writeRouteTypes } from '../routes/typegen.ts'; import type { CompiledRouteGraph } from '../routes/types.ts'; import type { DevRuntimePreparedMcpApp, DevRuntimePreparedMcpServer, DevRuntimePreparedProject } from './runtime-provider.ts'; import { freezeJsonValue, type JsonObject, type JsonValue, type SourceStatus } from './types.ts'; @@ -814,6 +816,7 @@ export class ProjectService { discovered, registry, ); + await writeRouteTypes(root, discovered.routeGraph ?? emptyCompiledRouteGraph); } catch (error) { if (isConfigExtensionFiniteJsonError(error)) { return failedPreparation( diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index e3b42eae0..69f0b9fc3 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -4,6 +4,7 @@ import type { PortableConfigExtension } from './adapters/portable.ts'; import type { AgentBundleConfig as CoreAgentBundleConfig } from './core/types.ts'; export { defineConfig, pathTokens, pluginRootEnvAnchor } from './core/types.ts'; +export type { AppRouteConfig, PromptConfig, ResourceConfig, RouteSchema, RouteSchemaOutput, ToolConfig, ToolRouteProps } from './routes/public.ts'; export { compareEvals, runEvals, startDevServer } from './api.ts'; export { createCodexEvalHarness, diff --git a/packages/agent-bundle/src/routes/contract.ts b/packages/agent-bundle/src/routes/contract.ts new file mode 100644 index 000000000..2c818f02c --- /dev/null +++ b/packages/agent-bundle/src/routes/contract.ts @@ -0,0 +1,97 @@ +import ts from 'typescript-5'; + +import type { Diagnostic } from '../core/diagnostics.ts'; + +const modifier = (node: ts.Node, kind: ts.SyntaxKind): boolean => + ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((item) => item.kind === kind) ?? false); + +const exported = (node: ts.Node): boolean => modifier(node, ts.SyntaxKind.ExportKeyword); +const asynchronous = (node: ts.Node): boolean => modifier(node, ts.SyntaxKind.AsyncKeyword); + +const unwrappedExpression = (expression: ts.Expression): ts.Expression => { + let current = expression; + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isSatisfiesExpression(current) || + ts.isNonNullExpression(current) + ) { + current = current.expression; + } + return current; +}; + +const diagnostic = ( + code: 'AB4810' | 'AB4811', + message: string, + sourcePath: string, + recovery: string, +): Diagnostic => ({ code, message, recovery, severity: 'error', sourcePath }); + +/** Validates G8's one executable route contract without evaluating the module. */ +export const validateRouteModuleContract = ( + moduleText: string, + relativePath: string, + sourcePath: string, +): readonly Diagnostic[] => { + const sourceFile = ts.createSourceFile(relativePath, moduleText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const named = new Set(); + let asyncDefault = false; + let splitExport = false; + + for (const statement of sourceFile.statements) { + if (ts.isVariableStatement(statement) && exported(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name)) continue; + named.add(declaration.name.text); + if (declaration.name.text === 'execute' || declaration.name.text === 'render') splitExport = true; + } + continue; + } + if (ts.isFunctionDeclaration(statement) && exported(statement)) { + if (modifier(statement, ts.SyntaxKind.DefaultKeyword)) { + asyncDefault = asynchronous(statement); + } else if (statement.name !== undefined) { + named.add(statement.name.text); + if (statement.name.text === 'execute' || statement.name.text === 'render') splitExport = true; + } + continue; + } + if (ts.isExportAssignment(statement) && !statement.isExportEquals) { + const expression = unwrappedExpression(statement.expression); + asyncDefault = (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) && asynchronous(expression); + continue; + } + if (ts.isExportDeclaration(statement) && statement.exportClause !== undefined && ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) { + const name = element.name.text; + named.add(name); + if (name === 'execute' || name === 'render') splitExport = true; + } + } + } + + const missing = ['inputSchema', 'resultSchema'].filter((name) => !named.has(name)); + const diagnostics: Diagnostic[] = []; + if (missing.length > 0 || !asyncDefault) { + const details = [ + ...(missing.length === 0 ? [] : [`missing named ${missing.join(' and ')}`]), + ...(asyncDefault ? [] : ['default export is not an async function component']), + ]; + diagnostics.push(diagnostic( + 'AB4810', + `Route module ${relativePath} does not satisfy the public route contract: ${details.join('; ')}.`, + sourcePath, + 'Export const inputSchema and resultSchema, plus one async default Server Component receiving { input, signal }.', + )); + } + if (splitExport) { + diagnostics.push(diagnostic( + 'AB4811', + `Route module ${relativePath} exports execute or render; routed modules use one async default Server Component instead of an execute/render split.`, + sourcePath, + 'Move execution into the async default component and render Agent.* elements from that component.', + )); + } + return Object.freeze(diagnostics); +}; diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 8c26dd278..67126061e 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -6,6 +6,7 @@ import fastGlob from 'fast-glob'; import { isProjectPathIgnored, readProjectIgnoreRules, toPosixPath } from '../config/ignore.ts'; import { extractRouteConfig } from './config-extract.ts'; +import { validateRouteModuleContract } from './contract.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { digest } from '../core/digest.ts'; import { deepFreeze } from '../core/freeze.ts'; @@ -479,6 +480,31 @@ export const compileRouteGraph = async ( conventionalEntry ?? routes[0]!.source, )); } + if (mode === 'generated') { + for (const route of routes) { + if (route.kind === 'app') { + const resourceUri = route.config['resourceUri']; + if (typeof resourceUri !== 'string' || resourceUri.trim() === '') { + diagnostics.push(routeError( + 'AB4812', + `MCP App route ${route.provenance.relativePath} requires a non-empty static config.resourceUri.`, + 'Export const config with the App resourceUri, then inspect again.', + route.source, + )); + } + continue; + } + try { + diagnostics.push(...validateRouteModuleContract( + await readFile(route.source, 'utf8'), + route.provenance.relativePath, + route.source, + )); + } catch { + // Racing deletion is handled by the next source snapshot. + } + } + } servers.push({ id: `mcp:${name}`, mode, diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index 42b447c06..0c397ecf3 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -17,3 +17,6 @@ export type { CompiledServerSurface, RouteProvenance, } from './types.ts'; +export { generateRouteTypes, routeTypesRelativePath, writeRouteTypes } from './typegen.ts'; +export { validateRouteModuleContract } from './contract.ts'; +export type { AppRouteConfig, PromptConfig, ResourceConfig, RouteSchema, RouteSchemaOutput, ToolConfig, ToolRouteProps } from './public.ts'; diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts new file mode 100644 index 000000000..024991da1 --- /dev/null +++ b/packages/agent-bundle/src/routes/public.ts @@ -0,0 +1,40 @@ +/** The structural schema surface route props infer without coupling to one schema library. */ +export interface RouteSchema { + readonly _output: Output; +} + +export type RouteSchemaOutput = Schema extends RouteSchema ? Output : never; + +/** Props received by every executable MCP route's async default Server Component. */ +export interface ToolRouteProps { + readonly input: RouteSchemaOutput; + readonly signal: AbortSignal; +} + +export interface ToolConfig { + readonly _meta?: Readonly>; + readonly annotations?: Readonly>; + readonly description?: string; + readonly title?: string; +} + +export interface ResourceConfig { + readonly _meta?: Readonly>; + readonly description?: string; + readonly mimeType?: string; + readonly title?: string; + readonly uri: string; +} + +export interface PromptConfig { + readonly _meta?: Readonly>; + readonly description?: string; + readonly title?: string; +} + +export interface AppRouteConfig { + readonly _meta?: Readonly>; + readonly resourceUri: string; + readonly targets?: readonly string[]; + readonly template?: string; +} diff --git a/packages/agent-bundle/src/routes/typegen.ts b/packages/agent-bundle/src/routes/typegen.ts new file mode 100644 index 000000000..6504c0b3a --- /dev/null +++ b/packages/agent-bundle/src/routes/typegen.ts @@ -0,0 +1,67 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, extname, join, relative } from 'node:path'; + +import type { CompiledAgentRoute, CompiledRouteGraph } from './types.ts'; + +export const routeTypesRelativePath = '.agent-bundle/routes.d.ts'; + +const executableRoutes = (graph: CompiledRouteGraph): readonly CompiledAgentRoute[] => Object.freeze([ + ...graph.servers.flatMap((server) => server.routes.filter((route) => route.kind !== 'app')), + ...(graph.cli?.routes ?? []), + ...graph.events, + ...graph.scripts, +].sort((left, right) => left.id.localeCompare(right.id))); + +const declarationImport = (route: CompiledAgentRoute, index: number): string => { + const relativePath = route.provenance.relativePath; + const extension = extname(relativePath); + const modulePath = `../${relativePath.slice(0, -extension.length)}.js`; + return `import type * as route${String(index)} from ${JSON.stringify(modulePath)};`; +}; + +/** Deterministic declarations derived from the exact immutable graph used by packaging. */ +export const generateRouteTypes = (graph: CompiledRouteGraph): string => { + const routes = executableRoutes(graph); + return [ + '// Generated by agent-bundle. Do not edit.', + ...routes.map(declarationImport), + '', + 'type SchemaOutput = Schema extends { readonly _output: infer Output } ? Output : never;', + 'export type RouteContract = Readonly<{', + ' input: SchemaOutput;', + ' result: SchemaOutput;', + '}>;', + '', + 'export interface AgentBundleRoutes {', + ...routes.map((route, index) => + ` ${JSON.stringify(route.id)}: RouteContract;`), + '}', + '', + 'export type RouteId = keyof AgentBundleRoutes;', + 'export type RouteInput = AgentBundleRoutes[Id][\'input\'];', + 'export type RouteResult = AgentBundleRoutes[Id][\'result\'];', + '', + ].join('\n'); +}; + +/** + * Publishes typegen with a same-directory rename so dev readers observe the + * previous complete file or the next complete file, never a partial write. + */ +export const writeRouteTypes = async (root: string, graph: CompiledRouteGraph): Promise => { + const output = join(root, routeTypesRelativePath); + if (executableRoutes(graph).length === 0) { + await rm(output, { force: true }); + return relative(root, output).replaceAll('\\', '/'); + } + await mkdir(dirname(output), { recursive: true }); + const temporary = `${output}.${String(process.pid)}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, generateRouteTypes(graph), 'utf8'); + await rename(temporary, output); + } finally { + await rm(temporary, { force: true }); + } + return relative(root, output).replaceAll('\\', '/'); +}; diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 0008f80e7..cdf7d080a 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -887,6 +887,7 @@ it('reports an orphan compiler MCP output after the artifact is rehashed', async }, { contents: 'export const server = true;\n', kind: 'bundle' as const, path: 'coherent/mcp/mcp-server-deadbeef.mjs' }, { contents: 'export const orphan = true;\n', kind: 'bundle' as const, path: 'coherent/mcp/mcp-junk-deadbeef.mjs' }, + { contents: 'export const orphanWorker = true;\n', kind: 'bundle' as const, path: 'coherent/mcp/mcp-junk-deadbeef-flight.mjs' }, ]; const root = await writeArtifact(files, true, [coherenceManifestTarget]); @@ -894,6 +895,7 @@ it('reports an orphan compiler MCP output after the artifact is rehashed', async const diagnostics = await validateArtifact({ artifactRoot: root, registry: coherenceRegistry() }); expect(diagnostics).toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6017', generatedPath: 'coherent/mcp/mcp-junk-deadbeef.mjs', target: coherenceTarget }), + expect.objectContaining({ code: 'AB6017', generatedPath: 'coherent/mcp/mcp-junk-deadbeef-flight.mjs', target: coherenceTarget }), ])); expect(diagnostics).not.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6004' }), diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 7e887fa64..03607bcde 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -3,6 +3,7 @@ import { access } from 'node:fs/promises'; import { describe, expect, it } from '@rstest/core'; import { scanEntryExportsSource, stripCommentsAndStrings } from '../src/build/entry-exports.ts'; +import * as entryShellModule from '../src/build/entry-shell.ts'; import { generatedExecutableEntrySource, generatedStdioMcpEntrySource, @@ -85,3 +86,73 @@ describe('generated entry templates', () => { expect(generatedExecutableEntrySource({ entrySource: '/e.ts', exportName: 'default' })).toContain('entry["default"]'); }); }); + + +it('generates one final-only Flight MCP factory from filesystem routes', () => { + const generate = (entryShellModule as unknown as { + readonly generatedRouteMcpEntrySource?: (options: Readonly>) => string; + }).generatedRouteMcpEntrySource; + expect(typeof generate).toBe('function'); + if (generate === undefined) return; + + const source = generate({ + plugin: { name: 'route-fixture', version: '1.2.3' }, + routes: [ + { + config: { annotations: { readOnlyHint: true }, description: 'Inspect sources.' }, + id: 'tool:curator/inspect', + kind: 'tool', + source: '/project/src/mcp/curator/tools/inspect.tsx', + }, + { + config: { description: 'Catalog.', mimeType: 'application/json', uri: 'catalog://books' }, + id: 'resource:curator/catalog', + kind: 'resource', + source: '/project/src/mcp/curator/resources/catalog.tsx', + }, + { + config: { description: 'Curate books.' }, + id: 'prompt:curator/curate', + kind: 'prompt', + source: '/project/src/mcp/curator/prompts/curate.tsx', + }, + ], + serverName: 'curator', + workerFile: 'mcp-curator-flight.mjs', + }); + + expect(source).toContain("from '@agent-bundle/runtime'"); + expect(source).toContain("from 'node:worker_threads'"); + expect(source).toContain('mcp-curator-flight.mjs'); + expect(source).toContain("from '@modelcontextprotocol/server'"); + expect(source).toContain("from 'agent-bundle/mcp-apps'"); + expect(source).toContain('createAgentRenderDispatcher'); + expect(source).toContain('runAgentRequest'); + expect(source).toContain('server.registerTool("inspect"'); + expect(source).toContain('server.registerResource("catalog", "catalog://books"'); + expect(source).toContain('server.registerPrompt("curate"'); + expect(source).toContain('export default createGeneratedRouteServer'); + expect(source).not.toContain('lowerMcpResult'); +}); + + +it('generates the warm react-server Flight worker separately from the MCP dispatcher', () => { + const generate = (entryShellModule as unknown as { + readonly generatedRouteFlightWorkerSource?: (options: Readonly>) => string; + }).generatedRouteFlightWorkerSource; + expect(typeof generate).toBe('function'); + if (generate === undefined) return; + const source = generate({ + routes: [{ + config: {}, + id: 'tool:curator/inspect', + kind: 'tool', + source: '/project/src/mcp/curator/tools/inspect.tsx', + }], + serverName: 'curator', + }); + expect(source).toContain("from '@agent-bundle/runtime/flight/server'"); + expect(source).toContain("from 'node:worker_threads'"); + expect(source).toContain('runAgentRequest'); + expect(source).toContain('/project/src/mcp/curator/tools/inspect.tsx'); +}); diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts new file mode 100644 index 000000000..035d9d1de --- /dev/null +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -0,0 +1,134 @@ +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, expect, it } from '@rstest/core'; + +import { build } from '../src/api.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const writeProjectFile = async (root: string, path: string, contents: string): Promise => { + const output = join(root, path); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, contents); +}; + +it('lists and calls a generated filesystem tool through final-only Flight', { retry: 2, timeout: 60_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-routes-')); + 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-routes-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + "export default defineConfig({ plugin: { name: 'generated-routes-fixture', version: '1.0.0' }, targets: ['portable'] });", + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/curator/resources/catalog.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + "export const config = { description: 'Read the catalog.', mimeType: 'application/json', uri: 'catalog://books' };", + "export const inputSchema = z.object({ uri: z.string() }).strict();", + "export const resultSchema = z.object({ contents: z.array(z.object({ mimeType: z.string(), text: z.string(), uri: z.string() })) }).strict();", + 'export default async function Catalog({ input }) {', + " const result = { contents: [{ mimeType: 'application/json', text: '{\"books\":1}', uri: input.uri }] };", + " return createElement(Agent.Result, { value: result }, createElement(Agent.Text, null, 'Catalog ready.'));", + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/curator/prompts/curate.tsx', [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + "export const config = { description: 'Curate one genre.' };", + "export const inputSchema = z.object({ genre: z.string() }).strict();", + "export const resultSchema = z.object({ messages: z.array(z.object({ content: z.object({ text: z.string(), type: z.literal('text') }), role: z.literal('user') })) }).strict();", + 'export default async function Curate({ input }) {', + " const result = { messages: [{ content: { text: `Curate ${input.genre}`, type: 'text' }, role: 'user' }] };", + " return createElement(Agent.Result, { value: result }, createElement(Agent.Text, null, 'Prompt ready.'));", + '}', + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/curator/apps/dashboard.ts', [ + "export const config = { resourceUri: 'ui://curator/dashboard.html' };", + "document.body.textContent = 'Curator dashboard';", + '', + ].join('\n')), + writeProjectFile(root, 'src/mcp/curator/tools/inspect.tsx', [ + "import { Agent, agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + "export const config = { annotations: { readOnlyHint: true }, description: 'Inspect one source.' };", + "export const inputSchema = z.object({ source: z.string() }).strict();", + "export const resultSchema = z.object({ invocationKind: z.literal('tool'), source: z.string() }).strict();", + 'export default async function Inspect({ input, signal }) {', + " if (signal.aborted) throw new DOMException('aborted', 'AbortError');", + ' const context = await agent();', + ' const result = { invocationKind: context.invocation.kind, source: input.source };', + ' return createElement(Agent.Result, { value: result }, createElement(Agent.Markdown, null, `Inspected **${input.source}**.`));', + '}', + '', + ].join('\n')), + ]); + + const output = join(root, 'artifact'); + const compiled = await build({ output, root, targets: ['portable'] }); + const generatedTypes = await readFile(join(root, '.agent-bundle', 'routes.d.ts'), 'utf8'); + expect(generatedTypes).toContain('tool:curator/inspect'); + expect(generatedTypes).toContain('resource:curator/catalog'); + expect(generatedTypes).toContain('prompt:curator/curate'); + const server = compiled.model.mcpServers[0]; + expect(server).toMatchObject({ id: 'mcp:curator', name: 'curator' }); + const entry = join(output, 'portable', server!.args![0]!); + const client = new Client({ name: 'generated-route-test', version: '0.0.0' }); + const transport = new StdioClientTransport({ args: [entry], 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 }); + } + await expect(client.listTools()).resolves.toMatchObject({ + tools: [{ annotations: { readOnlyHint: true }, description: 'Inspect one source.', name: 'inspect' }], + }); + await expect(client.callTool({ arguments: { source: 'library' }, name: 'inspect' }, { signal: AbortSignal.timeout(10_000) })).resolves.toMatchObject({ + content: [{ text: 'Inspected **library**.', type: 'text' }], + structuredContent: { invocationKind: 'tool', source: 'library' }, + }); + await expect(client.listResources()).resolves.toMatchObject({ resources: [ + expect.objectContaining({ uri: 'catalog://books' }), + expect.objectContaining({ uri: 'ui://curator/dashboard.html' }), + ] }); + await expect(client.readResource({ uri: 'catalog://books' })).resolves.toEqual({ + contents: [{ mimeType: 'application/json', text: '{"books":1}', uri: 'catalog://books' }], + }); + await expect(client.listPrompts()).resolves.toMatchObject({ prompts: [ + expect.objectContaining({ description: 'Curate one genre.', name: 'curate' }), + ] }); + await expect(client.getPrompt({ arguments: { genre: 'mystery' }, name: 'curate' })).resolves.toEqual({ + messages: [{ content: { text: 'Curate mystery', type: 'text' }, role: 'user' }], + }); + } finally { + await client.close(); + } +}); diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index 621c5580b..dedba130e 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -898,3 +898,46 @@ it('keeps shippable conventional script routes free of stage-1 gate diagnostics' expect(diagnostics.filter(({ code }) => code.startsWith('AB48'))).toEqual([]); }); + + +const routeGraphWithGeneratedServer = (root: string): CompiledRouteGraph => { + const route: CompiledAgentRoute = { + config: { annotations: { readOnlyHint: true }, description: 'Inspect sources.' }, + id: 'tool:curator/inspect', + kind: 'tool', + provenance: { kind: 'conventional', relativePath: 'src/mcp/curator/tools/inspect.tsx' }, + serverId: 'mcp:curator', + source: `${root}/src/mcp/curator/tools/inspect.tsx`, + }; + return { + diagnostics: [], + digest: 'generated-server-digest', + events: [], + providers: [], + scripts: [], + servers: [{ id: 'mcp:curator', mode: 'generated', name: 'curator', routes: [route] }], + }; +}; + +it('normalizes generated MCP route servers without a handwritten server declaration', async () => { + const root = '/workspace/project'; + const graph = routeGraphWithGeneratedServer(root); + const model = await normalizeProject( + loadedProject({ plugin: { name: 'review-tools', version: '1.0.0' } }), + { routeGraph: graph, skills: [] }, + registry, + ); + + expect(model.mcpServers).toHaveLength(1); + expect(model.mcpServers[0]).toMatchObject({ + command: 'node', + generatedRoutes: graph.servers[0]!.routes, + id: 'mcp:curator', + name: 'curator', + provenance: { kind: 'conventional', sourcePath: `${root}/src/mcp/curator/tools/inspect.tsx` }, + source: `${root}/src/mcp/curator/tools/inspect.tsx`, + targets: ['portable'], + transport: 'stdio', + }); + expect(model.mcpServers[0]!.args?.[0]).toMatch(/^mcp\/mcp-curator-[a-f\d]{8}\.mjs$/u); +}); diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index a34ae6adc..b11b97f64 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -9,7 +9,8 @@ import { runCli } from '../src/cli.ts'; import { discoverProject } from '../src/config/discover.ts'; import type { AgentBundleConfig } from '../src/core/types.ts'; import { compileRouteGraph, emptyCompiledRouteGraph, isEmptyRouteGraph } from '../src/routes/graph.ts'; -import { emptyRouteConfig } from '../src/routes/types.ts'; +import * as routesModule from '../src/routes/index.ts'; +import { emptyRouteConfig, type CompiledRouteGraph } from '../src/routes/types.ts'; const roots: string[] = []; @@ -23,7 +24,7 @@ const createRoot = async (): Promise => { return root; }; -const moduleSource = 'export default async () => undefined;\n'; +const moduleSource = 'export const inputSchema = {}; export const resultSchema = {}; export default async () => undefined;\n'; const writeTree = async (root: string, files: Readonly>): Promise => { for (const [path, contents] of Object.entries(files)) { @@ -42,7 +43,7 @@ const conventionalTree: Readonly> = { 'src/cli/doctor.tsx': moduleSource, 'src/cli/library/audit.ts': moduleSource, 'src/events/file/saved.tsx': moduleSource, - 'src/mcp/curator/apps/dashboard.tsx': moduleSource, + 'src/mcp/curator/apps/dashboard.tsx': `export const config = { resourceUri: 'ui://curator/dashboard.html' }; ${moduleSource}`, 'src/mcp/curator/prompts/curate.tsx': moduleSource, 'src/mcp/curator/resources/catalog.ts': moduleSource, 'src/mcp/curator/tools/inspect.tsx': moduleSource, @@ -107,7 +108,8 @@ it('compiles the conventional tree into one frozen graph with a machine-independ expect(Object.isFrozen(graph.servers)).toBe(true); expect(Object.isFrozen(curator!.routes[0])).toBe(true); expect(Object.isFrozen(graph.cli!.routes)).toBe(true); - expect(curator!.routes.every((route) => route.config === emptyRouteConfig)).toBe(true); + expect(curator!.routes.filter((route) => route.kind !== 'app').every((route) => route.config === emptyRouteConfig)).toBe(true); + expect(curator!.routes.find((route) => route.kind === 'app')?.config).toEqual({ resourceUri: 'ui://curator/dashboard.html' }); expect(graph.events[0]!.config).toEqual({}); // The digest covers relative identity only: the same tree in a different @@ -434,3 +436,67 @@ it('covers the route config in the graph digest', async () => { expect(left).toBe(sameAsLeft); expect(left).not.toBe(right); }); + + +it('generates deterministic route-specific types from the compiled graph', () => { + const generate = (routesModule as unknown as { + readonly generateRouteTypes?: (graph: CompiledRouteGraph) => string; + }).generateRouteTypes; + expect(typeof generate).toBe('function'); + if (generate === undefined) return; + + const graph: CompiledRouteGraph = { + diagnostics: [], + digest: 'typegen-digest', + events: [], + providers: [], + scripts: [], + servers: [{ + id: 'mcp:curator', + mode: 'generated', + name: 'curator', + routes: [{ + config: emptyRouteConfig, + id: 'tool:curator/inspect', + kind: 'tool', + provenance: { kind: 'conventional', relativePath: 'src/mcp/curator/tools/inspect.tsx' }, + serverId: 'mcp:curator', + source: '/workspace/project/src/mcp/curator/tools/inspect.tsx', + }], + }], + }; + const first = generate(graph); + const second = generate(structuredClone(graph)); + + expect(second).toBe(first); + expect(first).toContain('import type * as route0 from "../src/mcp/curator/tools/inspect.js";'); + expect(first).toContain('"tool:curator/inspect": RouteContract;'); + expect(first).toContain('export type RouteId = keyof AgentBundleRoutes;'); +}); + + +it('validates the single async route-module authoring contract statically', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/mcp/curator/tools/valid.tsx': [ + 'export const inputSchema = {};', + 'export const resultSchema = {};', + 'export default async function Valid() { return undefined; }', + '', + ].join('\n'), + 'src/mcp/curator/tools/split.tsx': [ + 'export const resultSchema = {};', + 'export const execute = async () => ({});', + 'export const render = () => undefined;', + 'export default function Split() { return undefined; }', + '', + ].join('\n'), + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + expect(graph.diagnostics.filter((diagnostic) => diagnostic.sourcePath?.endsWith('valid.tsx'))).toEqual([]); + expect(graph.diagnostics.filter((diagnostic) => diagnostic.sourcePath?.endsWith('split.tsx')).map(({ code }) => code)).toEqual([ + 'AB4810', + 'AB4811', + ]); +}); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 11e96cea6..8d3d7d0eb 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -28,6 +28,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/eval-service.test.ts', 'packages/agent-bundle/tests/eval-workbench.test.ts', 'packages/agent-bundle/tests/examples-contract.test.ts', + 'packages/agent-bundle/tests/generated-route-server.test.ts', 'packages/agent-bundle/tests/hook-playground-service.test.ts', 'packages/agent-bundle/tests/hooks.test.ts', 'packages/agent-bundle/tests/host-adapters.native.test.ts',