Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/public-route-compiler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": minor
---

Compile generated MCP route servers through a warm final-only Flight dispatcher and emit deterministic route types.
9 changes: 8 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<server>/{tools,resources,prompts,apps}/*`, `src/events/*/*`,
Expand All @@ -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
Expand All @@ -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`)

Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
12 changes: 9 additions & 3 deletions packages/agent-bundle/src/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -358,6 +362,7 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
apps: targetMcpApps,
cwd: options.projectRoot,
outDir: target.root,
plugin: { name: options.model.metadata.name, version: options.model.metadata.version },
target: target.name,
...tools,
})));
Expand Down Expand Up @@ -437,6 +442,7 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
compiledMcpEntries: Object.freeze(compiledMcpEntries.map((entry) => Object.freeze({
...entry,
output: publishedOutput(entry),
...(entry.workerOutput === undefined ? {} : { workerOutput: publishedOutput({ output: entry.workerOutput }) }),
}))),
manifest,
outputProvenance,
Expand Down
111 changes: 86 additions & 25 deletions packages/agent-bundle/src/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { emitPlanEntries, resolveArtifactDestination } from './emit.ts';
import { scanEntryExports } from './entry-exports.ts';
import {
generatedExecutableEntrySource,
generatedRouteFlightWorkerSource,
generatedRouteMcpEntrySource,
generatedStdioMcpEntrySource,
mcpEntryRuntimePath,
mcpEntryRuntimeSpecifier,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
}),
});
}));
};
Expand All @@ -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;
},
Expand All @@ -185,49 +199,96 @@ 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 }),
});
const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs]));
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)}.`); })(),
}),
})));
};

Expand Down
Loading
Loading