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
2 changes: 1 addition & 1 deletion .changeset/514-serve-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"agent-bundle": patch
---

Add `agent-bundle serve-app <server>/<app>` and `serveApp` in `agent-bundle/api`: serve one built MCP App standalone in a browser, bound to the plugin's own packed MCP server. The server launches exactly as `mcp run` does (same artifact resolution, `.env` layering, and plugin-data root), the App is hosted through the Workbench's MCP App host stack (sandbox proxy, consent authority, bridge) on `127.0.0.1` behind a per-launch token (`AB8003` / `AB8004` on refusal), and the App's tool is called once so it opens populated. `--tool`, `--input`, `--port`, `--profile`, `--allow <capability>`, `--open`, and the `mcp run` environment flags select the binding; `serveApp` returns `{ url, close, closed }` so a plugin's own CLI route can offer an "open the dashboard" command. Fixes #514. (#537)
Add `agent-bundle serve-app <server>/<app>` and `serveApp` in `agent-bundle/api`: serve one built MCP App standalone in a browser, bound to the plugin's own packed MCP server. The server launches exactly as `mcp run` does (same artifact resolution, `.env` layering, and plugin-data root), the App is hosted through the Workbench's MCP App host stack (sandbox proxy, consent authority, bridge) on `127.0.0.1` behind a per-launch token (`AB8003` / `AB8004` on refusal), and the App's tool is called once so it opens populated. `--tool`, `--input`, `--port`, `--profile`, `--allow <capability>`, `--open`, and the `mcp run` environment flags select the binding; `serveApp` returns `{ url, close, closed }` for scripts and tests — a plugin's own "open the dashboard" CLI route spawns `agent-bundle serve-app` instead, since the self-contained routed CLI bin cannot import `agent-bundle/api` (`AB6005`; #558). Fixes #514. (#537)
22 changes: 18 additions & 4 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1414,7 +1414,21 @@ and the bridge exposes only the selected server. This is a local preview
host, not a deployment target.

`serveApp` in `agent-bundle/api` is the programmatic form (`{ url, close,
closed }`) for a plugin's own routed CLI (`hauler dashboard`). It belongs to
the plugin's dev-time / CLI process — import it lazily from the route that
needs it — never to the MCP server shell, so emitted artifacts stay free of
the host runtime.
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
framework's runtime-relative module references (`Module not found: Can't
resolve '../events'`), 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".
10 changes: 6 additions & 4 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,12 @@ A built App is previewed in the Workbench MCP page, or served standalone in a
plain browser tab with `agent-bundle serve-app <server>/<app>` — the same
host stack (sandbox proxy, consent authority, bridge) bound to the plugin's
own packed server, launched as `mcp run` launches it. `serveApp` in
`agent-bundle/api` is the programmatic form for a plugin's own "open the
dashboard" CLI route; it runs in the plugin's dev-time / CLI process, never
in the MCP shell, and is a local preview host, not a deployment target. See
[Entry conventions](entry-conventions.md#agent-bundle-serve-app).
`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.

The compiler statically reads `config`, imports schemas and implementations
only into generated entries, installs `runAgentRequest`, and derives the real
Expand Down
9 changes: 6 additions & 3 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1498,9 +1498,12 @@ const scopedThrowawayArtifact = (
* `close()` to tear down the host and the server; `closed` settles when the
* server connection ends for any reason.
*
* This runs in a dev-time or CLI process — a plugin's own routed CLI can
* call it from a `hauler dashboard`-style route — never inside the MCP
* server shell.
* This is a host-process API: it runs in processes the framework does not
* compile — the first-party CLI, the Workbench, tests, a plugin's own
* scripts — never inside the MCP server shell, and not from a routed CLI
* command inside the artifact, whose self-contained bin cannot import
* `agent-bundle/api` (`AB6005`); such a route spawns `agent-bundle
* serve-app` instead (issue #558).
*/
export const serveApp = async (options: ServeAppOptions): Promise<ServedMcpApp> => {
const registry = registryFor(options);
Expand Down
139 changes: 105 additions & 34 deletions website/docs/en/guide/authoring/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -702,55 +702,126 @@ exposes only that server through the bridge; the App document runs on a second l
the framework's sandbox. It is a local preview host, not a deployment target. Every option is in
the [CLI reference](../../reference/cli.mdx#serve-app).

The same host is available programmatically as `serveApp` in `agent-bundle/api`, so a plugin's
own routed CLI can offer the command. It returns `{ url, close, closed }`: `close()` tears down the
host and the server, `closed` settles when the server connection ends for any reason.
The same host is available programmatically as `serveApp` in `agent-bundle/api`. It returns
`{ url, close, closed }`: `close()` tears down the host and the server, `closed` settles when the
server connection ends for any reason — always `close()` when you are done, because `closed`
settling means the server connection ended, not that the host was torn down. `autoApprove` grants
the listed consent capabilities on the operator's behalf as the App requests them (`call-tool`
lets a polling dashboard refresh without a prompt); anything else waits for an Allow/Deny decision
in the host page, as in the Workbench.

`serveApp` 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 it needs `agent-bundle` resolvable where that process runs.
Never call it from the MCP server shell.

A routed CLI command inside the plugin artifact cannot import it today. Routed CLI bins are
self-contained (`bin/<plugin>.mjs` in every host pack, `dist/bin/<plugin>.js` in the package
build), so a route with `await import('agent-bundle/api')` makes the bundler inline the whole
compiler into the bin, where it fails on the framework's runtime-relative module references
(`Module not found: Can't resolve '../events'`); leaving the import external is
`AB6005 … uses unsupported specifier "agent-bundle/api"`, and a non-literal `import(spec)` is
`AB6005 … has a non-literal dynamic import`. A helper a routed command can use is tracked in
[#558](https://github.com/ScriptedAlchemy/agent-bundle/issues/558).

The pattern that builds is a plain routed command that spawns `agent-bundle serve-app` as a child
process, as cargo-hauler's `hauler dashboard` does. It is a **checkout command**: it needs
`agent-bundle` under `node_modules` and the built `artifact/` beside the CLI, neither of which an
installed host pack has, so it says so instead of failing inside the child.

```ts
// src/cli/dashboard.ts — `hauler dashboard`: open the App against the running daemon.
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import type { CliRouteConfig, CliRouteProps } from 'agent-bundle';
import { z } from 'zod';

export const config = {
description: 'Open the cargo-hauler dashboard in a browser.',
description: 'Open the cargo-hauler dashboard in a browser (from the plugin checkout).',
exitCode: 'result',
} satisfies CliRouteConfig;

export const inputSchema = z.object({ open: z.boolean().default(true) }).strict();

export const resultSchema = z.object({ url: z.string() }).strict();
export const inputSchema = z.object({ noOpen: z.boolean().optional() }).strict();

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

// The framework CLI, read from the `bin` of the nearest `node_modules/agent-bundle` at or
// above the plugin root. Located by path, never imported: an `import()` of the package
// would pull the framework into the bin.
const frameworkCli = (root: string): string | undefined => {
for (let directory = root; ; directory = dirname(directory)) {
const manifestPath = join(directory, 'node_modules', 'agent-bundle', 'package.json');
if (existsSync(manifestPath)) {
const { bin } = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
bin?: string | Record<string, string>;
};
const relative = typeof bin === 'string' ? bin : bin?.['agent-bundle'];
if (relative === undefined) return undefined;
return resolve(dirname(manifestPath), relative);
}
if (directory === dirname(directory)) return undefined;
}
};

export default async function dashboard({ input, signal }: CliRouteProps<typeof inputSchema>) {
const { serveApp } = await import('agent-bundle/api');
const served = await serveApp({
app: 'hauler/dashboard',
artifact: new URL('../../artifact', import.meta.url).pathname,
autoApprove: ['call-tool'],
open: input.open,
root: process.cwd(),
target: 'cursor',
tool: 'hauler_status',
});
signal.addEventListener('abort', () => { void served.close(); }, { once: true });
try {
// Settles on Ctrl-C (through the signal) or when the daemon exits on its own.
await served.closed;
} finally {
// `closed` tracks only the server connection; close() also releases the
// host, the sandbox proxy, and any throwaway artifact.
await served.close();
// `dist/bin/<plugin>.js` sits two levels under the checkout, which holds `artifact/`.
const root = fileURLToPath(new URL('../../', import.meta.url));
const cli = frameworkCli(root);
const artifact = join(root, 'artifact');
if (cli === undefined || !existsSync(join(artifact, 'agent-bundle.manifest.json'))) {
return {
exitCode: 1,
message: 'hauler dashboard runs from the plugin checkout (pnpm install, then '
+ 'agent-bundle build); in an MCP host, call hauler_status instead.',
url: null,
};
}
return { url: served.url };
return new Promise<z.infer<typeof resultSchema>>((done, fail) => {
const child = spawn(process.execPath, [
cli, 'serve-app', 'hauler/dashboard', '--root', root,
'--artifact', artifact, '--target', 'portable',
'--tool', 'hauler_status', '--allow', 'call-tool',
input.noOpen === true ? '--no-open' : '--open',
], { stdio: ['ignore', 'pipe', 'inherit'] });
let url: string | null = null;
let pending = '';
child.stdout.on('data', (chunk: Buffer) => {
// The child prints `MCP App <app> at <url> (…)`; relay it to stderr so the routed
// CLI keeps stdout for its JSON result, and parse whole lines only — one write can
// arrive split across chunks.
const text = chunk.toString('utf8');
process.stderr.write(text);
pending += text;
const lines = pending.split('\n');
pending = lines.pop() ?? '';
for (const line of lines) {
url ??= /\bat (https?:\/\/\S+)/u.exec(line)?.[1] ?? null;
}
});
// Ctrl-C reaching the routed CLI becomes the child's SIGTERM.
signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
child.once('error', fail);
child.once('exit', (code) => done({
exitCode: code ?? 1,
message: code === 0
? 'dashboard closed'
: `agent-bundle serve-app exited with ${String(code)}`,
url,
}));
});
}
```

`serveApp` runs in the plugin's dev-time or CLI process, never inside the MCP server shell: import
it lazily from the route that needs it, as above, so the emitted artifact stays free of the host
runtime, and expect `agent-bundle` to be resolvable where that CLI runs. Always `close()` when
you are done — `closed` settling means the server connection ended, not that the host was torn
down. `autoApprove` grants the
listed consent capabilities on the operator's behalf as the App requests them (`call-tool` lets a
polling dashboard refresh without a prompt); anything else waits for an Allow/Deny decision in the
host page, as in the Workbench.
Every `serve-app` option — `--port`, `--input`, `--profile`, `--env-file`, `--plugin-root` —
passes through as argv, and the host packs stay self-contained because the framework is spawned,
never bundled.

## Server modes

Expand Down
2 changes: 1 addition & 1 deletion website/docs/en/reference/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Every public entry point is documented from its declarations:
| Entry point | Contents |
| --- | --- |
| `agent-bundle` | The authoring and orchestration surface: `defineSkill`, `canonicalAgentEvents`, `startDevServer`, `runEvals`, `compareEvals`, the eval harness factories, and the artifact-manifest helpers. |
| `agent-bundle/api` | The programmatic compiler: `build`, `validate`, `inspect`, `prepack`, their option and result types, the `AgentComponentKind` / `componentKindCapability` component-kind helpers, and the artifact operations `listMcp`, `invokeMcp`, `runMcp`, `serveApp` (a built MCP App served standalone in a browser), `listHooks`, and `simulateHook`. |
| `agent-bundle/api` | The programmatic compiler: `build`, `validate`, `inspect`, `prepack`, their option and result types, the `AgentComponentKind` / `componentKindCapability` component-kind helpers, and the artifact operations `listMcp`, `invokeMcp`, `runMcp`, `serveApp` (a built MCP App served standalone in a browser — a host-process API for scripts and tests; a routed CLI command inside the artifact cannot import it, see [Serving an App standalone](../guide/authoring/mcp.mdx#serving-an-app-standalone)), `listHooks`, and `simulateHook`. |
| `agent-bundle/config` | `defineConfig` and the configuration types. |
| `agent-bundle/test` | The route-testing harness, matchers, and contract matrices. |
| `agent-bundle/test/browser` | The MCP App bridge harness for browser-rendered views. |
Expand Down
4 changes: 3 additions & 1 deletion website/docs/en/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ The host binds to `127.0.0.1` only, serves one document and the authenticated `/
routes (a per-launch token plus same-origin checks; `AB8003` / `AB8004` on refusal), and exposes
only the selected server through the bridge. The App document itself runs on a second loopback
origin inside the framework's sandbox. It is a local preview host, not a deployment target. The
programmatic form is `serveApp` in `agent-bundle/api`; see
programmatic form for scripts and tests is `serveApp` in `agent-bundle/api`; a plugin's own routed
CLI command spawns this command instead, because the self-contained bin cannot import
`agent-bundle/api` — see
[Serving an App standalone](../guide/authoring/mcp.mdx#serving-an-app-standalone).

## build and prepack
Expand Down
Loading
Loading