Skip to content

Commit 4e610e6

Browse files
docs(mcp): serveApp is a host-process API; a routed CLI command spawns agent-bundle serve-app (#558) (#560)
* docs(mcp): serveApp is a host-process API; a routed CLI command spawns agent-bundle serve-app (#558) The #537 docs showed a plugin route doing await import('agent-bundle/api'). That does not build: routed CLI bins are self-contained, so the bundler inlines the compiler and fails on its runtime-relative module references, and the external or non-literal escapes are AB6005. State the real audience of serveApp (CLI, Workbench, tests, a plugin's own scripts), name the constraint, and show the child-process spawn pattern cargo-hauler shipped, with #558 linked. * docs(mcp): parse the serve-app URL from whole stdout lines in the dashboard sample A child stdout write can arrive split across data events; buffer to newlines before matching, as cargo-hauler's route does. Addresses the Codex P2 thread on #560.
1 parent cbda5ab commit 4e610e6

10 files changed

Lines changed: 241 additions & 80 deletions

File tree

‎.changeset/514-serve-app.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"agent-bundle": patch
33
---
44

5-
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)
5+
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)

‎docs/entry-conventions.md‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,7 +1414,21 @@ and the bridge exposes only the selected server. This is a local preview
14141414
host, not a deployment target.
14151415

14161416
`serveApp` in `agent-bundle/api` is the programmatic form (`{ url, close,
1417-
closed }`) for a plugin's own routed CLI (`hauler dashboard`). It belongs to
1418-
the plugin's dev-time / CLI process — import it lazily from the route that
1419-
needs it — never to the MCP server shell, so emitted artifacts stay free of
1420-
the host runtime.
1417+
closed }`). It is a host-process API: it belongs to processes the framework
1418+
does not compile — the first-party CLI, the Workbench, tests, a plugin's own
1419+
`package.json` scripts or a hand-written `.mjs` run from the checkout — and
1420+
never to the MCP server shell. A routed CLI command inside the artifact
1421+
cannot import it today: routed CLI bins are self-contained (#387), so the
1422+
bundler inlines `agent-bundle/dist/api.js` into the bin and fails on the
1423+
framework's runtime-relative module references (`Module not found: Can't
1424+
resolve '../events'`), while an external bare import (`AB6005 uses
1425+
unsupported specifier`) or a non-literal `import(spec)` (`AB6005 has a
1426+
non-literal dynamic import`) fails artifact validation. The pattern that
1427+
builds is a plain routed command that spawns `agent-bundle serve-app` as a
1428+
child process — resolving the framework CLI from `node_modules/agent-bundle`
1429+
by path, relaying the child's `MCP App <app> at <url>` line to stderr so the
1430+
routed CLI keeps stdout for its result, and turning the route `signal` into
1431+
the child's `SIGTERM` — which makes it a checkout-only command (an installed
1432+
host pack has neither `node_modules` nor the artifact). A framework helper
1433+
for that plumbing is tracked in #558; the worked example is in the MCP Apps
1434+
guide, "Serving an App standalone".

‎docs/framework-mode.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,12 @@ A built App is previewed in the Workbench MCP page, or served standalone in a
9797
plain browser tab with `agent-bundle serve-app <server>/<app>` — the same
9898
host stack (sandbox proxy, consent authority, bridge) bound to the plugin's
9999
own packed server, launched as `mcp run` launches it. `serveApp` in
100-
`agent-bundle/api` is the programmatic form for a plugin's own "open the
101-
dashboard" CLI route; it runs in the plugin's dev-time / CLI process, never
102-
in the MCP shell, and is a local preview host, not a deployment target. See
103-
[Entry conventions](entry-conventions.md#agent-bundle-serve-app).
100+
`agent-bundle/api` is the programmatic form for host processes — the CLI,
101+
the Workbench, tests, a plugin's own scripts — never the MCP shell, and a
102+
local preview host, not a deployment target. A plugin's own "open the
103+
dashboard" CLI route cannot import it (the routed CLI bin is self-contained;
104+
`AB6005`) and spawns `agent-bundle serve-app` instead; see
105+
[Entry conventions](entry-conventions.md#agent-bundle-serve-app) and #558.
104106

105107
The compiler statically reads `config`, imports schemas and implementations
106108
only into generated entries, installs `runAgentRequest`, and derives the real

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,9 +1498,12 @@ const scopedThrowawayArtifact = (
14981498
* `close()` to tear down the host and the server; `closed` settles when the
14991499
* server connection ends for any reason.
15001500
*
1501-
* This runs in a dev-time or CLI process — a plugin's own routed CLI can
1502-
* call it from a `hauler dashboard`-style route — never inside the MCP
1503-
* server shell.
1501+
* This is a host-process API: it runs in processes the framework does not
1502+
* compile — the first-party CLI, the Workbench, tests, a plugin's own
1503+
* scripts — never inside the MCP server shell, and not from a routed CLI
1504+
* command inside the artifact, whose self-contained bin cannot import
1505+
* `agent-bundle/api` (`AB6005`); such a route spawns `agent-bundle
1506+
* serve-app` instead (issue #558).
15041507
*/
15051508
export const serveApp = async (options: ServeAppOptions): Promise<ServedMcpApp> => {
15061509
const registry = registryFor(options);

‎website/docs/en/guide/authoring/mcp.mdx‎

Lines changed: 105 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -702,55 +702,126 @@ exposes only that server through the bridge; the App document runs on a second l
702702
the framework's sandbox. It is a local preview host, not a deployment target. Every option is in
703703
the [CLI reference](../../reference/cli.mdx#serve-app).
704704
705-
The same host is available programmatically as `serveApp` in `agent-bundle/api`, so a plugin's
706-
own routed CLI can offer the command. It returns `{ url, close, closed }`: `close()` tears down the
707-
host and the server, `closed` settles when the server connection ends for any reason.
705+
The same host is available programmatically as `serveApp` in `agent-bundle/api`. It returns
706+
`{ url, close, closed }`: `close()` tears down the host and the server, `closed` settles when the
707+
server connection ends for any reason — always `close()` when you are done, because `closed`
708+
settling means the server connection ended, not that the host was torn down. `autoApprove` grants
709+
the listed consent capabilities on the operator's behalf as the App requests them (`call-tool`
710+
lets a polling dashboard refresh without a prompt); anything else waits for an Allow/Deny decision
711+
in the host page, as in the Workbench.
712+
713+
`serveApp` is a **host-process API**. It belongs to processes the framework does not compile —
714+
the first-party CLI, the Workbench, tests, a plugin's own `package.json` scripts or a hand-written
715+
`.mjs` run from the checkout — and it needs `agent-bundle` resolvable where that process runs.
716+
Never call it from the MCP server shell.
717+
718+
A routed CLI command inside the plugin artifact cannot import it today. Routed CLI bins are
719+
self-contained (`bin/<plugin>.mjs` in every host pack, `dist/bin/<plugin>.js` in the package
720+
build), so a route with `await import('agent-bundle/api')` makes the bundler inline the whole
721+
compiler into the bin, where it fails on the framework's runtime-relative module references
722+
(`Module not found: Can't resolve '../events'`); leaving the import external is
723+
`AB6005 … uses unsupported specifier "agent-bundle/api"`, and a non-literal `import(spec)` is
724+
`AB6005 … has a non-literal dynamic import`. A helper a routed command can use is tracked in
725+
[#558](https://github.com/ScriptedAlchemy/agent-bundle/issues/558).
726+
727+
The pattern that builds is a plain routed command that spawns `agent-bundle serve-app` as a child
728+
process, as cargo-hauler's `hauler dashboard` does. It is a **checkout command**: it needs
729+
`agent-bundle` under `node_modules` and the built `artifact/` beside the CLI, neither of which an
730+
installed host pack has, so it says so instead of failing inside the child.
708731
709732
```ts
710733
// src/cli/dashboard.ts — `hauler dashboard`: open the App against the running daemon.
734+
import { spawn } from 'node:child_process';
735+
import { existsSync, readFileSync } from 'node:fs';
736+
import { dirname, join, resolve } from 'node:path';
737+
import { fileURLToPath } from 'node:url';
738+
711739
import type { CliRouteConfig, CliRouteProps } from 'agent-bundle';
712740
import { z } from 'zod';
713741
714742
export const config = {
715-
description: 'Open the cargo-hauler dashboard in a browser.',
743+
description: 'Open the cargo-hauler dashboard in a browser (from the plugin checkout).',
744+
exitCode: 'result',
716745
} satisfies CliRouteConfig;
717746
718-
export const inputSchema = z.object({ open: z.boolean().default(true) }).strict();
719-
720-
export const resultSchema = z.object({ url: z.string() }).strict();
747+
export const inputSchema = z.object({ noOpen: z.boolean().optional() }).strict();
748+
749+
export const resultSchema = z.object({
750+
exitCode: z.number().int(),
751+
message: z.string(),
752+
url: z.string().nullable(),
753+
}).strict();
754+
755+
// The framework CLI, read from the `bin` of the nearest `node_modules/agent-bundle` at or
756+
// above the plugin root. Located by path, never imported: an `import()` of the package
757+
// would pull the framework into the bin.
758+
const frameworkCli = (root: string): string | undefined => {
759+
for (let directory = root; ; directory = dirname(directory)) {
760+
const manifestPath = join(directory, 'node_modules', 'agent-bundle', 'package.json');
761+
if (existsSync(manifestPath)) {
762+
const { bin } = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
763+
bin?: string | Record<string, string>;
764+
};
765+
const relative = typeof bin === 'string' ? bin : bin?.['agent-bundle'];
766+
if (relative === undefined) return undefined;
767+
return resolve(dirname(manifestPath), relative);
768+
}
769+
if (directory === dirname(directory)) return undefined;
770+
}
771+
};
721772
722773
export default async function dashboard({ input, signal }: CliRouteProps<typeof inputSchema>) {
723-
const { serveApp } = await import('agent-bundle/api');
724-
const served = await serveApp({
725-
app: 'hauler/dashboard',
726-
artifact: new URL('../../artifact', import.meta.url).pathname,
727-
autoApprove: ['call-tool'],
728-
open: input.open,
729-
root: process.cwd(),
730-
target: 'cursor',
731-
tool: 'hauler_status',
732-
});
733-
signal.addEventListener('abort', () => { void served.close(); }, { once: true });
734-
try {
735-
// Settles on Ctrl-C (through the signal) or when the daemon exits on its own.
736-
await served.closed;
737-
} finally {
738-
// `closed` tracks only the server connection; close() also releases the
739-
// host, the sandbox proxy, and any throwaway artifact.
740-
await served.close();
774+
// `dist/bin/<plugin>.js` sits two levels under the checkout, which holds `artifact/`.
775+
const root = fileURLToPath(new URL('../../', import.meta.url));
776+
const cli = frameworkCli(root);
777+
const artifact = join(root, 'artifact');
778+
if (cli === undefined || !existsSync(join(artifact, 'agent-bundle.manifest.json'))) {
779+
return {
780+
exitCode: 1,
781+
message: 'hauler dashboard runs from the plugin checkout (pnpm install, then '
782+
+ 'agent-bundle build); in an MCP host, call hauler_status instead.',
783+
url: null,
784+
};
741785
}
742-
return { url: served.url };
786+
return new Promise<z.infer<typeof resultSchema>>((done, fail) => {
787+
const child = spawn(process.execPath, [
788+
cli, 'serve-app', 'hauler/dashboard', '--root', root,
789+
'--artifact', artifact, '--target', 'portable',
790+
'--tool', 'hauler_status', '--allow', 'call-tool',
791+
input.noOpen === true ? '--no-open' : '--open',
792+
], { stdio: ['ignore', 'pipe', 'inherit'] });
793+
let url: string | null = null;
794+
let pending = '';
795+
child.stdout.on('data', (chunk: Buffer) => {
796+
// The child prints `MCP App <app> at <url> (…)`; relay it to stderr so the routed
797+
// CLI keeps stdout for its JSON result, and parse whole lines only — one write can
798+
// arrive split across chunks.
799+
const text = chunk.toString('utf8');
800+
process.stderr.write(text);
801+
pending += text;
802+
const lines = pending.split('\n');
803+
pending = lines.pop() ?? '';
804+
for (const line of lines) {
805+
url ??= /\bat (https?:\/\/\S+)/u.exec(line)?.[1] ?? null;
806+
}
807+
});
808+
// Ctrl-C reaching the routed CLI becomes the child's SIGTERM.
809+
signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
810+
child.once('error', fail);
811+
child.once('exit', (code) => done({
812+
exitCode: code ?? 1,
813+
message: code === 0
814+
? 'dashboard closed'
815+
: `agent-bundle serve-app exited with ${String(code)}`,
816+
url,
817+
}));
818+
});
743819
}
744820
```
745821
746-
`serveApp` runs in the plugin's dev-time or CLI process, never inside the MCP server shell: import
747-
it lazily from the route that needs it, as above, so the emitted artifact stays free of the host
748-
runtime, and expect `agent-bundle` to be resolvable where that CLI runs. Always `close()` when
749-
you are done — `closed` settling means the server connection ended, not that the host was torn
750-
down. `autoApprove` grants the
751-
listed consent capabilities on the operator's behalf as the App requests them (`call-tool` lets a
752-
polling dashboard refresh without a prompt); anything else waits for an Allow/Deny decision in the
753-
host page, as in the Workbench.
822+
Every `serve-app` option — `--port`, `--input`, `--profile`, `--env-file`, `--plugin-root` —
823+
passes through as argv, and the host packs stay self-contained because the framework is spawned,
824+
never bundled.
754825
755826
## Server modes
756827

‎website/docs/en/reference/api.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Every public entry point is documented from its declarations:
1414
| Entry point | Contents |
1515
| --- | --- |
1616
| `agent-bundle` | The authoring and orchestration surface: `defineSkill`, `canonicalAgentEvents`, `startDevServer`, `runEvals`, `compareEvals`, the eval harness factories, and the artifact-manifest helpers. |
17-
| `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`. |
17+
| `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`. |
1818
| `agent-bundle/config` | `defineConfig` and the configuration types. |
1919
| `agent-bundle/test` | The route-testing harness, matchers, and contract matrices. |
2020
| `agent-bundle/test/browser` | The MCP App bridge harness for browser-rendered views. |

‎website/docs/en/reference/cli.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ The host binds to `127.0.0.1` only, serves one document and the authenticated `/
9999
routes (a per-launch token plus same-origin checks; `AB8003` / `AB8004` on refusal), and exposes
100100
only the selected server through the bridge. The App document itself runs on a second loopback
101101
origin inside the framework's sandbox. It is a local preview host, not a deployment target. The
102-
programmatic form is `serveApp` in `agent-bundle/api`; see
102+
programmatic form for scripts and tests is `serveApp` in `agent-bundle/api`; a plugin's own routed
103+
CLI command spawns this command instead, because the self-contained bin cannot import
104+
`agent-bundle/api` — see
103105
[Serving an App standalone](../guide/authoring/mcp.mdx#serving-an-app-standalone).
104106

105107
## build and prepack

0 commit comments

Comments
 (0)