From 9b314f7c022ebdb2118f4376b18889e0ff9ad49a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 06:29:02 +0000 Subject: [PATCH 1/4] feat(cli): serve a built MCP App standalone with agent-bundle serve-app and serveApp in agent-bundle/api (#514) --- .changeset/514-serve-app.md | 5 + docs/entry-conventions.md | 35 + docs/framework-mode.md | 9 + packages/agent-bundle/src/api.ts | 82 ++- packages/agent-bundle/src/cli.ts | 129 +++- .../src/dev/mcp-apps/mcp-app-preview-host.ts | 41 ++ .../agent-bundle/src/dev/workbench-server.ts | 30 +- .../src/serve-app/serve-app-page.ts | 326 ++++++++++ .../src/serve-app/serve-mcp-app.ts | 615 ++++++++++++++++++ packages/agent-bundle/src/services/mcp-run.ts | 44 +- packages/agent-bundle/tests/cli.test.ts | 128 ++++ packages/agent-bundle/tests/serve-app.test.ts | 279 ++++++++ rstest.integration-tests.ts | 1 + website/docs/en/guide/authoring/mcp.mdx | 61 ++ .../docs/en/guide/development/workbench.mdx | 4 +- website/docs/en/reference/api.mdx | 2 +- website/docs/en/reference/cli.mdx | 41 +- website/docs/en/reference/security.mdx | 7 + website/docs/zh/guide/authoring/mcp.mdx | 57 ++ .../docs/zh/guide/development/workbench.mdx | 3 +- website/docs/zh/reference/api.mdx | 2 +- website/docs/zh/reference/cli.mdx | 37 +- website/docs/zh/reference/security.mdx | 5 + 23 files changed, 1889 insertions(+), 54 deletions(-) create mode 100644 .changeset/514-serve-app.md create mode 100644 packages/agent-bundle/src/dev/mcp-apps/mcp-app-preview-host.ts create mode 100644 packages/agent-bundle/src/serve-app/serve-app-page.ts create mode 100644 packages/agent-bundle/src/serve-app/serve-mcp-app.ts create mode 100644 packages/agent-bundle/tests/serve-app.test.ts diff --git a/.changeset/514-serve-app.md b/.changeset/514-serve-app.md new file mode 100644 index 000000000..5df35e615 --- /dev/null +++ b/.changeset/514-serve-app.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Add `agent-bundle serve-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 `, `--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. (#527) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index e5afdf1ed..dc2a57e75 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1179,3 +1179,38 @@ content-hashed bundle inside the target root). `--plugin-root ` overrides the env-anchor root, e.g. point it at `artifact/` 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 / [--artifact ] [--target ] + [--tool ] [--input | --input-file ] [--port ] + [--profile ] [--allow ]... [--open] + [--env-file ]... [--no-env] [--plugin-root ] +``` + +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. diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 9ad096fa7..bce2e631f 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -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 /` — 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 diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 2a65fcb00..8998d4805 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -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'; @@ -32,6 +33,10 @@ 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, type ServedMcpApp, type ServeMcpAppOptions } from './serve-app/serve-mcp-app.ts'; +export type { McpAppConsentCapability, ServedMcpApp as ServedApp } from './serve-app/serve-mcp-app.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'; @@ -526,6 +531,19 @@ export interface RunMcpOptions extends ArtifactOperationOptions { readonly target: string; } +export interface ServeAppOptions extends ArtifactOperationOptions, Pick { + /** The MCP App to serve: `/` (for example `status/status`), or `/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; } @@ -1342,6 +1360,68 @@ export const runMcp = async (options: RunMcpOptions): Promise => { })); }; +/** + * 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 => 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//`), 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 => { + 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)) { diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index de76fa67b..19de93a09 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -21,10 +21,13 @@ import type { inspect, prepack, runEvals, + serveApp, startDevServer, validate, InspectionComponentCapability, InspectionSkippedComponent, + McpAppConsentCapability, + McpAppProfileId, ProjectOptions, } from './api.ts'; import type { @@ -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; @@ -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 => { @@ -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 = new Set([ + '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.'); @@ -619,25 +657,36 @@ const humanValidate = (result: Awaited>): 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>, 'close'>, signals: CliSignalSource, writeDiagnostics: (text: string) => Promise, + until?: Promise, ): Promise => new Promise((settle) => { const terminationSignals = ['SIGINT', 'SIGTERM'] as const; let closing: Promise | 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 ( @@ -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('', 'MCP App as /, or /ui://... for an exact resource URI') + .option('--root ', 'Project root', process.cwd()) + .option('--config ', 'Configuration file relative to --root') + .option('--mode ', 'Configuration mode', 'production') + .option('--artifact ', 'Use exactly this built artifact') + .option('--target ', 'Artifact target containing the MCP server', 'portable') + .option('--tool ', 'Tool whose result opens the App (default: the only tool that declares the App)') + .option('--input ', 'Inline JSON object input for the opening tool call') + .option('--input-file ', 'JSON object input file for the opening tool call') + .option('--port ', 'Loopback TCP port', port) + .option('--profile ', 'Simulated MCP Apps host profile: portable, claude, or chatgpt', mcpAppProfile, 'portable') + .option( + '--allow ', + '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 ', '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 ', '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'), ) diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-preview-host.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-preview-host.ts new file mode 100644 index 000000000..632b33fe1 --- /dev/null +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-preview-host.ts @@ -0,0 +1,41 @@ +import { spawn } from 'node:child_process'; + +import type { McpAppBridgeHost, McpAppBridgeHostInfo } from './mcp-app-bridge.ts'; + +export type OpenBrowser = (url: string) => Promise | void; + +/** The host identity every agent-bundle MCP App host (Workbench and `serve-app`) advertises on `ui/initialize`. */ +export const mcpAppPreviewHostInfo: McpAppBridgeHostInfo = Object.freeze({ name: 'agent-bundle', version: '0.1.0' }); + +/** Opens `url` with the operating system's default handler and returns once the launcher spawned. */ +export const openInBrowser: OpenBrowser = (url) => new Promise((resolvePromise, rejectPromise) => { + const [command, args] = process.platform === 'darwin' + ? ['open', [url]] + : process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', url]] + : ['xdg-open', [url]]; + const child = spawn(command, args, { detached: true, stdio: 'ignore' }); + child.once('error', rejectPromise); + child.once('spawn', () => { + child.unref(); + resolvePromise(); + }); +}); + +/** + * The host-side action callbacks behind an agent-bundle MCP App preview: + * display-mode requests are honored as asked, downloads open as a + * host-created opaque data URL, and external links open in the default + * browser. The Workbench MCP page and `agent-bundle serve-app` share this + * exact object so an App behaves the same under both hosts; consent for + * each action still flows through the preview service's consent authority. + */ +export const mcpAppPreviewHost = (openBrowser: OpenBrowser): Omit => Object.freeze({ + onDisplayMode: (mode) => mode, + onDownload: async (download) => { + // This is a host-created opaque data URL; App-controlled content is + // encoded before it crosses the browser-launch boundary. + await openBrowser(`data:application/json;charset=utf-8,${encodeURIComponent(JSON.stringify(download.contents))}`); + }, + onOpenLink: async (url) => { await openBrowser(url); }, +}); diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 7e2c5ca48..b0ab420bd 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { join, resolve } from 'node:path'; @@ -36,6 +35,7 @@ import { import { McpAppBindingService, type McpAppToolDefinition } from './mcp-apps/mcp-app-binding-service.ts'; import type { McpAppRoutePreviewService } from './mcp-apps/mcp-app-routes.ts'; import { McpAppPreviewService } from './mcp-apps/mcp-app-preview-service.ts'; +import { mcpAppPreviewHost, mcpAppPreviewHostInfo, openInBrowser, type OpenBrowser } from './mcp-apps/mcp-app-preview-host.ts'; import { McpAppRuntimeBindingService } from './mcp-app-runtime-binding-service.ts'; import { McpAppRuntimePreviewService } from './mcp-app-runtime-preview-service.ts'; import { @@ -78,7 +78,7 @@ export interface DevServerSession { readonly url: string; } -export type OpenBrowser = (url: string) => Promise | void; +export type { OpenBrowser } from './mcp-apps/mcp-app-preview-host.ts'; interface Closeable { close(): Promise; @@ -539,20 +539,6 @@ const withMcpSessionLifecycle = ( status, }); -const openInBrowser: OpenBrowser = (url) => new Promise((resolvePromise, rejectPromise) => { - const [command, args] = process.platform === 'darwin' - ? ['open', [url]] - : process.platform === 'win32' - ? ['cmd', ['/c', 'start', '', url]] - : ['xdg-open', [url]]; - const child = spawn(command, args, { detached: true, stdio: 'ignore' }); - child.once('error', rejectPromise); - child.once('spawn', () => { - child.unref(); - resolvePromise(); - }); -}); - /** Starts one loopback foreground session over the current project services. */ export const startDevServer = async (options: StartDevServerOptions): Promise => { const root = resolve(options.root); @@ -917,16 +903,8 @@ export const startDevServer = async (options: StartDevServerOptions): Promise mode, - onDownload: async (download) => { - // This is a host-created opaque data URL; App-controlled content is - // encoded before it crosses the browser-launch boundary. - await openBrowser(`data:application/json;charset=utf-8,${encodeURIComponent(JSON.stringify(download.contents))}`); - }, - onOpenLink: async (url) => { await openBrowser(url); }, - }, - hostInfo: { name: 'agent-bundle', version: '0.1.0' }, + host: mcpAppPreviewHost(openBrowser), + hostInfo: mcpAppPreviewHostInfo, hostOrigin: foreground.url, sandboxProxy: sandbox, toolAuthority: { diff --git a/packages/agent-bundle/src/serve-app/serve-app-page.ts b/packages/agent-bundle/src/serve-app/serve-app-page.ts new file mode 100644 index 000000000..b47ac3f8b --- /dev/null +++ b/packages/agent-bundle/src/serve-app/serve-app-page.ts @@ -0,0 +1,326 @@ +import type { McpAppJsonValue } from '../dev/mcp-apps/mcp-app-binding-service.ts'; +import type { McpAppConsentCapability } from '../dev/mcp-apps/mcp-app-consent.ts'; +import type { McpAppProfileId } from '../dev/mcp-app-profile-descriptors.ts'; + +/** + * Everything the standalone host document needs to bind its App: the bound + * session, the tool whose result the App opens with, and the per-launch + * credential the authenticated MCP App routes require. It is embedded in the + * document served at `/`, which only this process's loopback origin can read. + */ +export interface ServeAppPageSeed { + /** Consent capabilities the operator pre-approved when launching the host. */ + readonly autoApprove: readonly McpAppConsentCapability[]; + readonly input: McpAppJsonValue; + readonly previewProfile: McpAppProfileId; + readonly result: McpAppJsonValue; + readonly sessionId: string; + readonly title: string; + readonly token: string; + readonly toolName: string; +} + +/** The request header the host document presents on every authenticated route. */ +export const SERVE_APP_TOKEN_HEADER = 'x-agent-bundle-serve-app'; + +const escapeHtml = (value: string): string => + value.replace(/[&<>"']/gu, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] ?? character); + +/** JSON that is safe inside a ``, + ``, + '', + '', + '', +].join('\n'); diff --git a/packages/agent-bundle/src/serve-app/serve-mcp-app.ts b/packages/agent-bundle/src/serve-app/serve-mcp-app.ts new file mode 100644 index 000000000..ef4d1404f --- /dev/null +++ b/packages/agent-bundle/src/serve-app/serve-mcp-app.ts @@ -0,0 +1,615 @@ +import { Client, type Resource, type Tool } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { Context, Effect, Layer, type Scope } from 'effect'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { Socket } from 'node:net'; +import type { Stream } from 'node:stream'; + +import type { TargetRegistry } from '../adapters/registry.ts'; +import { isRecord } from '../core/strict-json.ts'; +import type { McpAppProfileId } from '../dev/mcp-app-profile-descriptors.ts'; +import { + McpAppBindingService, + selectMcpAppResourceUri, + type McpAppBridgeResource, + type McpAppBridgeSession, + type McpAppBridgeTool, + type McpAppJsonValue, + type McpAppSessionAuthority, + type McpAppSessionLease, + type McpAppToolDefinition, +} from '../dev/mcp-apps/mcp-app-binding-service.ts'; +import { MCP_APP_MIME_TYPE } from '../dev/mcp-apps/mcp-app-bridge.ts'; +import type { McpAppConsentCapability } from '../dev/mcp-apps/mcp-app-consent.ts'; +import { + mcpAppPreviewHost, + mcpAppPreviewHostInfo, + openInBrowser, + type OpenBrowser, +} from '../dev/mcp-apps/mcp-app-preview-host.ts'; +import { McpAppPreviewService } from '../dev/mcp-apps/mcp-app-preview-service.ts'; +import { McpAppRoutes } from '../dev/mcp-apps/mcp-app-routes.ts'; +import { createMcpAppSandboxProxy, type McpAppSandboxProxy } from '../dev/mcp-apps/mcp-app-sandbox.ts'; +import { + canonicalMcpAppJson, + canonicalMcpAppResource, + canonicalMcpAppTool, + mcpAppClientCapabilities, +} from '../dev/mcp-session/mcp-session-apps.ts'; +import { diagnostic, isRequestDiagnostic, requestError, responseDiagnostic, singleHeader } from '../dev/http.ts'; +import { makeScopedEffectRuntime } from '../effect/boundary.ts'; +import { liftPromise, liftTry } from '../effect/lift.ts'; +import { resolveMcpLaunchEnvironment, type McpLaunchEnvironmentOptions, type ResolvedMcpStdioLaunch } from '../services/mcp-run.ts'; +import { renderServeAppPage, SERVE_APP_TOKEN_HEADER } from './serve-app-page.ts'; + +/** + * `agent-bundle serve-app`: one built MCP App, served standalone in a browser + * over a bound session to the plugin's own packed MCP server. + * + * This is the Workbench's MCP App preview stack without the Workbench: + * the same `McpAppBindingService` → `McpAppPreviewService` → `McpAppRoutes` + * chain hosts the App over `/api/mcp/...`, the same loopback sandbox proxy + * (`createMcpAppSandboxProxy`) isolates the App document on its own origin, + * and the same `McpAppBridge` enforces the MCP Apps protocol, consent, and + * resource policy. Only two things are specific to this module: the session + * authority is one stdio connection to the packed server (launched exactly + * as `mcp run` launches it), and the host document is a small page whose + * inline relay mirrors the Workbench's `McpAppFrameRelay` over those routes. + * + * Every resource is `acquireRelease`d into one Effect scope owned by a + * `makeScopedEffectRuntime`; `close()` finalizes that scope once, newest + * resource first: routes, preview bindings, sandbox proxy, HTTP server, MCP + * session. + */ + +export type { McpAppConsentCapability } from '../dev/mcp-apps/mcp-app-consent.ts'; + +export interface ServeMcpAppOptions extends Omit { + /** The MCP App to serve: `/`, or a full `ui://` resource URI. */ + readonly app: string; + /** + * A built artifact root, or an Effect that acquires one into the served + * App's scope (a throwaway build removed on `close()`). + */ + readonly artifact: string | Effect.Effect; + /** + * Consent capabilities approved on the operator's behalf as the App + * requests them; everything else waits for a decision in the host page, + * exactly as in the Workbench. + */ + readonly autoApprove?: readonly McpAppConsentCapability[]; + /** Arguments for the opening tool call; defaults to `{}`. */ + readonly input?: Readonly>; + /** Open the default browser on the served URL once the host is listening. */ + readonly open?: boolean; + /** Injectable only to keep browser launching deterministic in tests. */ + readonly openBrowser?: OpenBrowser; + /** Loopback TCP port for the host document; `0` (default) picks an ephemeral one. */ + readonly port?: number; + /** The simulated MCP Apps host profile; defaults to `portable`. */ + readonly profile?: McpAppProfileId; + readonly registry?: TargetRegistry; + /** Per-request timeout for the bound session, in milliseconds. */ + readonly timeoutMs?: number; + /** + * The tool whose result the App opens with. Defaults to the only tool that + * declares the App's `_meta.ui.resourceUri`; required when several do. + */ + readonly tool?: string; +} + +export interface ServedMcpApp { + /** The App's canonical `ui://` resource URI. */ + readonly resourceUri: string; + /** Loopback origin of the sandbox proxy the App document runs on. */ + readonly sandboxOrigin: string; + /** The generated MCP server the App is bound to. */ + readonly server: string; + /** The tool whose call opened the App. */ + readonly tool: string; + /** The host document URL. */ + readonly url: string; + /** Settles once the bound MCP server connection has ended, whether by `close()` or on its own. */ + readonly closed: Promise; + close(): Promise; +} + +const defaultTimeoutMs = 30_000; +const maxStderrBytes = 64 * 1024; +const closeTimeoutMs = 1_000; + +interface StandaloneSession { + readonly bridge: McpAppBridgeSession; + readonly client: Client; + readonly closed: Promise; + readonly sessionId: string; + readonly stderr: () => string; + close(): Promise; + listResources(): Promise; + listTools(): Promise; + watchClosed(listener: () => void): () => void; +} + +interface AppSelection { + readonly input: Readonly>; + readonly result: McpAppJsonValue; + readonly resourceUri: string; + readonly server: string; + readonly tool: McpAppToolDefinition; +} + +interface ServedMcpAppShape { + readonly closed: Promise; + readonly resourceUri: string; + readonly sandboxOrigin: string; + readonly server: string; + readonly tool: string; + readonly url: string; +} + +class ServedMcpAppService extends Context.Service()( + 'agent-bundle/serve-app/ServedMcpAppService', +) {} + +const loopbackHosts: ReadonlySet = new Set(['127.0.0.1', 'localhost', '[::1]']); + +const requireJsonObject = (value: unknown, label: string): Readonly> => { + const snapshot = canonicalMcpAppJson(value, label); + if (!isRecord(snapshot)) throw new TypeError(`${label} must be a JSON object.`); + return snapshot as Readonly>; +}; + +export interface ServeAppSelector { + readonly name?: string; + readonly resourceUri?: string; + readonly server: string; +} + +/** Splits `/` or `/ui://...` into its server and App parts, rejecting anything else. */ +export const parseServeAppSelector = (value: string): ServeAppSelector => { + const trimmed = value.trim(); + if (trimmed.length === 0) throw new Error('MCP App must be named as / or a ui:// resource URI.'); + const separator = trimmed.indexOf('/'); + if (separator < 1 || separator === trimmed.length - 1) { + throw new Error(`MCP App ${JSON.stringify(value)} must be named as / or /ui://... .`); + } + const server = trimmed.slice(0, separator); + const rest = trimmed.slice(separator + 1); + if (rest.startsWith('ui://')) return Object.freeze({ resourceUri: rest, server }); + if (rest.includes('/')) throw new Error(`MCP App name ${JSON.stringify(rest)} must not contain a slash.`); + return Object.freeze({ name: rest, server }); +}; + +const appNameOf = (resourceUri: string): string | undefined => { + try { + const parsed = new URL(resourceUri); + if (parsed.protocol !== 'ui:') return undefined; + const segment = parsed.pathname.split('/').filter((part) => part.length > 0).at(-1); + return segment === undefined ? undefined : segment.replace(/\.html?$/iu, ''); + } catch { + return undefined; + } +}; + +const captureStderr = (stream: Stream | null): (() => string) => { + if (stream === null) return () => ''; + let captured = ''; + stream.on('data', (chunk: unknown) => { + if (captured.length >= maxStderrBytes) return; + captured = `${captured}${String(chunk)}`.slice(0, maxStderrBytes); + }); + return () => captured; +}; + +const openSession = async ( + launch: ResolvedMcpStdioLaunch, + identity: Readonly<{ readonly serverName: string; readonly target: string }>, + timeoutMs: number, +): Promise => { + const client = new Client({ name: mcpAppPreviewHostInfo.name, version: mcpAppPreviewHostInfo.version }, { + capabilities: mcpAppClientCapabilities, + }); + const transport = new StdioClientTransport({ + args: [...launch.args], + command: launch.command, + cwd: launch.cwd, + env: { ...launch.env }, + stderr: 'pipe', + }); + const stderr = captureStderr(transport.stderr); + const closedGate = Promise.withResolvers(); + const listeners = new Set<() => void>(); + let closed = false; + const markClosed = (): void => { + if (closed) return; + closed = true; + closedGate.resolve(); + for (const listener of listeners) { + try { + listener(); + } catch { + // A close watcher must never disrupt teardown. + } + } + listeners.clear(); + }; + transport.onclose = markClosed; + try { + await client.connect(transport, { timeout: timeoutMs }); + } catch (error) { + markClosed(); + const output = stderr(); + throw new Error( + `The packed MCP server did not start: ${error instanceof Error ? error.message : String(error)}` + + `${output.length === 0 ? '' : `\nserver stderr:\n${output}`}`, + { cause: error }, + ); + } + // The transport's own onclose is installed by the SDK client on connect; + // chain ours behind it so an unexpected server exit still settles `closed`. + const sdkOnClose = transport.onclose; + transport.onclose = () => { + try { + sdkOnClose?.(); + } finally { + markClosed(); + } + }; + const assertActive = (): void => { + if (closed) throw new Error('The bound MCP server connection is closed.'); + }; + const requestOptions = Object.freeze({ timeout: timeoutMs }); + let bridgeTools: Promise | undefined; + let bridgeResources: Promise | undefined; + const listTools = async (): Promise => Object.freeze([...(await client.listTools(undefined, requestOptions)).tools]); + const listResources = async (): Promise => Object.freeze([...(await client.listResources(undefined, requestOptions)).resources]); + const sessionId = randomUUID(); + const bridge: McpAppBridgeSession = Object.freeze({ + callTool: async ({ arguments: toolArguments, name }: { readonly arguments: McpAppJsonValue | undefined; readonly name: string }) => { + assertActive(); + const argumentsSnapshot = requireJsonObject(toolArguments ?? {}, 'MCP App tool arguments'); + const result = await client.callTool({ arguments: { ...argumentsSnapshot }, name }, requestOptions); + assertActive(); + return canonicalMcpAppJson(result, 'MCP App tool result'); + }, + identity: Object.freeze({ epochId: `serve-app:${sessionId}`, serverName: identity.serverName, sessionId, target: identity.target }), + listBridgeResources: async () => { + assertActive(); + bridgeResources ??= listResources().then((resources) => Object.freeze(resources.map(canonicalMcpAppResource))); + const resources = await bridgeResources; + assertActive(); + return resources; + }, + listBridgeTools: async () => { + assertActive(); + bridgeTools ??= listTools().then((tools) => Object.freeze(tools.map(canonicalMcpAppTool))); + const tools = await bridgeTools; + assertActive(); + return tools; + }, + readResource: async ({ uri }: { readonly uri: string }) => { + assertActive(); + const result = await client.readResource({ uri }, requestOptions); + assertActive(); + return canonicalMcpAppJson(result, 'MCP App resource result'); + }, + }); + let closing: Promise | undefined; + return Object.freeze({ + bridge, + client, + close: () => { + closing ??= client.close().catch(() => undefined).then(markClosed); + return closing; + }, + closed: closedGate.promise, + listResources, + listTools, + sessionId, + stderr, + watchClosed: (listener: () => void) => { + if (closed) { + listener(); + return () => undefined; + } + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + }); +}; + +/** + * Resolves the App and its opening tool against the live server, then calls + * the tool once so the App opens populated — the same input/result pair the + * Workbench binds when it previews a tool run. + */ +const selectApp = async (session: StandaloneSession, options: ServeMcpAppOptions): Promise => { + const requested = parseServeAppSelector(options.app); + const [tools, resources] = await Promise.all([session.listTools(), session.listResources()]); + const appResources = resources.filter((resource) => resource.mimeType === MCP_APP_MIME_TYPE); + const matching = appResources.filter((resource) => requested.resourceUri === undefined + ? appNameOf(resource.uri) === requested.name + : resource.uri === requested.resourceUri); + const available = appResources.map((resource) => `${requested.server}/${appNameOf(resource.uri) ?? resource.uri}`); + if (matching.length === 0) { + throw new Error( + `MCP server ${JSON.stringify(requested.server)} serves no MCP App ${JSON.stringify(requested.name ?? requested.resourceUri)}` + + `${available.length === 0 ? ' (it serves no MCP App resources).' : `; available: ${available.join(', ')}.`}`, + ); + } + if (matching.length > 1) { + throw new Error( + `MCP App ${JSON.stringify(requested.name)} names ${String(matching.length)} resources on server ${JSON.stringify(requested.server)}; ` + + `use ${requested.server}/ to select one of: ${matching.map((resource) => resource.uri).join(', ')}.`, + ); + } + const resourceUri = matching[0]!.uri; + const appTools = tools.filter((tool) => { + const definition = canonicalMcpAppTool(tool).definition; + return selectMcpAppResourceUri(definition) === resourceUri; + }); + const selectedTool = options.tool === undefined + ? appTools.length === 1 ? appTools[0] : undefined + : appTools.find((tool) => tool.name === options.tool); + if (selectedTool === undefined) { + if (options.tool !== undefined) { + throw new Error( + `Tool ${JSON.stringify(options.tool)} does not open MCP App ${resourceUri}` + + `${appTools.length === 0 ? '.' : `; tools that do: ${appTools.map((tool) => tool.name).join(', ')}.`}`, + ); + } + throw new Error(appTools.length === 0 + ? `No tool on server ${JSON.stringify(requested.server)} declares _meta.ui.resourceUri ${resourceUri}.` + : `Several tools open MCP App ${resourceUri} (${appTools.map((tool) => tool.name).join(', ')}); choose one with --tool.`); + } + const definition = canonicalMcpAppTool(selectedTool).definition; + const input = requireJsonObject(options.input ?? {}, 'MCP App tool input'); + const result = await session.bridge.callTool({ arguments: input, name: definition.name }); + return Object.freeze({ input, resourceUri, result, server: requested.server, tool: definition }); +}; + +/** The one bound session, leased to every App binding the host page creates. */ +const sessionAuthorityFor = (session: StandaloneSession): McpAppSessionAuthority => Object.freeze({ + acquireAppLease: async (sessionId: string): Promise => { + if (sessionId !== session.sessionId) throw new Error(`Unknown MCP App session ${JSON.stringify(sessionId)}.`); + return Object.freeze({ + release: async () => undefined, + session: session.bridge, + watchSessionClosed: (listener: (reason?: unknown) => Promise | void) => { + let closedNow = false; + const unsubscribe = session.watchClosed(() => { + closedNow = true; + void listener(); + }); + return Object.freeze({ closed: closedNow, unsubscribe }); + }, + }); + }, +}); + +const listen = async (server: Server, port: number): Promise => new Promise((resolvePort, reject) => { + server.once('error', reject); + server.listen({ host: '127.0.0.1', port }, () => { + server.off('error', reject); + const address = server.address(); + if (address === null || typeof address === 'string') { + reject(new Error('The MCP App host did not receive a TCP address.')); + return; + } + resolvePort(address.port); + }); +}); + +const closeServer = async (server: Server, sockets: ReadonlySet): Promise => new Promise((resolveClose, reject) => { + const deadline = setTimeout(() => { + for (const socket of sockets) socket.destroy(); + }, closeTimeoutMs); + server.close((error) => { + clearTimeout(deadline); + if (error !== undefined && (error as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING') reject(error); + else resolveClose(); + }); + for (const socket of sockets) socket.destroy(); +}); + +const validPort = (value: number | undefined): number => { + const port = value ?? 0; + if (!Number.isSafeInteger(port) || port < 0 || port > 65_535) throw new RangeError('MCP App host port must be a TCP port number.'); + return port; +}; + +const validProfile = (value: McpAppProfileId | undefined): McpAppProfileId => { + const profile = value ?? 'portable'; + if (profile !== 'portable' && profile !== 'claude' && profile !== 'chatgpt') { + throw new RangeError(`Unsupported MCP App profile ${JSON.stringify(String(profile))}.`); + } + return profile; +}; + +const requestOriginIsHost = (request: IncomingMessage, url: string): boolean => { + const origin = singleHeader(request.headers.origin); + if (origin !== undefined) return origin === url; + return singleHeader(request.headers['sec-fetch-site']) === 'same-origin'; +}; + +const hostHeaderIsLoopback = (request: IncomingMessage, port: number): boolean => { + const host = singleHeader(request.headers.host); + if (host === undefined) return false; + const separator = host.lastIndexOf(':'); + if (separator === -1) return false; + return loopbackHosts.has(host.slice(0, separator)) && host.slice(separator + 1) === String(port); +}; + +const serveProgram = (options: ServeMcpAppOptions): Effect.Effect => Effect.gen(function* () { + const port = yield* liftTry(() => validPort(options.port)); + const profile = yield* liftTry(() => validProfile(options.profile)); + const autoApprove = Object.freeze([...(options.autoApprove ?? [])]); + const timeoutMs = options.timeoutMs ?? defaultTimeoutMs; + const requestedApp = yield* liftTry(() => parseServeAppSelector(options.app)); + const artifact = typeof options.artifact === 'string' ? options.artifact : yield* options.artifact; + const launch = yield* liftPromise(() => resolveMcpLaunchEnvironment({ + artifact, + ...(options.envFiles === undefined ? {} : { envFiles: options.envFiles }), + ...(options.envPluginRoot === undefined ? {} : { envPluginRoot: options.envPluginRoot }), + ...(options.loadEnvFiles === undefined ? {} : { loadEnvFiles: options.loadEnvFiles }), + ...(options.mode === undefined ? {} : { mode: options.mode }), + pluginDataRoot: options.pluginDataRoot, + ...(options.registry === undefined ? {} : { registry: options.registry }), + server: requestedApp.server, + target: options.target, + workspaceRoot: options.workspaceRoot, + })); + const session = yield* Effect.acquireRelease( + liftPromise(() => openSession(launch, { serverName: requestedApp.server, target: options.target }, timeoutMs)), + (opened) => Effect.promise(() => opened.close()), + ); + const selection = yield* liftPromise(() => selectApp(session, options)); + + const token = randomBytes(32).toString('base64url'); + const sockets = new Set(); + // The listener is installed after the routes exist; a request racing the + // wiring is refused rather than served without authorization. + const dispatch: { current?: (request: IncomingMessage, response: ServerResponse) => Promise } = {}; + const server = createServer((request, response) => { + const handler = dispatch.current; + if (handler === undefined) { + responseDiagnostic(response, diagnostic('AB8022', 'MCP App host is not ready.', 503)); + return; + } + void handler(request, response).catch((error: unknown) => { + if (isRequestDiagnostic(error)) { + responseDiagnostic(response, error); + return; + } + responseDiagnostic(response, diagnostic('AB8023', 'MCP App operation could not be completed.', 502)); + }); + }); + server.on('connection', (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + const boundPort = yield* Effect.acquireRelease( + liftPromise(() => listen(server, port)), + () => Effect.promise(() => closeServer(server, sockets).catch(() => undefined)), + ); + const url = `http://127.0.0.1:${String(boundPort)}`; + const sandbox: McpAppSandboxProxy = yield* Effect.acquireRelease( + liftPromise(() => createMcpAppSandboxProxy({ hostOrigin: url })), + (proxy) => Effect.promise(() => proxy.close().catch(() => undefined)), + ); + const openBrowser = options.openBrowser ?? openInBrowser; + const bindings = new McpAppBindingService({ sessionAuthority: sessionAuthorityFor(session) }); + const previews = yield* Effect.acquireRelease( + Effect.sync(() => new McpAppPreviewService({ + bindingAuthority: bindings, + host: mcpAppPreviewHost(openBrowser), + hostInfo: mcpAppPreviewHostInfo, + hostOrigin: url, + sandboxProxy: sandbox, + toolAuthority: { + resolveTool: async (sessionId, toolName): Promise => { + if (sessionId !== session.sessionId || toolName !== selection.tool.name) { + throw new Error(`Unknown MCP App tool ${JSON.stringify(toolName)}.`); + } + return selection.tool; + }, + }, + })), + (service) => Effect.promise(() => service.closeAll().catch(() => undefined)), + ); + const authorize = (request: IncomingMessage): void => { + if (!hostHeaderIsLoopback(request, boundPort) || !requestOriginIsHost(request, url)) { + throw requestError(diagnostic('AB8003', 'Request origin is not this MCP App host.', 403)); + } + if (singleHeader(request.headers[SERVE_APP_TOKEN_HEADER]) !== token) { + throw requestError(diagnostic('AB8004', 'A valid MCP App host token is required.', 403)); + } + }; + const routes = yield* Effect.acquireRelease( + Effect.sync(() => new McpAppRoutes({ authorize, service: previews })), + (created) => Effect.sync(() => { created.close(); }), + ); + const page = renderServeAppPage({ + autoApprove, + input: selection.input, + previewProfile: profile, + result: selection.result, + sessionId: session.sessionId, + title: `${selection.server}/${appNameOf(selection.resourceUri) ?? selection.resourceUri}`, + token, + toolName: selection.tool.name, + }); + const contentSecurityPolicy = [ + "default-src 'none'", + "base-uri 'none'", + "connect-src 'self'", + "form-action 'none'", + `frame-src ${sandbox.origin}`, + "script-src 'unsafe-inline'", + "style-src 'unsafe-inline'", + ].join('; '); + dispatch.current = async (request, response) => { + if (!hostHeaderIsLoopback(request, boundPort)) { + throw requestError(diagnostic('AB8003', 'Request origin is not this MCP App host.', 403)); + } + if (await routes.handle(request, response)) return; + const pathname = new URL(request.url ?? '/', url).pathname; + if (pathname !== '/' && pathname !== '/index.html') { + responseDiagnostic(response, diagnostic('AB8020', 'Not found.', 404)); + return; + } + if (request.method !== 'GET' && request.method !== 'HEAD') { + responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); + return; + } + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-security-policy': contentSecurityPolicy, + 'content-type': 'text/html; charset=utf-8', + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', + }); + response.end(request.method === 'HEAD' ? undefined : page); + }; + if (options.open === true) yield* liftPromise(() => Promise.resolve(openBrowser(`${url}/`))); + return Object.freeze({ + closed: session.closed, + resourceUri: selection.resourceUri, + sandboxOrigin: sandbox.origin, + server: selection.server, + tool: selection.tool.name, + url: `${url}/`, + }); +}); + +/** + * Serves one built MCP App standalone: launches the plugin's packed MCP + * server exactly as `agent-bundle mcp run` would, binds the App to it + * through the Workbench's MCP App host stack, and returns the loopback URL of + * a page that renders the App. `close()` tears everything down, the server + * process included. + */ +export const serveMcpApp = async (options: ServeMcpAppOptions): Promise => { + const runtime = makeScopedEffectRuntime(Layer.effect(ServedMcpAppService, serveProgram(options))); + let service: ServedMcpAppShape; + try { + service = await runtime.run(ServedMcpAppService); + } catch (error) { + await runtime.close().catch(() => undefined); + throw error; + } + let closing: Promise | undefined; + return Object.freeze({ + close: () => { + closing ??= runtime.close(); + return closing; + }, + closed: service.closed, + resourceUri: service.resourceUri, + sandboxOrigin: service.sandboxOrigin, + server: service.server, + tool: service.tool, + url: service.url, + }); +}; diff --git a/packages/agent-bundle/src/services/mcp-run.ts b/packages/agent-bundle/src/services/mcp-run.ts index aea0a87da..85df425e9 100644 --- a/packages/agent-bundle/src/services/mcp-run.ts +++ b/packages/agent-bundle/src/services/mcp-run.ts @@ -140,7 +140,7 @@ export const resolveMcpStdioLaunch = async ( }); }; -export interface RunMcpForegroundOptions extends ResolveMcpStdioLaunchOptions { +export interface McpLaunchEnvironmentOptions extends ResolveMcpStdioLaunchOptions { /** * Explicit `.env` files replacing the conventional workspace-root set. * Files use Node's `--env-file` dialect and load in order, later files @@ -152,6 +152,9 @@ export interface RunMcpForegroundOptions extends ResolveMcpStdioLaunchOptions { readonly loadEnvFiles?: boolean; /** Configuration mode selecting `.env.` variants of the conventional set. */ readonly mode?: string; +} + +export interface RunMcpForegroundOptions extends McpLaunchEnvironmentOptions { /** Injectable only to make foreground process behavior deterministic in tests. */ readonly spawnProcess?: ( command: string, @@ -169,7 +172,7 @@ export interface RunMcpForegroundOptions extends ResolveMcpStdioLaunchOptions { * still seeds `${VAR}` interpolation inside env-file values. */ const loadLaunchFileEnv = async ( - options: RunMcpForegroundOptions, + options: McpLaunchEnvironmentOptions, processEnv: Readonly>, ): Promise> => { if (options.loadEnvFiles === false) return {}; @@ -195,26 +198,43 @@ const loadLaunchFileEnv = async ( }; /** - * Resolves the server's generated entry from the built artifact and runs it - * in the foreground with inherited stdio. SIGINT/SIGTERM forward to the - * child; the child's exit code (or 128 + signal number) is returned. - * - * Launch environment precedence, lowest to highest: manifest env (declared - * entries plus the injected plugin-root anchor, path tokens expanded), the - * `.env` file layer, then the operator's real `process.env` — an exported - * variable always beats every file- or manifest-declared value. + * The complete launch of one stdio server out of a built artifact: the + * resolved command plus the layered environment `mcp run` and `serve-app` + * share. Precedence, lowest to highest: manifest env (declared entries plus + * the injected plugin-root anchor, path tokens expanded), the `.env` file + * layer, then the operator's real `process.env` — an exported variable + * always beats every file- or manifest-declared value. The plugin-data root + * is created so the server's durable-state anchor exists before it starts. */ -export const runMcpForeground = async (options: RunMcpForegroundOptions): Promise => { +export const resolveMcpLaunchEnvironment = async ( + options: McpLaunchEnvironmentOptions, +): Promise => { const launch = await resolveMcpStdioLaunch(options); await mkdir(resolve(options.pluginDataRoot), { recursive: true }); const inheritedEnv = Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), ); const fileEnv = await loadLaunchFileEnv(options, inheritedEnv); + return Object.freeze({ + args: launch.args, + command: launch.command, + cwd: launch.cwd, + env: Object.freeze({ ...launch.env, ...fileEnv, ...inheritedEnv }), + }); +}; + +/** + * Resolves the server's generated entry from the built artifact and runs it + * in the foreground with inherited stdio. SIGINT/SIGTERM forward to the + * child; the child's exit code (or 128 + signal number) is returned. The + * launch environment is {@link resolveMcpLaunchEnvironment}'s. + */ +export const runMcpForeground = async (options: RunMcpForegroundOptions): Promise => { + const launch = await resolveMcpLaunchEnvironment(options); const spawnProcess = options.spawnProcess ?? ((command, args, spawnOptions) => spawn(command, [...args], spawnOptions)); const child = spawnProcess(launch.command, launch.args, { cwd: launch.cwd, - env: { ...launch.env, ...fileEnv, ...inheritedEnv }, + env: { ...launch.env }, stdio: 'inherit', }); diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 81e6ee211..a21647303 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -833,3 +833,131 @@ it('dispatches the install command through the native installer surface', async state: 'installed', }); }); + +it('maps serve-app argv onto serveApp, prints the served URL, and closes the host once on a termination signal', async () => { + const calls: unknown[] = []; + const handlers = new Map void>(); + const removed: NodeJS.Signals[] = []; + let closeCalls = 0; + const closedGate = Promise.withResolvers(); + const result = await runSourceCliWithOutput([ + 'serve-app', 'hauler/dashboard', + '--root', '/project', '--artifact', 'artifact', '--target', 'claude', + '--tool', 'hauler_status', '--input', '{"scope":"all"}', '--port', '4941', '--profile', 'claude', + '--allow', 'call-tool', '--allow', 'open-external-link', '--open', '--env-file', '.env.dashboard', '--plugin-root', '/state', + ], { + serveApp: async (options) => { + calls.push(options); + return { + close: async () => { + closeCalls += 1; + closedGate.resolve(); + }, + closed: closedGate.promise, + resourceUri: 'ui://cargo-hauler/dashboard.html', + sandboxOrigin: 'http://127.0.0.1:4942', + server: 'hauler', + tool: 'hauler_status', + url: 'http://127.0.0.1:4941/', + }; + }, + signals: { + once: (signal, listener) => { handlers.set(signal, listener); }, + removeListener: (signal) => { removed.push(signal); }, + }, + }); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toBe('MCP App hauler/dashboard at http://127.0.0.1:4941/ (tool hauler_status; Ctrl-C stops the server)\n'); + expect(calls).toEqual([{ + app: 'hauler/dashboard', + artifact: 'artifact', + autoApprove: ['call-tool', 'open-external-link'], + envFiles: ['.env.dashboard'], + input: { scope: 'all' }, + mode: 'production', + open: true, + pluginRoot: '/state', + port: 4941, + profile: 'claude', + root: '/project', + target: 'claude', + tool: 'hauler_status', + }]); + + handlers.get('SIGINT')?.(); + handlers.get('SIGTERM')?.(); + await closedGate.promise; + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + expect(closeCalls).toBe(1); + expect(removed).toEqual(expect.arrayContaining(['SIGINT', 'SIGTERM'])); +}); + +it('reports the bound server exiting on its own as one diagnostic and releases the serve-app signal listeners', async () => { + const handlers = new Map void>(); + const removed: NodeJS.Signals[] = []; + let closeCalls = 0; + const serverExit = Promise.withResolvers(); + const terminal = captureCliTerminal(); + Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); + const code = await runSourceCli(['serve-app', 'status/status', '--root', '/project', '--no-open'], terminal.output, { + serveApp: async () => ({ + close: async () => { closeCalls += 1; }, + closed: serverExit.promise, + resourceUri: 'ui://mcp-app-example/status.html', + sandboxOrigin: 'http://127.0.0.1:4102', + server: 'status', + tool: 'show-status', + url: 'http://127.0.0.1:4101/', + }), + signals: { + once: (signal, listener) => { handlers.set(signal, listener); }, + removeListener: (signal) => { removed.push(signal); }, + }, + }); + expect(code).toBe(0); + expect(handlers.size).toBe(2); + + serverExit.resolve(); + for (let attempt = 0; attempt < 20 && closeCalls === 0; attempt += 1) { + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + } + + expect(closeCalls).toBe(1); + expect(removed).toEqual(expect.arrayContaining(['SIGINT', 'SIGTERM'])); + expect(JSON.parse(terminal.stderr())).toEqual([{ + code: 'AB5000', + message: 'The MCP server behind status/status exited; the MCP App host closed.', + severity: 'error', + }]); +}); + +it('rejects serve-app argv that cannot be served before anything launches', async () => { + const launched: unknown[] = []; + const dependencies: CliDependencies = { + serveApp: async (options) => { + launched.push(options); + throw new Error('unreachable'); + }, + }; + const missingApp = await runSourceCliWithOutput(['serve-app', '--root', '/project'], dependencies); + expect(missingApp.code).toBe(2); + expect(missingApp.stderr).toContain("missing required argument 'app'"); + const badInput = await runSourceCliWithOutput(['serve-app', 'status/status', '--root', '/project', '--input', '[1]'], dependencies); + expect(badInput.code).toBe(1); + expect(JSON.parse(badInput.stderr)).toEqual([{ code: 'AB5000', message: 'Input must be a JSON object.', severity: 'error' }]); + const bothEnv = await runSourceCliWithOutput(['serve-app', 'status/status', '--root', '/project', '--no-env', '--env-file', '.env'], dependencies); + expect(bothEnv.code).toBe(1); + expect(JSON.parse(bothEnv.stderr)).toEqual([{ code: 'AB5000', message: 'Use either --env-file or --no-env, not both.', severity: 'error' }]); + const badProfile = await runSourceCliWithOutput(['serve-app', 'status/status', '--root', '/project', '--profile', 'cursor'], dependencies); + expect(badProfile.code).toBe(2); + expect(badProfile.stderr).toContain('MCP App profile must be portable, claude, or chatgpt.'); + const badCapability = await runSourceCliWithOutput(['serve-app', 'status/status', '--root', '/project', '--allow', 'camera'], dependencies); + expect(badCapability.code).toBe(2); + expect(badCapability.stderr).toContain('Consent capability must be call-tool, download-file, open-external-link, or request-display-mode.'); + const badPort = await runSourceCliWithOutput(['serve-app', 'status/status', '--root', '/project', '--port', '70000'], dependencies); + expect(badPort.code).toBe(1); + expect(JSON.parse(badPort.stderr)).toEqual([{ code: 'AB5000', message: 'Port must be a TCP port number.', severity: 'error' }]); + expect(launched).toEqual([]); +}); diff --git a/packages/agent-bundle/tests/serve-app.test.ts b/packages/agent-bundle/tests/serve-app.test.ts new file mode 100644 index 000000000..ec0e34f62 --- /dev/null +++ b/packages/agent-bundle/tests/serve-app.test.ts @@ -0,0 +1,279 @@ +import { cp, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { request as httpRequest } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, expect, it } from '@rstest/core'; + +import { build, serveApp } from '../src/api.ts'; +import { MCP_APP_PROTOCOL_VERSION } from '../src/dev/mcp-apps/mcp-app-bridge.ts'; +import { SERVE_APP_TOKEN_HEADER } from '../src/serve-app/serve-app-page.ts'; +import { timeScale } from './support/time-scale.ts'; + +/** + * `agent-bundle serve-app` end to end over a real packed server: build the + * public MCP App example, serve its App, and drive the host document's + * protocol by hand — the same `/api/mcp/...` routes the Workbench relay + * uses — from the initialize handshake through a consented `tools/call`, + * then close and verify every listener is gone. + */ + +const sourceExampleRoot = join(process.cwd(), 'examples', 'mcp-app'); + +interface JsonRpc { + readonly error?: { readonly code: number; readonly message: string }; + readonly id?: string | number | null; + readonly jsonrpc: '2.0'; + readonly method?: string; + readonly params?: unknown; + readonly result?: unknown; +} + +interface Seed { + readonly autoApprove: readonly string[]; + readonly input: unknown; + readonly previewProfile: string; + readonly result: unknown; + readonly sessionId: string; + readonly title: string; + readonly token: string; + readonly toolName: string; +} + +const seedOf = (html: string): Seed => { + const match = /