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/558-serve-app-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Add `agent-bundle/serve-app-command`, a dependency-free entry a routed CLI command (or any other generated executable) imports to serve a built MCP App without importing the compiler: `spawnServeApp(options)` lowers the `serveApp` options to `agent-bundle serve-app` argv (`serveAppArgv`), resolves the framework CLI installed at or above the project root (`locateFrameworkCli`), spawns it with its stdout relayed to stderr so the route keeps stdout for its JSON result, resolves with `{ url, port, tool, server, pid, closed, close() }` once the CLI prints its ready line (`parseServeAppReadyLine`), tears the server down when the route's `signal` aborts, and rejects with `ServeAppCommandError` (`framework-not-installed`, `artifact-missing`, `spawn-failed`, `exited-before-ready`, `aborted`, `stop-failed`). Report the new `AB4837` diagnostic from `inspect`, `validate`, `build`, and `dev` when a route module, layout, or provider — or a module it reaches through relative imports — value-imports a compiler-carrying framework entry (`agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, `agent-bundle/test/browser`), naming the file, the specifier, and the helper, instead of failing inside the bundler with `Can't resolve '../events'`; `import type` and type-only usage are not reported (#582)
5 changes: 3 additions & 2 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ even when no error diagnostic was reported.
| `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). |
| `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/<name>.mjs`; a host-emitted file collides with it (see below). |
| `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. |
| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), and provider conventions (see below). |
| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), and provider conventions (see below). |
| `AB5000` | General CLI and adapter failures. |
| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). |
| `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). |
Expand Down Expand Up @@ -606,7 +606,7 @@ framework-owned plugin twice by accident.
| `AB4723` | error | `tools.rspack` is not an Rspack config object, a mutator function, or an array of both. | Use one of the three Rslib `tools.rspack` forms. |
| `AB4724` | error | `tools.rsbuild.plugins` supplies a plugin whose `name` matches a framework-owned registration (`rsbuild:react` from `@rsbuild/plugin-react`). The message names the plugin and its package. | Remove the plugin from `tools.rsbuild.plugins`; agent-bundle registers it in every config it synthesizes. |

## Route graph, state, layout, and provider conventions (`AB4800`–`AB4836`, `AB4940`–`AB4942`)
## Route graph, state, layout, and provider conventions (`AB4800`–`AB4837`, `AB4940`–`AB4942`)

The route-graph compiler discovers conventional route modules
(`src/mcp/<server>/{tools,resources,prompts,apps}/*`, `src/events/*/*`,
Expand Down Expand Up @@ -821,6 +821,7 @@ schema constants), unions, nested objects, transforms, coercions — raises
| `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but the root `tsconfig.json` program — resolved like `tsc -p`, including `extends` and one level of project `references` — does not compile it, so `renderRoute` / `renderRouteEvents` type-check route ids as `string` and `input` / `result` as `unknown`. Reported on `tsconfig.json`; never for a project without one. | Add `".agent-bundle/routes.d.ts"` to `tsconfig.json` `include` (not `files`: an `include` entry is inert until the first build publishes the file, while a missing `files` entry is a `tsc` error); `build`, `dev`, and `validate` keep the file current and it stays gitignored. |
| `AB4835` | error | A route's static `config.render` (the render budget of one call, #454) is malformed: `render` is not an object, carries a key other than `maxElapsedMs`, `maxElapsedMs` is not a positive integer of milliseconds, or it exceeds the framework ceiling of `86400000` (24 hours) — or a plain `.ts` CLI command declares one, although it executes without a render session. Reported once per route: on an MCP tool, resource, or prompt route with its server (the tool's projected CLI command inherits the value), or on a `src/cli/**` command route; a route with a rejected budget compiles no command. Omit `render` to keep the runtime default (`60000`). Declare `config.render = { maxElapsedMs: <positive integer ≤ 86400000> }` on a rendered route, or remove it. The budget bounds the framework's render session only: Codex's `tool_timeout_sec` (60 s by default) and any per-server host timeout must be raised by the operator separately, while Claude Code's default per-call wall clock is about 28 hours and its idle timer is kept alive by the `notifications/progress` the projector forwards. |
| `AB4836` | error | A route's static `config.execution` (MCP task support, #369) is malformed: `execution` is not an object, carries a key other than `taskSupport`, or `taskSupport` is not one of `forbidden`, `optional`, `required` — or a resource or prompt route declares it, although the `2025-11-25` Tasks utility augments `tools/call` only. Reported once per route with its server. Omit `execution` to keep the wire default (`forbidden`: every call is an ordinary request), or declare `config.execution = { taskSupport: 'optional' }` so a task-aware client may receive a `CreateTaskResult` and poll `tasks/get` / `tasks/result` while the render continues, or `'required'` to refuse ordinary calls with JSON-RPC `-32601`. The generated server advertises the value in `tools/list` and declares the `tasks` capability only when at least one tool opted in. |
| `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/serve-app-command`. |
| `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, plugin, signal }`. |
| `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. |
| `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. |
Expand Down
44 changes: 32 additions & 12 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1477,17 +1477,37 @@ closed }`). It is a host-process API: it belongs to processes the framework
does not compile — the first-party CLI, the Workbench, tests, a plugin's own
`package.json` scripts or a hand-written `.mjs` run from the checkout — and
never to the MCP server shell. A routed CLI command inside the artifact
cannot import it today: routed CLI bins are self-contained (#387), so the
bundler inlines `agent-bundle/dist/api.js` into the bin and fails on the
cannot import it: routed CLI bins are self-contained (#387), so the bundler
would inline `agent-bundle/dist/api.js` into the bin and fail on the
framework's runtime-relative module references (`Module not found: Can't
resolve '../events'`), while an external bare import (`AB6005 uses
resolve '../events'`). The route graph reports such an import first, as
`AB4837` naming the module and the specifier
(`src/routes/framework-imports.ts`; the compiler-carrying entries are
`agent-bundle`, `agent-bundle/api`, `agent-bundle/config`,
`agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, and
`agent-bundle/test/browser`, matched exactly; `import type` and type-only
usage are not reported), while an external bare import (`AB6005 uses
unsupported specifier`) or a non-literal `import(spec)` (`AB6005 has a
non-literal dynamic import`) fails artifact validation. The pattern that
builds is a plain routed command that spawns `agent-bundle serve-app` as a
child process — resolving the framework CLI from `node_modules/agent-bundle`
by path, relaying the child's `MCP App <app> at <url>` line to stderr so the
routed CLI keeps stdout for its result, and turning the route `signal` into
the child's `SIGTERM` — which makes it a checkout-only command (an installed
host pack has neither `node_modules` nor the artifact). A framework helper
for that plumbing is tracked in #558; the worked example is in the MCP Apps
guide, "Serving an App standalone".
non-literal dynamic import`) still fails artifact validation. The sanctioned
shape is `spawnServeApp` from `agent-bundle/serve-app-command`
(`src/serve-app-command.ts`, #558) — plain Node with no dependencies, so the
bundler inlines it into the self-contained executable the way it inlines
`agent-bundle/launch-env`. It lowers the `serveApp` options to `serve-app`
argv (`serveAppArgv`; every `ServeAppOptions` key except the host-process-only
`logger`, `registry`, `openBrowser`, `targets`, and `timeoutMs`), resolves the
framework CLI from the `agent-bundle` package installed at or above `root`
(`locateFrameworkCli`, through `src/core/dependency-manifest.ts`), spawns it
with the child's stdout piped and its stderr inherited, relays every stdout
line to stderr so the routed CLI keeps stdout for its result, resolves once
the child prints the ready line `MCP App <app> at <url> (tool <tool>; Ctrl-C
stops the server)` — the CLI writes it and the helper parses it through one
module, `src/serve-app/command-contract.ts` — and turns the route `signal`
into the child's `SIGTERM`. The result is `{ app, url, tool, server, port,
pid, closed, close() }`; failures are `ServeAppCommandError` with `code`
`framework-not-installed`, `artifact-missing`, `spawn-failed`,
`exited-before-ready` (carrying the child's `exit`), `aborted`, or
`stop-failed` (the running child refused the signal `close()` or the abort
sent; it is still running). It is a
checkout command: an installed host pack has neither `node_modules/agent-bundle`
nor the artifact, and the first two codes say so before anything is spawned.
The worked example is in the MCP Apps guide, "Serving an App standalone".
8 changes: 5 additions & 3 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,11 @@ own packed server, launched as `mcp run` launches it. `serveApp` in
`agent-bundle/api` is the programmatic form for host processes — the CLI,
the Workbench, tests, a plugin's own scripts — never the MCP shell, and a
local preview host, not a deployment target. A plugin's own "open the
dashboard" CLI route cannot import it (the routed CLI bin is self-contained;
`AB6005`) and spawns `agent-bundle serve-app` instead; see
[Entry conventions](entry-conventions.md#agent-bundle-serve-app) and #558.
dashboard" CLI route cannot import it — the routed CLI bin is self-contained,
and the route graph reports the value import as `AB4837` — so it calls
`spawnServeApp` from `agent-bundle/serve-app-command`, which spawns
`agent-bundle serve-app` as a child process (#558); see
[Entry conventions](entry-conventions.md#agent-bundle-serve-app).

The compiler statically reads `config`, imports schemas and implementations
only into generated entries, installs `runAgentRequest`, and derives the real
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// The `agent-bundle/serve-app-command` packed proof (#558): one MCP server
// with one App, and a routed CLI command (`src/cli/dashboard.ts`) that serves
// the App by spawning `agent-bundle serve-app` from inside the generated bin.
// Plain object export, like every other repository fixture: the fixture must
// compile without the package's own built configuration entry.
export default {
mcp: {
servers: {
status: {
apps: {
status: {
entry: './views/status.ts',
resourceUri: 'ui://serve-app-command-fixture/status.html',
targets: ['portable'],
template: './views/status.html',
},
},
},
},
},
plugin: {
description: 'A routed CLI command that serves this plugin\'s MCP App through agent-bundle/serve-app-command.',
name: 'serve-app-command-fixture',
version: '1.0.0',
},
targets: ['portable'],
};
8 changes: 8 additions & 0 deletions packages/agent-bundle/fixtures/serve-app-command/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "serve-app-command-fixture",
"private": true,
"type": "module",
"devDependencies": {
"@modelcontextprotocol/server": "2.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { CliRouteConfig, CliRouteProps } from 'agent-bundle';
import { ServeAppCommandError, spawnServeApp } from 'agent-bundle/serve-app-command';
import { z } from 'zod';

export const config = {
description: 'Open the status App in a browser, served from this checkout\'s built artifact.',
exitCode: 'result',
} satisfies CliRouteConfig;

export const inputSchema = z.object({
noOpen: z.boolean().optional(),
port: z.number().int().min(0).max(65_535).optional(),
/** Test seam: fetch the served page once, then stop the server and report. */
probe: z.boolean().optional(),
}).strict();

export const resultSchema = z.object({
exitCode: z.number().int(),
message: z.string(),
pid: z.number().int().nullable(),
probeStatus: z.number().int().nullable(),
url: z.string().nullable(),
}).strict();

export default async function dashboard({ input, signal }: CliRouteProps<typeof inputSchema>) {
let served;
try {
served = await spawnServeApp({
app: 'status/status',
root: process.cwd(),
artifact: 'artifact',
tool: 'status',
autoApprove: ['call-tool'],
open: input.noOpen !== true,
...(input.port === undefined ? {} : { port: input.port }),
signal,
});
} catch (error) {
if (error instanceof ServeAppCommandError) {
return { exitCode: 1, message: `${error.code}: ${error.message}`, pid: null, probeStatus: null, url: null };
}
throw error;
}
let probeStatus: number | null = null;
if (input.probe === true) {
probeStatus = (await fetch(served.url)).status;
await served.close();
}
const exit = await served.closed;
return {
exitCode: exit.code ?? 1,
message: exit.code === 0 ? 'dashboard closed' : `agent-bundle serve-app exited with ${exit.signal ?? exit.code}`,
pid: served.pid,
probeStatus,
url: served.url,
};
}
34 changes: 34 additions & 0 deletions packages/agent-bundle/fixtures/serve-app-command/src/mcp/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { McpServer } from '@modelcontextprotocol/server';
import apps from 'agent-bundle/mcp-apps';
import { name, version } from 'agent-bundle/meta';

const app = apps[0];
if (app === undefined) throw new Error('Expected the status MCP App.');

/**
* Default-exported server factory: `agent-bundle build` wraps it in the
* framework stdio lifecycle shell. One App resource and one tool that opens
* it — the pair `agent-bundle serve-app status/status --tool status` binds.
* The tool takes no input, so the opening call `serve-app` makes needs none.
*/
export default function createStatusServer(): McpServer {
const server = new McpServer({ name, version });

server.registerResource(app.name, app.resourceUri, {
_meta: { ui: { resourceUri: app.resourceUri } },
mimeType: app.mimeType,
}, async (uri) => ({
contents: [{ mimeType: app.mimeType, text: app.html, uri: uri.href }],
}));

server.registerTool('status', {
_meta: { ui: { resourceUri: app.resourceUri } },
description: 'Reports the fixture status and opens the status App.',
}, async () => ({
_meta: { ui: { resourceUri: app.resourceUri } },
content: [{ text: 'status: healthy', type: 'text' }],
structuredContent: { status: 'healthy' },
}));

return server;
}
10 changes: 10 additions & 0 deletions packages/agent-bundle/fixtures/serve-app-command/views/status.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Status</title>
</head>
<body>
<main id="view"></main>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
document.querySelector('#view')!.textContent = 'serve-app-command fixture status';
4 changes: 4 additions & 0 deletions packages/agent-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@
"types": "./dist/rstest/index.d.ts",
"import": "./dist/rstest.js"
},
"./serve-app-command": {
"types": "./dist/serve-app-command.d.ts",
"import": "./dist/serve-app-command.js"
},
"./test": {
"types": "./dist/test/index.d.ts",
"import": "./dist/test.js"
Expand Down
Loading
Loading