Skip to content
5 changes: 5 additions & 0 deletions .changeset/514-serve-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"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)
35 changes: 35 additions & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1258,3 +1258,38 @@ content-hashed bundle inside the target root). `--plugin-root <path>`
overrides the env-anchor root, e.g. point it at `artifact/<target>` for a
byte-faithful rehearsal of a copied-artifact launch; under a host install the
anchor still means the durable install root, exactly as before.

## `agent-bundle serve-app`

```sh
agent-bundle serve-app <server>/<app> [--artifact <path>] [--target <target>]
[--tool <name>] [--input <json> | --input-file <path>] [--port <port>]
[--profile <profile>] [--allow <capability>]... [--open]
[--env-file <path>]... [--no-env] [--plugin-root <path>]
```

Serves one built MCP App standalone in a browser, outside any MCP host and
without the Workbench. The command launches the App's packed MCP server
through exactly the `mcp run` launcher above (same manifest resolution, same
three-layer environment, same durable-state anchors), binds the App to that
one session through the Workbench's own MCP App host stack
(`McpAppBindingService` → `McpAppPreviewService` → `McpAppRoutes`, the
loopback sandbox proxy, the consent authority, `McpAppBridge`), calls the
App's tool once so it opens populated, and prints the loopback URL. It runs
in the foreground until SIGINT/SIGTERM, or until the server exits on its own,
which is reported as one `AB5000` diagnostic with exit code 1. Without
`--artifact`, a throwaway artifact is built into a staging directory beside
the project root and removed when the host closes.

The host document is served on `127.0.0.1` only, at `/`, with the
authenticated `/api/mcp/...` routes behind a per-launch token plus
same-origin and loopback `Host` checks (`AB8003` / `AB8004` on refusal); the
App document runs on a second loopback origin inside the framework sandbox,
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.
9 changes: 9 additions & 0 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ as well, and is the form to use when the component also needs the URI at run
time. The full grammar and the `config.template` resolution rule are in
[Diagnostics](diagnostics.md).

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).

The compiler statically reads `config`, imports schemas and implementations
only into generated entries, installs `runAgentRequest`, and derives the real
MCP server from the route graph. Each call renders through a warm internal
Expand Down
83 changes: 82 additions & 1 deletion packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { execFile as executeFile } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';

import { Effect } from 'effect';
import { Effect, type Scope } from 'effect';

import { capabilityIsSupported, unavailableCapability } from './adapters/capability-state.ts';
import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts';
Expand Down Expand Up @@ -32,6 +33,11 @@ import {
import { emptyCompiledRouteGraph } from './routes/graph.ts';
import { inspectRouteGraph, type RouteGraphInspection } from './routes/inspect.ts';
import { mcpServerStateDirectory, runMcpForeground } from './services/mcp-run.ts';
import { parseServeAppSelector, serveMcpApp } from './serve-app/serve-mcp-app.ts';
import type { ServedMcpApp, ServeMcpAppPublicOptions } from './serve-app/types.ts';
export type { McpAppConsentCapability, ServedMcpApp as ServedApp } from './serve-app/types.ts';
export type { OpenBrowser } from './dev/mcp-apps/mcp-app-preview-host.ts';
export type { McpAppProfileId } from './dev/mcp-app-profile-descriptors.ts';
import { deepFreeze } from './core/freeze.ts';

export { compileRouteGraph, emptyCompiledRouteGraph, isEmptyRouteGraph } from './routes/graph.ts';
Expand Down Expand Up @@ -526,6 +532,19 @@ export interface RunMcpOptions extends ArtifactOperationOptions {
readonly target: string;
}

export interface ServeAppOptions extends ArtifactOperationOptions, ServeMcpAppPublicOptions {
/** The MCP App to serve: `<server>/<app>` (for example `status/status`), or `<server>/ui://...` for an exact resource URI. */
readonly app: string;
/** Explicit `.env` files replacing the conventional project-root set; see {@link RunMcpOptions.envFiles}. */
readonly envFiles?: readonly string[];
/** Set false to launch the server without any `.env` layer. */
readonly loadEnvFiles?: boolean;
/** Root the env-declared plugin-root anchors expand to; see {@link RunMcpOptions.pluginRoot}. */
readonly pluginRoot?: string;
/** The artifact target whose generated server to bind; defaults to `portable`. */
readonly target?: string;
}

export interface ListHooksOptions extends ArtifactOperationOptions {
readonly target?: string;
}
Expand Down Expand Up @@ -1342,6 +1361,68 @@ export const runMcp = async (options: RunMcpOptions): Promise<number> => {
}));
};

/**
* A throwaway artifact whose lifetime is the served App's: built into a
* staging directory beside the project when the App is served, removed when
* `close()` finalizes the scope. Ownership transfers to the served App, so
* this is a scoped `acquireRelease` rather than `withTempDirectory`.
*/
const scopedThrowawayArtifact = (
options: ArtifactOperationOptions,
): Effect.Effect<string, unknown, Scope.Scope> => Effect.acquireRelease(
liftPromise(() => mkdtemp(join(resolve(options.root), '.agent-bundle-artifact-'))),
(artifact) => Effect.promise(() => rm(artifact, { force: true, recursive: true }).catch(() => undefined)),
).pipe(Effect.tap((artifact) => liftPromise(() => build({
configPath: options.configPath,
logger: options.logger,
mode: options.mode,
output: artifact,
registry: options.registry,
root: options.root,
targets: options.targets,
}))));

/**
* Serves one built MCP App standalone in a browser, bound to the plugin's
* own packed MCP server. The server launches exactly as {@link runMcp}
* launches it (same artifact resolution, same `.env` layering, same
* plugin-data root under `.agent-bundle/mcp-run/<target>/<server>`), the App
* is hosted through the Workbench's MCP App host stack (sandbox proxy,
* consent authority, bridge), and the result's `url` renders it. Call
* `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.
*/
export const serveApp = async (options: ServeAppOptions): Promise<ServedMcpApp> => {
const registry = registryFor(options);
const workspaceRoot = resolve(options.root);
const target = options.target ?? 'portable';
const { server } = parseServeAppSelector(options.app);
return serveMcpApp({
app: options.app,
artifact: options.artifact === undefined ? scopedThrowawayArtifact({ ...options, registry }) : resolve(options.artifact),
...(options.autoApprove === undefined ? {} : { autoApprove: options.autoApprove }),
...(options.envFiles === undefined ? {} : { envFiles: options.envFiles }),
...(options.pluginRoot === undefined ? {} : { envPluginRoot: resolve(options.pluginRoot) }),
...(options.input === undefined ? {} : { input: options.input }),
...(options.loadEnvFiles === undefined ? {} : { loadEnvFiles: options.loadEnvFiles }),
...(options.mode === undefined ? {} : { mode: options.mode }),
...(options.open === undefined ? {} : { open: options.open }),
...(options.openBrowser === undefined ? {} : { openBrowser: options.openBrowser }),
pluginDataRoot: join(workspaceRoot, '.agent-bundle', 'mcp-run', target, mcpServerStateDirectory(server)),
...(options.port === undefined ? {} : { port: options.port }),
...(options.profile === undefined ? {} : { profile: options.profile }),
registry,
target,
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
...(options.tool === undefined ? {} : { tool: options.tool }),
workspaceRoot,
});
};

export const listHooks = async (options: ListHooksOptions) => {
const registry = registryFor(options);
if (options.target !== undefined && !registry.has(options.target)) {
Expand Down
129 changes: 124 additions & 5 deletions packages/agent-bundle/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ import type {
inspect,
prepack,
runEvals,
serveApp,
startDevServer,
validate,
InspectionComponentCapability,
InspectionSkippedComponent,
McpAppConsentCapability,
McpAppProfileId,
ProjectOptions,
} from './api.ts';
import type {
Expand Down Expand Up @@ -93,6 +96,8 @@ export interface CliDependencies {
readonly prepack?: typeof prepack;
readonly runDoctor?: typeof runDoctor;
readonly runHostMcpProxy?: typeof runHostMcpProxy;
/** Injectable only to verify the serve-app CLI contract without a built artifact. */
readonly serveApp?: typeof serveApp;
/** Injectable only to make foreground shutdown behavior deterministic in tests. */
readonly signals?: CliSignalSource;
readonly startDevServer?: typeof startDevServer;
Expand Down Expand Up @@ -191,6 +196,22 @@ interface DevProxyCommandOptions {
readonly url?: string;
}

interface ServeAppCommandOptions extends JsonInputOptions {
readonly allow: readonly McpAppConsentCapability[];
readonly artifact?: string;
readonly config?: string;
readonly env: boolean;
readonly envFile: readonly string[];
readonly mode?: string;
readonly open?: boolean;
readonly pluginRoot?: string;
readonly port?: number;
readonly profile: McpAppProfileId;
readonly root: string;
readonly target: string;
readonly tool?: string;
}

const collect = (value: string, previous: string[]): string[] => [...previous, value];

const port = (value: string): number => {
Expand Down Expand Up @@ -225,6 +246,23 @@ const installScope = (value: string): InstallScope => {
throw new TypeError('Install scope must be user, project, or local.');
};

const mcpAppProfile = (value: string): McpAppProfileId => {
if (value === 'portable' || value === 'claude' || value === 'chatgpt') return value;
throw new InvalidArgumentError('MCP App profile must be portable, claude, or chatgpt.');
};

const consentCapabilities: ReadonlySet<McpAppConsentCapability> = new Set<McpAppConsentCapability>([
'call-tool', 'download-file', 'open-external-link', 'request-display-mode',
]);

const consentCapability = (value: string): McpAppConsentCapability => {
if (consentCapabilities.has(value as McpAppConsentCapability)) return value as McpAppConsentCapability;
throw new InvalidArgumentError('Consent capability must be call-tool, download-file, open-external-link, or request-display-mode.');
};

const collectConsentCapability = (value: string, previous: readonly McpAppConsentCapability[]): readonly McpAppConsentCapability[] =>
[...previous, consentCapability(value)];

const doctorHost = (value: string): DoctorHost => {
if (value === 'claude' || value === 'codex' || value === 'cursor') return value;
throw new InvalidArgumentError('Doctor host must be claude, codex, or cursor.');
Expand Down Expand Up @@ -619,25 +657,36 @@ const humanValidate = (result: Awaited<ReturnType<typeof validate>>): string =>
};

/**
* Closes the foreground development session on SIGINT/SIGTERM. Returns a
* promise that settles once a signal has closed the session (so the caller
* can keep the terminal services alive until the close diagnostics, if any,
* have been written); it never settles when no signal arrives.
* Closes the foreground session on SIGINT/SIGTERM. Returns a promise that
* settles once a signal has closed the session (so the caller can keep the
* terminal services alive until the close diagnostics, if any, have been
* written). Without `until` it never settles when no signal arrives; when
* `until` settles first, the signal listeners are released and the promise
* settles without closing anything.
*/
const closeForegroundOnSignal = (
session: Pick<Awaited<ReturnType<typeof startDevServer>>, 'close'>,
signals: CliSignalSource,
writeDiagnostics: (text: string) => Promise<void>,
until?: Promise<unknown>,
): Promise<void> => new Promise<void>((settle) => {
const terminationSignals = ['SIGINT', 'SIGTERM'] as const;
let closing: Promise<void> | undefined;
const detach = (): void => {
for (const signal of terminationSignals) signals.removeListener(signal, close);
};
const close = (): void => {
closing ??= session.close().catch((error: unknown) => writeDiagnostics(machineLine(diagnosticsFor(error)))).finally(() => {
for (const signal of terminationSignals) signals.removeListener(signal, close);
detach();
settle();
});
};
for (const signal of terminationSignals) signals.once(signal, close);
void until?.then(() => {
if (closing !== undefined) return;
detach();
settle();
}, () => undefined);
});

export const runCli = async (
Expand Down Expand Up @@ -710,6 +759,76 @@ export const runCli = async (
await pending;
});

const serveAppCommand = program.command('serve-app')
.description('Serve one built MCP App standalone in a browser, bound to its packed MCP server')
.argument('<app>', 'MCP App as <server>/<app>, or <server>/ui://... for an exact resource URI')
.option('--root <root>', 'Project root', process.cwd())
.option('--config <path>', 'Configuration file relative to --root')
.option('--mode <mode>', 'Configuration mode', 'production')
.option('--artifact <path>', 'Use exactly this built artifact')
.option('--target <target>', 'Artifact target containing the MCP server', 'portable')
.option('--tool <tool>', 'Tool whose result opens the App (default: the only tool that declares the App)')
.option('--input <json>', 'Inline JSON object input for the opening tool call')
.option('--input-file <path>', 'JSON object input file for the opening tool call')
.option('--port <port>', 'Loopback TCP port', port)
.option('--profile <profile>', 'Simulated MCP Apps host profile: portable, claude, or chatgpt', mcpAppProfile, 'portable')
.option(
'--allow <capability>',
'Approve one consent capability on your behalf as the App requests it (repeatable): call-tool, download-file, open-external-link, request-display-mode',
collectConsentCapability,
[],
)
.option('--open', 'Open the default browser once the host is listening')
.option('--no-open', 'Do not open the default browser')
.option('--env-file <path>', 'Load exactly this .env file, replacing the project-root set (repeatable)', collect, [])
.option('--no-env', 'Launch the server without loading any .env files')
.option('--plugin-root <path>', 'Expand env plugin-root anchors against this root instead of the project root');
serveAppCommand.action(async (app: string, options: ServeAppCommandOptions) => {
if (options.env === false && options.envFile.length > 0) {
throw new TypeError('Use either --env-file or --no-env, not both.');
}
const input = options.input === undefined && options.inputFile === undefined ? {} : await parseJsonObject(options);
const { serveApp: serve } = await import('./api.ts');
const served = await (dependencies.serveApp ?? serve)({
...(options.allow.length === 0 ? {} : { autoApprove: options.allow }),
app,
...(options.artifact === undefined ? {} : { artifact: options.artifact }),
...(options.config === undefined ? {} : { configPath: options.config }),
...(options.envFile.length === 0 ? {} : { envFiles: options.envFile }),
input,
...(options.env === false ? { loadEnvFiles: false } : {}),
mode: options.mode,
open: options.open === true,
...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }),
...(options.port === undefined ? {} : { port: options.port }),
profile: options.profile,
root: options.root,
target: options.target,
...(options.tool === undefined ? {} : { tool: options.tool }),
});
await show(`MCP App ${app} at ${served.url} (tool ${served.tool}; Ctrl-C stops the server)\n`);
// The host outlives this call like `dev` does; it ends on a termination
// signal, or when the bound server exits on its own, which is reported
// as a diagnostic and, in the real process, as exit code 1.
let closedBySignal = false;
const session = {
close: () => {
closedBySignal = true;
return served.close();
},
};
foreground = closeForegroundOnSignal(session, dependencies.signals ?? process, diagnostics, served.closed).then(async () => {
if (closedBySignal) return;
await diagnostics(machineLine([{
code: 'AB5000',
message: `The MCP server behind ${app} exited; the MCP App host closed.`,
severity: 'error',
} satisfies Diagnostic]));
if (dependencies.signals === undefined) process.exitCode = 1;
await served.close().catch(() => undefined);
});
});

const buildCommand = configureSourceOptions(
program.command('build').description('Build a validated Agent Bundle artifact'),
)
Expand Down
Loading
Loading