Skip to content

Commit ed06db0

Browse files
feat(routes): compile generated MCP servers through Flight (#147)
Publish the single async route contract with deterministic typegen and host each generated server on a reusable final-only Flight dispatcher.
1 parent 33f8651 commit ed06db0

25 files changed

Lines changed: 1050 additions & 55 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": minor
3+
---
4+
5+
Compile generated MCP route servers through a warm final-only Flight dispatcher and emit deterministic route types.

‎docs/diagnostics.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ simply not been built yet is a validation **warning** that only
118118
| `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. |
119119
| `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. |
120120

121-
## Route graph (`AB4800`–`AB4809`)
121+
## Route graph (`AB4800`–`AB4812`)
122122

123123
The route-graph compiler discovers conventional route modules
124124
(`src/mcp/<server>/{tools,resources,prompts,apps}/*`, `src/events/*/*`,
@@ -141,6 +141,10 @@ optionally wrapped in unary `+`/`-`; `true`, `false`, and `null`; and
141141
accepted form. Anything else is dynamic: the route compiles with an empty
142142
config beside a named `AB4806` error. A module without a `config` export
143143
compiles silently with an empty config.
144+
Generated route declarations are published at `.agent-bundle/routes.d.ts` from
145+
the same graph. Development writes a sibling temporary file and renames it over
146+
the prior complete declaration atomically; invalid source retains the prior
147+
last-good file, while a successful route-free preparation removes it.
144148

145149
Conventional `src/scripts/` routes ship through the same pipeline as
146150
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.
160164
| `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. |
161165
| `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. |
162166
| `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. |
167+
| `AB4810` | error | A generated MCP route is missing named `inputSchema`/`resultSchema` exports or its default export is not an async function component. |
168+
| `AB4811` | error | A generated MCP route exports `execute` or `render`; route mode accepts only the async default Server Component contract. |
169+
| `AB4812` | error | A generated MCP App route has no non-empty static `config.resourceUri`. |
163170

164171
## Development package build (`AB7103`)
165172

‎packages/agent-bundle/src/api.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { emptyCompiledRouteGraph } from './routes/graph.ts';
1212
import { inspectRouteGraph, type RouteGraphInspection } from './routes/inspect.ts';
1313
import { mcpServerStateDirectory, runMcpForeground } from './services/mcp-run.ts';
1414
export { compileRouteGraph, emptyCompiledRouteGraph, isEmptyRouteGraph } from './routes/graph.ts';
15+
export type { AppRouteConfig, PromptConfig, ResourceConfig, RouteSchema, RouteSchemaOutput, ToolConfig, ToolRouteProps } from './routes/public.ts';
1516
export { inspectRouteGraph } from './routes/inspect.ts';
1617
export type { RouteGraphInspection } from './routes/inspect.ts';
1718
export { emptyRouteConfig } from './routes/types.ts';

‎packages/agent-bundle/src/build/build.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ const plannedDestinations = (targets: readonly StagedTarget[]): readonly string[
193193
...target.compiledEntries.map((entry) => entry.output),
194194
...target.compiledHooks.map((entry) => entry.output),
195195
...target.compiledMcpApps.map((entry) => entry.output),
196-
...target.compiledMcpEntries.map((entry) => entry.output),
196+
...target.compiledMcpEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]),
197197
]);
198198

199199
const hookIndexSourceInputs = (
@@ -238,11 +238,15 @@ const outputCandidatesFor = (options: {
238238
path: entry.output,
239239
sourceInputs: entry.sourceInputs,
240240
})),
241-
...options.compiledMcpEntries.map((entry) => ({
241+
...options.compiledMcpEntries.flatMap((entry) => [{
242242
kind: 'bundle' as const,
243243
path: entry.output,
244244
sourceInputs: entry.sourceInputs,
245-
})),
245+
}, ...(entry.workerOutput === undefined ? [] : [{
246+
kind: 'bundle' as const,
247+
path: entry.workerOutput,
248+
sourceInputs: entry.workerSourceInputs ?? entry.sourceInputs,
249+
}])]),
246250
{
247251
kind: 'generated' as const,
248252
path: resolveArtifactDestination(options.artifactRoot, artifactHookIndexName),
@@ -358,6 +362,7 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
358362
apps: targetMcpApps,
359363
cwd: options.projectRoot,
360364
outDir: target.root,
365+
plugin: { name: options.model.metadata.name, version: options.model.metadata.version },
361366
target: target.name,
362367
...tools,
363368
})));
@@ -437,6 +442,7 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
437442
compiledMcpEntries: Object.freeze(compiledMcpEntries.map((entry) => Object.freeze({
438443
...entry,
439444
output: publishedOutput(entry),
445+
...(entry.workerOutput === undefined ? {} : { workerOutput: publishedOutput({ output: entry.workerOutput }) }),
440446
}))),
441447
manifest,
442448
outputProvenance,

‎packages/agent-bundle/src/build/entries.ts‎

Lines changed: 86 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { emitPlanEntries, resolveArtifactDestination } from './emit.ts';
99
import { scanEntryExports } from './entry-exports.ts';
1010
import {
1111
generatedExecutableEntrySource,
12+
generatedRouteFlightWorkerSource,
13+
generatedRouteMcpEntrySource,
1214
generatedStdioMcpEntrySource,
1315
mcpEntryRuntimePath,
1416
mcpEntryRuntimeSpecifier,
@@ -41,6 +43,8 @@ export interface CompiledHookEntry extends CompiledEntry {
4143

4244
export interface CompiledMcpEntry extends CompiledEntry {
4345
readonly id: string;
46+
readonly workerOutput?: string;
47+
readonly workerSourceInputs?: readonly string[];
4448
readonly target: string;
4549
}
4650

@@ -145,14 +149,23 @@ export const planCompiledMcpEntries = (
145149
throw new Error(`Duplicate compiled MCP destination ${JSON.stringify(`mcp/${outputName}`)}.`);
146150
}
147151
names.add(name);
152+
const sourceInputs = Object.freeze([...new Set([
153+
server.provenance.sourcePath,
154+
server.source!,
155+
...(server.generatedRoutes ?? []).map((route) => route.source),
156+
])]);
148157
return Object.freeze({
149158
id: server.id,
150159
name,
151160
output: resolveArtifactDestination(resolve(options.outDir, 'mcp'), outputName),
152161
outputKind: 'bundle',
153162
source: server.source!,
154-
sourceInputs: Object.freeze([server.provenance.sourcePath, server.source!]),
163+
sourceInputs,
155164
target: options.target,
165+
...(server.generatedRoutes === undefined ? {} : {
166+
workerOutput: resolveArtifactDestination(resolve(options.outDir, 'mcp'), `${name}-flight.mjs`),
167+
workerSourceInputs: sourceInputs,
168+
}),
156169
});
157170
}));
158171
};
@@ -163,6 +176,7 @@ export const compileMcpEntries = async (
163176
readonly apps?: readonly CompiledMcpApp[];
164177
readonly cwd: string;
165178
readonly outDir: string;
179+
readonly plugin: { readonly name: string; readonly version: string };
166180
readonly target: string;
167181
readonly tools?: AgentBundleToolsConfig;
168182
},
@@ -185,49 +199,96 @@ export const compileMcpEntries = async (
185199
'',
186200
].join('\n');
187201
}));
202+
const routeModuleSpecifier = 'agent-bundle/generated-route-server';
203+
const generatedRouteSources = compiled.map((entry) => {
204+
const server = servers.find((candidate) => candidate.id === entry.id);
205+
return server?.generatedRoutes === undefined
206+
? undefined
207+
: generatedRouteMcpEntrySource({
208+
plugin: options.plugin,
209+
routes: server.generatedRoutes,
210+
serverName: server.name,
211+
workerFile: `${entry.name}-flight.mjs`,
212+
});
213+
});
214+
const generatedWorkerSources = compiled.map((entry) => {
215+
const server = servers.find((candidate) => candidate.id === entry.id);
216+
return server?.generatedRoutes === undefined
217+
? undefined
218+
: generatedRouteFlightWorkerSource({ routes: server.generatedRoutes, serverName: server.name });
219+
});
188220
// Factory-exporting entries (default export) are wrapped in the framework
189221
// stdio lifecycle shell; self-connecting entries keep today's behavior byte
190222
// for byte. The shell is aliased onto the local runtime module so emitted
191223
// bundles stay self-contained (no residual `agent-bundle` import).
192-
const entryShells = await Promise.all(compiled.map(async (entry) =>
193-
(await scanEntryExports(entry.source)).hasDefaultExport
224+
const entryShells = await Promise.all(compiled.map(async (entry, index) => {
225+
if (generatedRouteSources[index] !== undefined) {
226+
return generatedStdioMcpEntrySource({
227+
entrySource: routeModuleSpecifier,
228+
serverName: entry.id.startsWith('mcp:') ? entry.id.slice('mcp:'.length) : entry.name,
229+
});
230+
}
231+
return (await scanEntryExports(entry.source)).hasDefaultExport
194232
? generatedStdioMcpEntrySource({
195233
entrySource: entry.source,
196234
serverName: entry.id.startsWith('mcp:') ? entry.id.slice('mcp:'.length) : entry.name,
197235
})
198-
: undefined));
236+
: undefined;
237+
}));
199238
const runtimeShell = entryShells.some((shell) => shell !== undefined) ? mcpEntryRuntimePath() : undefined;
239+
const mainEntries = compiled.map(({ id, name, source, sourceInputs }, index) => ({
240+
...(entryShells[index] === undefined || runtimeShell === undefined
241+
? {}
242+
: {
243+
aliases: { [mcpEntryRuntimeSpecifier]: runtimeShell },
244+
virtualSource: entryShells[index],
245+
}),
246+
name,
247+
outputRelativePath: `mcp/${name}.mjs`,
248+
...(generatedRouteSources[index] === undefined ? {} : { rscManifest: true as const }),
249+
source,
250+
sourceInputs: Object.freeze([
251+
...sourceInputs,
252+
...(options.apps ?? [])
253+
.filter((app) => app.serverIds.includes(id))
254+
.flatMap((app) => app.sourceInputs),
255+
]),
256+
virtualModules: [
257+
{ name: 'agent-bundle/mcp-apps', source: virtualSources[index]! },
258+
...(generatedRouteSources[index] === undefined ? [] : [{
259+
name: routeModuleSpecifier,
260+
source: generatedRouteSources[index],
261+
}]),
262+
],
263+
}));
264+
const workerEntries = compiled.flatMap((entry, index) => {
265+
const workerSource = generatedWorkerSources[index];
266+
if (workerSource === undefined) return [];
267+
return [{
268+
name: `${entry.name}-flight`,
269+
outputRelativePath: `mcp/${entry.name}-flight.mjs`,
270+
reactServer: true as const,
271+
rscManifest: true as const,
272+
source: entry.source,
273+
sourceInputs: entry.sourceInputs,
274+
virtualSource: workerSource,
275+
}];
276+
});
200277
const evidence = await buildWithRslib({
201278
cwd: options.cwd,
202-
entries: compiled.map(({ id, name, source, sourceInputs }, index) => ({
203-
...(entryShells[index] === undefined || runtimeShell === undefined
204-
? {}
205-
: {
206-
aliases: { [mcpEntryRuntimeSpecifier]: runtimeShell },
207-
virtualSource: entryShells[index],
208-
}),
209-
name,
210-
outputRelativePath: `mcp/${name}.mjs`,
211-
source,
212-
sourceInputs: Object.freeze([
213-
...sourceInputs,
214-
...(options.apps ?? [])
215-
.filter((app) => app.serverIds.includes(id))
216-
.flatMap((app) => app.sourceInputs),
217-
]),
218-
virtualModules: [{
219-
name: 'agent-bundle/mcp-apps',
220-
source: virtualSources[index]!,
221-
}],
222-
})),
279+
entries: [...mainEntries, ...workerEntries],
223280
...(runtimeShell === undefined ? {} : { ignoredSourcePaths: [runtimeShell] }),
281+
logLevel: 'error',
224282
outputRoot: options.outDir,
225283
...(options.tools === undefined ? {} : { tools: options.tools }),
226284
});
227285
const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs]));
228286
return Object.freeze(compiled.map((entry) => Object.freeze({
229287
...entry,
230288
sourceInputs: evidenceByPath.get(`mcp/${entry.name}.mjs`) ?? (() => { throw new Error(`Missing bundled MCP evidence for ${JSON.stringify(entry.name)}.`); })(),
289+
...(entry.workerOutput === undefined ? {} : {
290+
workerSourceInputs: evidenceByPath.get(`mcp/${entry.name}-flight.mjs`) ?? (() => { throw new Error(`Missing bundled MCP Flight worker evidence for ${JSON.stringify(entry.name)}.`); })(),
291+
}),
231292
})));
232293
};
233294

0 commit comments

Comments
 (0)