From dc360644219eb83ddaff5563f2d3590408d670c7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:49:36 +0000 Subject: [PATCH 01/22] web-host: extract the shared host core from serve-app (#564, lane 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the standalone MCP App host out of serve-app/serve-mcp-app.ts into plain-Node modules under src/web-host/ that both `agent-bundle serve-app` and the generated ` web` command (later lanes) run: - select-app.ts: parseAppSelector (was parseServeAppSelector), appNameOf, openApp over an AppSelectionSource, requireJsonObject. - session.ts: openStdioAppSession / sessionAuthorityFor; the session now carries a `selection` adapter over the SDK client. - page.ts: renderWebHostPage({ script, seed }), WEB_HOST_TOKEN_HEADER (x-agent-bundle-web-host), webHostContentSecurityPolicy; the HTML shell, style, and escaping move here from serve-app-page.ts, the inline relay script is replaced by the caller-supplied built page script, and the seed element is #agent-bundle-web-host-seed. - page-script.ts: readWebHostPageScript() reads dist/web-host/page.js once. - host-server.ts: startWebHost / validPort / validProfile — the loopback HTTP host as plain async acquire/release, closing newest-first. serve-mcp-app.ts keeps its public surface and becomes the framework-side orchestration (validate, artifact, launch env, session, selection, host) inside the existing Effect scope; serve-app-page.ts is deleted and api.ts switches to parseAppSelector. page.ts imports WEB_HOST_SEED_ELEMENT_ID / WebHostPageSeed from ./browser/seed.ts, which Lane 2 owns and provides; no stub is committed. The framework page script (dist/web-host/page.js) is also Lane 2's, so tests/serve-app.test.ts needs Lane 2's build step to run green. Tests: web-host-page.test.ts, web-host-select-app.test.ts (unit); serve-app.test.ts updated for the new seed id and token header. --- packages/agent-bundle/src/api.ts | 5 +- .../src/serve-app/serve-app-page.ts | 328 ----------- .../src/serve-app/serve-mcp-app.ts | 556 ++---------------- .../agent-bundle/src/web-host/host-server.ts | 285 +++++++++ .../agent-bundle/src/web-host/page-script.ts | 40 ++ packages/agent-bundle/src/web-host/page.ts | 107 ++++ .../agent-bundle/src/web-host/select-app.ts | 144 +++++ packages/agent-bundle/src/web-host/session.ts | 219 +++++++ packages/agent-bundle/tests/serve-app.test.ts | 12 +- .../agent-bundle/tests/web-host-page.test.ts | 104 ++++ .../tests/web-host-select-app.test.ts | 198 +++++++ 11 files changed, 1154 insertions(+), 844 deletions(-) delete mode 100644 packages/agent-bundle/src/serve-app/serve-app-page.ts create mode 100644 packages/agent-bundle/src/web-host/host-server.ts create mode 100644 packages/agent-bundle/src/web-host/page-script.ts create mode 100644 packages/agent-bundle/src/web-host/page.ts create mode 100644 packages/agent-bundle/src/web-host/select-app.ts create mode 100644 packages/agent-bundle/src/web-host/session.ts create mode 100644 packages/agent-bundle/tests/web-host-page.test.ts create mode 100644 packages/agent-bundle/tests/web-host-select-app.test.ts diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index cf4234235..5f832a885 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -36,8 +36,9 @@ 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 { serveMcpApp } from './serve-app/serve-mcp-app.ts'; import type { ServedMcpApp, ServeMcpAppPublicOptions } from './serve-app/types.ts'; +import { parseAppSelector } from './web-host/select-app.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'; @@ -1508,7 +1509,7 @@ 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); + const { server } = parseAppSelector(options.app); return serveMcpApp({ app: options.app, artifact: options.artifact === undefined ? scopedThrowawayArtifact({ ...options, registry }) : resolve(options.artifact), diff --git a/packages/agent-bundle/src/serve-app/serve-app-page.ts b/packages/agent-bundle/src/serve-app/serve-app-page.ts deleted file mode 100644 index 49ad360cc..000000000 --- a/packages/agent-bundle/src/serve-app/serve-app-page.ts +++ /dev/null @@ -1,328 +0,0 @@ -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 index 8f6a5f65c..de9bfcc95 100644 --- a/packages/agent-bundle/src/serve-app/serve-mcp-app.ts +++ b/packages/agent-bundle/src/serve-app/serve-mcp-app.ts @@ -1,65 +1,28 @@ -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 type { ServedMcpApp, ServeMcpAppPublicOptions } from './types.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 { - mcpAppPreviewHost, - mcpAppPreviewHostInfo, - openInBrowser, -} 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'; +import { resolveMcpLaunchEnvironment, type McpLaunchEnvironmentOptions } from '../services/mcp-run.ts'; +import { startWebHost, validPort, validProfile, type WebHost } from '../web-host/host-server.ts'; +import { readWebHostPageScript } from '../web-host/page-script.ts'; +import { appNameOf, openApp, parseAppSelector } from '../web-host/select-app.ts'; +import { openStdioAppSession } from '../web-host/session.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. + * The host itself is the framework's shared web host (`web-host/*`): the same + * stdio session, App selection, loopback HTTP host, and page a generated + * plugin's ` web` command runs from its bin. What is specific to this + * module is the framework side — validating the operator's options, + * resolving (or building) the artifact and its launch environment exactly as + * `mcp run` does, and reading the built page script from the package — and + * the Effect scope that owns every acquired resource: `close()` finalizes it + * once, newest resource first (the web host, then the MCP session, then a + * throwaway artifact). */ export type { McpAppConsentCapability, ServedMcpApp, ServeMcpAppPublicOptions } from './types.ts'; @@ -76,337 +39,19 @@ export interface ServeMcpAppOptions extends ServeMcpAppPublicOptions, Omit; - 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()( +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 serveProgram = (options: ServeMcpAppOptions): Effect.Effect => Effect.gen(function* () { const port = yield* liftTry(() => validPort(options.port)); const profile = yield* liftTry(() => validProfile(options.profile)); + const requestedApp = yield* liftTry(() => parseAppSelector(options.app)); const autoApprove = Object.freeze([...(options.autoApprove ?? [])]); const timeoutMs = options.timeoutMs ?? defaultTimeoutMs; - const requestedApp = yield* liftTry(() => parseServeAppSelector(options.app)); + // Before any build or launch: a package without its page cannot host anything. + const pageScript = yield* liftPromise(() => readWebHostPageScript()); const artifact = typeof options.artifact === 'string' ? options.artifact : yield* options.artifact; const launch = yield* liftPromise(() => resolveMcpLaunchEnvironment({ artifact, @@ -421,137 +66,30 @@ const serveProgram = (options: ServeMcpAppOptions): Effect.Effect openSession(launch, { serverName: requestedApp.server, target: options.target }, timeoutMs)), + liftPromise(() => openStdioAppSession(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; - }, - }, + const selection = yield* liftPromise(() => openApp(session.selection, { + ...(options.input === undefined ? {} : { input: options.input }), + ...(requestedApp.name === undefined ? {} : { name: requestedApp.name }), + ...(requestedApp.resourceUri === undefined ? {} : { resourceUri: requestedApp.resourceUri }), + server: requestedApp.server, + ...(options.tool === undefined ? {} : { tool: options.tool }), + })); + return yield* Effect.acquireRelease( + liftPromise(() => startWebHost({ + autoApprove, + open: options.open === true, + ...(options.openBrowser === undefined ? {} : { openBrowser: options.openBrowser }), + pageScript, + port, + profile, + selection, + session, + title: `${selection.server}/${appNameOf(selection.resourceUri) ?? selection.resourceUri}`, })), - (service) => Effect.promise(() => service.closeAll().catch(() => undefined)), + (host) => Effect.promise(() => host.close()), ); - 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)); - } - }; - // The page binds the opening call this host already made instead of - // posting the result back: a large result would otherwise exceed the - // request-body bound and drop the App to the fallback panel (#562). - const openingCall = (sessionId: string, toolName: string) => - sessionId === session.sessionId && toolName === selection.tool.name - ? Object.freeze({ input: selection.input, result: selection.result }) - : undefined; - const routes = yield* Effect.acquireRelease( - Effect.sync(() => new McpAppRoutes({ authorize, openingCall, 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, - }); - // `frame-ancestors` does not inherit from `default-src`: without it, a page - // on another origin could frame this consent-bearing document on a fixed - // `--port` and clickjack its Allow/Deny controls. - const contentSecurityPolicy = [ - "default-src 'none'", - "base-uri 'none'", - "connect-src 'self'", - "form-action 'none'", - "frame-ancestors '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}/`, - }); }); /** @@ -563,9 +101,9 @@ const serveProgram = (options: ServeMcpAppOptions): Effect.Effect => { const runtime = makeScopedEffectRuntime(Layer.effect(ServedMcpAppService, serveProgram(options))); - let service: ServedMcpAppShape; + let host: WebHost; try { - service = await runtime.run(ServedMcpAppService); + host = await runtime.run(ServedMcpAppService); } catch (error) { await runtime.close().catch(() => undefined); throw error; @@ -576,11 +114,11 @@ export const serveMcpApp = async (options: ServeMcpAppOptions): Promise web` command: 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. The session authority is the one stdio + * session the caller opened (`session.ts`), and the host document is the + * page `page.ts` renders around the caller's page script. + * + * Plain async acquire/release, no Effect: resources are acquired in order and + * `close()` releases them newest first — routes, preview bindings, sandbox + * proxy, HTTP server — exactly as the former Effect scope did. The session + * stays the caller's to close; `closed` is the session's own settlement. + */ + +export interface StartWebHostOptions { + /** Consent capabilities the operator pre-approved; the page decides them without asking. */ + readonly autoApprove: readonly McpAppConsentCapability[]; + /** Open the default browser on the host URL once it listens. */ + readonly open: boolean; + /** Injectable only to keep browser launching deterministic in tests. */ + readonly openBrowser?: OpenBrowser; + /** The page script: inlined into a generated bin, or `readWebHostPageScript()` in the framework. */ + readonly pageScript: string; + /** Loopback TCP port; `0` picks an ephemeral one. */ + readonly port: number; + /** The simulated MCP Apps host profile. */ + readonly profile: McpAppProfileId; + readonly selection: AppSelection; + readonly session: StdioAppSession; + /** The document title, e.g. `/`. */ + readonly title: string; +} + +export interface WebHost { + /** Settles once the bound MCP server connection has ended, whether by the caller's close or on its own. */ + readonly closed: Promise; + readonly resourceUri: string; + /** Loopback origin of the sandbox proxy the App document runs on. */ + readonly sandboxOrigin: string; + readonly server: string; + readonly tool: string; + /** The host document URL. */ + readonly url: string; + /** Releases the host's own resources, newest first; the session is left to its owner. Idempotent. */ + close(): Promise; +} + +const closeTimeoutMs = 1_000; + +const loopbackHosts: ReadonlySet = new Set(['127.0.0.1', 'localhost', '[::1]']); + +/** A valid loopback TCP port, `0` (ephemeral) when absent. */ +export 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; +}; + +/** A supported MCP Apps host profile, `portable` when absent. */ +export 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 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 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); +}; + +/** + * Acquired resources, released newest first. Each release swallows its own + * failure so a later one still runs, as the finalizers of the former Effect + * scope did; `run()` is idempotent. + */ +const releaseStack = (): Readonly<{ push(release: () => Promise | void): void; run(): Promise }> => { + const releases: (() => Promise | void)[] = []; + let running: Promise | undefined; + return Object.freeze({ + push: (release: () => Promise | void) => { releases.push(release); }, + run: () => { + running ??= (async () => { + for (const release of releases.splice(0).reverse()) { + try { + await release(); + } catch { + // Best-effort teardown: the next resource still gets released. + } + } + })(); + return running; + }, + }); +}; + +/** + * Hosts one selected App over the caller's session: listens on loopback, + * starts the sandbox proxy, wires the preview stack and its authenticated + * routes around a per-launch token, serves the host document at `/`, and + * optionally opens the browser. A failure part-way releases what was + * acquired before rejecting. + */ +export const startWebHost = async (options: StartWebHostOptions): Promise => { + const port = validPort(options.port); + const profile = validProfile(options.profile); + const { selection, session } = options; + const autoApprove = Object.freeze([...options.autoApprove]); + const openBrowser = options.openBrowser ?? openInBrowser; + const token = randomBytes(32).toString('base64url'); + const releases = releaseStack(); + try { + 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 = await listen(server, port); + releases.push(() => closeServer(server, sockets)); + const url = `http://127.0.0.1:${String(boundPort)}`; + const sandbox = await createMcpAppSandboxProxy({ hostOrigin: url }); + releases.push(() => sandbox.close()); + const bindings = new McpAppBindingService({ sessionAuthority: sessionAuthorityFor(session) }); + const previews = 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; + }, + }, + }); + releases.push(() => previews.closeAll()); + 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[WEB_HOST_TOKEN_HEADER]) !== token) { + throw requestError(diagnostic('AB8004', 'A valid MCP App host token is required.', 403)); + } + }; + // The page binds the opening call this host already made instead of + // posting the result back: a large result would otherwise exceed the + // request-body bound and drop the App to the fallback panel (#562). + const openingCall = (sessionId: string, toolName: string) => + sessionId === session.sessionId && toolName === selection.tool.name + ? Object.freeze({ input: selection.input, result: selection.result }) + : undefined; + const routes = new McpAppRoutes({ authorize, openingCall, service: previews }); + releases.push(() => { routes.close(); }); + const page = renderWebHostPage({ + script: options.pageScript, + seed: { + autoApprove, + input: selection.input, + previewProfile: profile, + result: selection.result, + sessionId: session.sessionId, + title: options.title, + token, + tokenHeader: WEB_HOST_TOKEN_HEADER, + toolName: selection.tool.name, + }, + }); + const contentSecurityPolicy = webHostContentSecurityPolicy(sandbox.origin); + 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) await openBrowser(`${url}/`); + return Object.freeze({ + close: () => releases.run(), + closed: session.closed, + resourceUri: selection.resourceUri, + sandboxOrigin: sandbox.origin, + server: selection.server, + tool: selection.tool.name, + url: `${url}/`, + }); + } catch (error) { + await releases.run(); + throw error; + } +}; diff --git a/packages/agent-bundle/src/web-host/page-script.ts b/packages/agent-bundle/src/web-host/page-script.ts new file mode 100644 index 000000000..84cf9cd3d --- /dev/null +++ b/packages/agent-bundle/src/web-host/page-script.ts @@ -0,0 +1,40 @@ +import { readFile } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; + +/** + * The framework's own copy of the built page script. The package build emits + * `web-host/browser/main.ts` as `dist/web-host/page.js` (an Rsbuild web + * build, inline-safe); framework hosts (`agent-bundle serve-app`) read it + * from there, while a generated bin inlines the same bytes as a string + * through a virtual module and never reaches this module. + */ + +// Rslib bundles this module into a chunk at the dist root, while source-level +// consumers (tests) run it from src/web-host. Spelled as paths, not +// `new URL(…, import.meta.url)`: the package's own Rslib build would read a +// static URL as an asset to emit. The same rule dev/workbench-assets.ts uses +// to find dist/workbench. +const packageRoot = basename(import.meta.dirname) === 'dist' + ? resolve(import.meta.dirname, '..') + : resolve(import.meta.dirname, '../..'); + +const pageScriptPath = resolve(packageRoot, 'dist', 'web-host', 'page.js'); + +let pageScript: Promise | undefined; + +/** + * The built page script's bytes, read once and cached for the process. A + * package built without it (a source checkout before `pnpm build`, or a build + * that skipped the web host page) fails with the path it looked in; the miss + * is not cached, so a later build is picked up. + */ +export const readWebHostPageScript = (): Promise => { + pageScript ??= readFile(pageScriptPath, 'utf8').catch((error: unknown) => { + pageScript = undefined; + throw new Error( + `agent-bundle was built without its web host page (${pageScriptPath}); run pnpm build.`, + { cause: error }, + ); + }); + return pageScript; +}; diff --git a/packages/agent-bundle/src/web-host/page.ts b/packages/agent-bundle/src/web-host/page.ts new file mode 100644 index 000000000..99e92bed8 --- /dev/null +++ b/packages/agent-bundle/src/web-host/page.ts @@ -0,0 +1,107 @@ +import { WEB_HOST_SEED_ELEMENT_ID, type WebHostPageSeed } from './browser/seed.ts'; + +/** + * The host document every agent-bundle web host serves at `/`: one HTML + * shell carrying the page seed (the bound session, the tool whose result the + * App opens with, and the per-launch credential the authenticated + * `/api/mcp/...` routes require) and the framework's built page script + * (`web-host/browser/main.ts`, the browser half of the Workbench's + * `McpAppFrameRelay`). Rendering is pure: the script arrives as a string, + * inlined from the bin or read from `dist/web-host/page.js` (`page-script.ts`), + * so this module touches no filesystem and bundles into the generated bin. + */ + +/** + * The request header the host document presents on every authenticated + * route. Standalone hosts (`agent-bundle serve-app`, ` web`) issue + * their own per-launch token under it; the dev server's host reuses its + * session header instead. The name reaches the page through the seed. + */ +export const WEB_HOST_TOKEN_HEADER = 'x-agent-bundle-web-host'; + +export interface RenderWebHostPageOptions { + /** The built page script, inlined verbatim into the document's ``, + ``, + '', + '', + '', + ].join('\n'); +}; diff --git a/packages/agent-bundle/src/web-host/select-app.ts b/packages/agent-bundle/src/web-host/select-app.ts new file mode 100644 index 000000000..6d4f8c6c5 --- /dev/null +++ b/packages/agent-bundle/src/web-host/select-app.ts @@ -0,0 +1,144 @@ +import { isRecord } from '../core/strict-json.ts'; +import { + selectMcpAppResourceUri, + type McpAppJsonValue, + type McpAppToolDefinition, +} from '../dev/mcp-apps/mcp-app-binding-service.ts'; +import { canonicalMcpAppJson } from '../dev/mcp-session/mcp-session-apps.ts'; + +/** + * App selection shared by every agent-bundle web host. `agent-bundle + * serve-app` and the generated ` web` command resolve an operator's + * `/` selector the same way: to one `ui://` resource the live + * server actually serves and one tool that declares it as its App, which the + * host then calls once so the App opens populated — the same input/result + * pair the Workbench binds when it previews a tool run. + * + * Plain Node: the generated bin bundles this module (AB6005), so it carries + * no Effect and no compiler modules. `session.ts` adapts the SDK client to + * {@link AppSelectionSource}; tests substitute a fake. + */ + +/** What App selection needs from a live MCP session. */ +export interface AppSelectionSource { + callTool(name: string, input: Readonly>): Promise; + /** The `ui://` resources whose MIME type is the MCP Apps document type. */ + listAppResourceUris(): Promise; + /** Canonical tool definitions (`mcp-session-apps.ts#canonicalMcpAppTool(tool).definition`). */ + listToolDefinitions(): Promise; +} + +export interface AppSelector { + readonly name?: string; + readonly resourceUri?: string; + readonly server: string; +} + +export interface OpenAppRequest { + /** + * Arguments for the opening tool call; defaults to `{}`. Any JSON-shaped + * record is accepted and canonicalized here, so callers holding parsed + * `--input` JSON or a manifest's `input` pass it through unchanged. + */ + readonly input?: Readonly>; + /** The App's name, resolved against the resources the server serves. Ignored when `resourceUri` is given. */ + readonly name?: string; + /** A known `ui://` resource URI (a manifest's); still verified against the live server. */ + readonly resourceUri?: string; + readonly server: string; + /** The opening tool; defaults to the only tool declaring the App's `_meta.ui.resourceUri`. */ + readonly tool?: string; +} + +export interface AppSelection { + readonly input: Readonly>; + readonly resourceUri: string; + readonly result: McpAppJsonValue; + readonly server: string; + readonly tool: McpAppToolDefinition; +} + +/** A detached, canonical JSON object, or a `TypeError` naming `label`; the bound of every tool argument record a host sends. */ +export 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>; +}; + +/** Splits `/` or `/ui://...` into its server and App parts, rejecting anything else. */ +export const parseAppSelector = (value: string): AppSelector => { + 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 }); +}; + +/** The App name a `ui://` resource URI spells: its last path segment without an `.html` suffix. */ +export 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 matchingResourceUris = (resourceUris: readonly string[], request: OpenAppRequest): readonly string[] => { + if (request.resourceUri !== undefined) return resourceUris.includes(request.resourceUri) ? [request.resourceUri] : []; + return resourceUris.filter((uri) => appNameOf(uri) === request.name); +}; + +/** + * Resolves the App and its opening tool against the live server, then calls + * the tool once. A known `resourceUri` skips name matching but is still + * verified against what the server serves; the tool must advertise the App + * as `_meta.ui.resourceUri`, and without `tool` exactly one may. + */ +export const openApp = async (source: AppSelectionSource, request: OpenAppRequest): Promise => { + if (request.resourceUri === undefined && request.name === undefined) { + throw new Error('MCP App must be named as / or a ui:// resource URI.'); + } + const [tools, resourceUris] = await Promise.all([source.listToolDefinitions(), source.listAppResourceUris()]); + const matching = matchingResourceUris(resourceUris, request); + const available = resourceUris.map((uri) => `${request.server}/${appNameOf(uri) ?? uri}`); + if (matching.length === 0) { + throw new Error( + `MCP server ${JSON.stringify(request.server)} serves no MCP App ${JSON.stringify(request.resourceUri ?? request.name)}` + + `${available.length === 0 ? ' (it serves no MCP App resources).' : `; available: ${available.join(', ')}.`}`, + ); + } + if (matching.length > 1) { + throw new Error( + `MCP App ${JSON.stringify(request.name)} names ${String(matching.length)} resources on server ${JSON.stringify(request.server)}; ` + + `use ${request.server}/ to select one of: ${matching.join(', ')}.`, + ); + } + const resourceUri = matching[0]!; + const appTools = tools.filter((tool) => selectMcpAppResourceUri(tool) === resourceUri); + const selectedTool = request.tool === undefined + ? appTools.length === 1 ? appTools[0] : undefined + : appTools.find((tool) => tool.name === request.tool); + if (selectedTool === undefined) { + if (request.tool !== undefined) { + throw new Error( + `Tool ${JSON.stringify(request.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(request.server)} declares _meta.ui.resourceUri ${resourceUri}.` + : `Several tools open MCP App ${resourceUri} (${appTools.map((tool) => tool.name).join(', ')}); choose one with --tool.`); + } + const input = requireJsonObject(request.input ?? {}, 'MCP App tool input'); + const result = await source.callTool(selectedTool.name, input); + return Object.freeze({ input, resourceUri, result, server: request.server, tool: selectedTool }); +}; diff --git a/packages/agent-bundle/src/web-host/session.ts b/packages/agent-bundle/src/web-host/session.ts new file mode 100644 index 000000000..eb21da3fa --- /dev/null +++ b/packages/agent-bundle/src/web-host/session.ts @@ -0,0 +1,219 @@ +import { Client, type Resource, type Tool } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { randomUUID } from 'node:crypto'; +import type { Stream } from 'node:stream'; + +import type { + McpAppBridgeResource, + McpAppBridgeSession, + McpAppBridgeTool, + McpAppJsonValue, + McpAppSessionAuthority, + McpAppSessionLease, + McpAppToolDefinition, +} from '../dev/mcp-apps/mcp-app-binding-service.ts'; +import { MCP_APP_MIME_TYPE } from '../dev/mcp-apps/mcp-app-bridge.ts'; +import { mcpAppPreviewHostInfo } from '../dev/mcp-apps/mcp-app-preview-host.ts'; +import { + canonicalMcpAppJson, + canonicalMcpAppResource, + canonicalMcpAppTool, + mcpAppClientCapabilities, +} from '../dev/mcp-session/mcp-session-apps.ts'; +import { requireJsonObject, type AppSelectionSource } from './select-app.ts'; + +/** + * The one stdio session behind an agent-bundle web host: a launched MCP + * server (the plugin's own packed executable, spawned exactly as `mcp run` + * spawns it) connected through the SDK client, exposed both as the + * `McpAppBridgeSession` the App host stack leases and as the + * `AppSelectionSource` App selection reads. Plain Node plus + * `@modelcontextprotocol/client`: the generated bin bundles it (AB6005). + */ + +const maxStderrBytes = 64 * 1024; + +/** How to spawn the MCP server: `services/mcp-run.ts#ResolvedMcpStdioLaunch` and a manifest `web` entry both resolve to this. */ +export interface StdioLaunch { + readonly args: readonly string[]; + readonly command: string; + readonly cwd: string; + readonly env: Readonly>; +} + +export interface StdioAppSession { + readonly bridge: McpAppBridgeSession; + /** Settles once the server connection has ended, whether by `close()` or on its own. */ + readonly closed: Promise; + /** App selection over this session's tools and `ui://` resources. */ + readonly selection: AppSelectionSource; + readonly sessionId: string; + /** The server's captured stderr so far (bounded), for the error a failed start reports. */ + readonly stderr: () => string; + close(): Promise; + watchClosed(listener: () => void): () => void; +} + +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; +}; + +/** + * Launches the server and connects; a start that fails within `timeoutMs` + * rejects with the server's stderr attached. Every request over the session + * carries the same timeout. + */ +export const openStdioAppSession = async ( + launch: StdioLaunch, + 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: `web-host:${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'); + }, + }); + const selection: AppSelectionSource = Object.freeze({ + callTool: (name: string, input: Readonly>) => bridge.callTool({ arguments: input, name }), + listAppResourceUris: async (): Promise => { + assertActive(); + const resources = await listResources(); + assertActive(); + return Object.freeze(resources.filter((resource) => resource.mimeType === MCP_APP_MIME_TYPE).map((resource) => resource.uri)); + }, + listToolDefinitions: async (): Promise => { + assertActive(); + const tools = await listTools(); + assertActive(); + return Object.freeze(tools.map((tool) => canonicalMcpAppTool(tool).definition)); + }, + }); + let closing: Promise | undefined; + return Object.freeze({ + bridge, + close: () => { + closing ??= client.close().catch(() => undefined).then(markClosed); + return closing; + }, + closed: closedGate.promise, + selection, + sessionId, + stderr, + watchClosed: (listener: () => void) => { + if (closed) { + listener(); + return () => undefined; + } + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + }); +}; + +/** The one bound session, leased to every App binding the host page creates. */ +export const sessionAuthorityFor = (session: StdioAppSession): 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 }); + }, + }); + }, +}); diff --git a/packages/agent-bundle/tests/serve-app.test.ts b/packages/agent-bundle/tests/serve-app.test.ts index 1ba6fa3a7..43e87bc15 100644 --- a/packages/agent-bundle/tests/serve-app.test.ts +++ b/packages/agent-bundle/tests/serve-app.test.ts @@ -14,7 +14,7 @@ import { createAppClient, } from '../src/app/index.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 { WEB_HOST_TOKEN_HEADER } from '../src/web-host/page.ts'; import { timeScale } from './support/time-scale.ts'; /** @@ -44,11 +44,12 @@ interface Seed { readonly sessionId: string; readonly title: string; readonly token: string; + readonly tokenHeader: string; readonly toolName: string; } const seedOf = (html: string): Seed => { - const match = /`, 'u').exec(html); + if (match?.[1] === undefined) throw new Error('The host document carries no seed element.'); + return match[1]; +}; + +describe('renderWebHostPage', () => { + it('embeds the seed as JSON the page reads back unchanged, and the script verbatim', () => { + const html = renderWebHostPage({ script, seed }); + expect(html.startsWith('\n')).toBe(true); + expect(JSON.parse(seedElementOf(html))).toEqual(seed); + expect(html).toContain(``); + expect(html).toContain('status/status'); + expect(html).toContain('

status/status

'); + expect(html).toContain(''); + }); + + it('keeps a hostile tool result from terminating the seed element', () => { + const hostile = { + ...seed, + result: { content: [{ text: ' line\u2028break\u2029