From a6ef29c5ed56be466fe79c6248bde345cfb1bd98 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 06:19:48 +0000 Subject: [PATCH 1/5] feat: add typed MCP App client --- .changeset/typed-app-client.md | 5 + docs/diagnostics.md | 8 +- docs/entry-conventions.md | 110 ++- examples/mcp-app/package.json | 1 - .../browser-app/status-panel.browser.test.ts | 46 +- examples/mcp-app/views/status-panel.ts | 76 ++- packages/agent-bundle/README.md | 13 + packages/agent-bundle/package.json | 4 + packages/agent-bundle/rslib.config.ts | 17 + packages/agent-bundle/src/app/index.ts | 645 ++++++++++++++++++ .../agent-bundle/src/build/app-runtime.ts | 18 + packages/agent-bundle/src/build/entries.ts | 26 +- .../agent-bundle/src/build/entry-shell.ts | 8 +- packages/agent-bundle/src/build/mcp-apps.ts | 36 +- .../agent-bundle/src/build/runtime-path.ts | 21 + packages/agent-bundle/src/config/normalize.ts | 3 +- packages/agent-bundle/src/config/validate.ts | 3 +- .../agent-bundle/src/routes/cli-commands.ts | 3 +- packages/agent-bundle/src/routes/layouts.ts | 10 +- .../agent-bundle/src/routes/protocol-name.ts | 63 ++ packages/agent-bundle/src/routes/typegen.ts | 59 ++ packages/agent-bundle/src/test/contract.ts | 3 +- packages/agent-bundle/src/test/mcp.ts | 3 +- packages/agent-bundle/src/test/render.ts | 9 +- .../agent-bundle/tests/app-client.test.ts | 403 +++++++++++ .../tests/check-declaration-imports.test.ts | 24 + .../tests/mcp-apps-compile.test.ts | 77 ++- .../agent-bundle/tests/public-api.test.ts | 5 +- .../tests/route-framework-imports.test.ts | 3 +- .../agent-bundle/tests/route-graph.test.ts | 203 +++++- .../tests/route-protocol-name.test.ts | 119 ++++ .../tests/route-register-typegen.test.ts | 34 +- .../tests/route-typegen-write.test.ts | 41 ++ pnpm-lock.yaml | 3 - website/docs/en/examples/mcp-app.mdx | 13 +- website/docs/en/guide/authoring/mcp.mdx | 136 +++- .../docs/en/guide/distribution/validation.mdx | 5 +- website/docs/en/reference/api.mdx | 3 + website/docs/en/reference/security.mdx | 17 + website/docs/zh/examples/mcp-app.mdx | 8 +- website/docs/zh/guide/authoring/mcp.mdx | 50 +- .../docs/zh/guide/distribution/validation.mdx | 6 +- website/docs/zh/reference/api.mdx | 3 + website/docs/zh/reference/security.mdx | 9 + website/rspress.config.ts | 1 + 45 files changed, 2219 insertions(+), 134 deletions(-) create mode 100644 .changeset/typed-app-client.md create mode 100644 packages/agent-bundle/src/app/index.ts create mode 100644 packages/agent-bundle/src/build/app-runtime.ts create mode 100644 packages/agent-bundle/src/build/runtime-path.ts create mode 100644 packages/agent-bundle/src/routes/protocol-name.ts create mode 100644 packages/agent-bundle/tests/app-client.test.ts create mode 100644 packages/agent-bundle/tests/route-protocol-name.test.ts diff --git a/.changeset/typed-app-client.md b/.changeset/typed-app-client.md new file mode 100644 index 000000000..edcc8483a --- /dev/null +++ b/.changeset/typed-app-client.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Call typed MCP App routes through `agent-bundle/app` without triggering `AB4837` (#594) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 0908edd23..2e72db03d 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -309,8 +309,10 @@ entry citing the warning text it drops and why it is noise; it may be empty. Every App is measured after it is emitted: the UTF-8 bytes of the self-contained HTML and their gzip size, what a compressing transport would -carry. `AB4772` is the size advisory, one **warning** per App. Any view that -imports `@modelcontextprotocol/ext-apps` starts at about 437 kB (104 kB gzip) +carry. `AB4772` is the size advisory, one **warning** per App. A view that +reaches its host through `agent-bundle/app` carries the framework's client +and nothing else from the protocol stack; one that imports +`@modelcontextprotocol/ext-apps` instead starts at about 437 kB (104 kB gzip) — `zod` v3 and v4, `@modelcontextprotocol/sdk`, `zod-to-json-schema`, and `ext-apps` itself — so the advisory bound of 1 MiB (1,048,576 bytes) sits at roughly 2.4× that floor and at half the 2 MiB (2,097,152 bytes) bound above @@ -905,7 +907,7 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but the root `tsconfig.json` program — resolved like `tsc -p`, including `extends` and one level of project `references` — does not compile it, so `renderRoute` / `renderRouteEvents` type-check route ids as `string` and `input` / `result` as `unknown`. Reported on `tsconfig.json`; never for a project without one. | Add `".agent-bundle/routes.d.ts"` to `tsconfig.json` `include` (not `files`: an `include` entry is inert until the first build publishes the file, while a missing `files` entry is a `tsc` error); `build`, `dev`, and `validate` keep the file current and it stays gitignored. | | `AB4835` | error | A route's static `config.render` (the render budget of one call, #454) is malformed: `render` is not an object, carries a key other than `maxElapsedMs`, `maxElapsedMs` is not a positive integer of milliseconds, or it exceeds the framework ceiling of `86400000` (24 hours) — or a plain `.ts` CLI command declares one, although it executes without a render session. Reported once per route: on an MCP tool, resource, or prompt route with its server (the tool's projected CLI command inherits the value), or on a `src/cli/**` command route; a route with a rejected budget compiles no command. Omit `render` to keep the runtime default (`60000`). Declare `config.render = { maxElapsedMs: }` on a rendered route, or remove it. The budget bounds the framework's render session only: Codex's `tool_timeout_sec` (60 s by default) and any per-server host timeout must be raised by the operator separately, while Claude Code's default per-call wall clock is about 28 hours and its idle timer is kept alive by the `notifications/progress` the projector forwards. | | `AB4836` | error | A route's static `config.execution` (MCP task support, #369) is malformed: `execution` is not an object, carries a key other than `taskSupport`, or `taskSupport` is not one of `forbidden`, `optional`, `required` — or a resource or prompt route declares it, although the `2025-11-25` Tasks utility augments `tools/call` only. Reported once per route with its server. Omit `execution` to keep the wire default (`forbidden`: every call is an ordinary request), or declare `config.execution = { taskSupport: 'optional' }` so a task-aware client may receive a `CreateTaskResult` and poll `tasks/get` / `tasks/result` while the render continues, or `'required'` to refuse ordinary calls with JSON-RPC `-32601`. The generated server advertises the value in `tools/list` and declares the `tasks` capability only when at least one tool opted in. | -| `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/serve-app-command`. | +| `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/app` (the browser MCP App client, a leaf with no Zod, Node, or compiler import), `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/serve-app-command`. | | `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, plugin, signal }`. | | `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. | | `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index a90318a88..d5f0b2cec 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -76,7 +76,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. | `src/index.ts` | Library output with declarations. | `lib: false` | | `src/mcp/.ts` | Stdio entry for the declared MCP server `` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly | | `src/mcp//{tools,resources,prompts}/*.{ts,tsx}` | Generated MCP server routes; path supplies identity and each executable module supplies static `config`, schemas, and one async default Server Component. | Set `routes.servers.` to `custom`, `command`, or `remote` | -| `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required (`AB4812`), and two App routes of one server sharing a URI are `AB4829` (the same URI on different servers is not a collision). An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. | Use a custom server or prefix the file with `_` | +| `src/mcp//apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required (`AB4812`), and two App routes of one server sharing a URI are `AB4829` (the same URI on different servers is not a collision). An optional `config.template` HTML shell resolves relative to the route module like its imports (`'./dashboard.html'`); the legacy project-root-relative form is accepted only while unambiguous (`AB4827` otherwise). Tools, resources, and prompts reference the App from their own static `config` with `appResourceUri('')` from `agent-bundle/routes` or a shared `const` string literal instead of repeating the `ui://` literal. The view talks to its host through `createAppClient()` from the browser-safe `agent-bundle/app` (see [`agent-bundle/app`](#agent-bundleapp--the-app-side-bridge-client)), typed by the generated `AppRegister` augmentation. | Use a custom server or prefix the file with `_` | | `src/scripts/.ts` | Plain script compiled to `scripts/.mjs` in every selected target artifact — the same pipeline explicit `scripts` entries use, with ordinary Node stdout/stderr semantics. A `scripts` entry that references the file claims it. Nested modules are hard errors (`AB4808`). A `bin` entry that references the file does **not** claim it: the module ships as both the npm bin and the artifact script (see [Which config keys claim a conventional module](#which-config-keys-claim-a-conventional-module)); export `main` or make the module self-executing, because a `default`-only module would run as the bin but ship as an inert script (`AB4738`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/scripts/.tsx` | Rendered script: the async default component receives `{ argv, signal }` and renders through the Agent renderer with the CLI output contract (`--json`, `--ndjson`, TTY progress, piped Markdown). Compiles to `scripts/.mjs` plus a `scripts/-flight.mjs` react-server worker. The extension is the explicit, visible contract — plain `.ts` scripts are never wrapped in React behavior, and explicit `scripts` config entries stay plain regardless of extension. A `bin` entry that references a rendered script is `AB4737` unless the module exports both the default component (for the script) and a named `main` (for the bin envelope); with both, the module serves both surfaces. | Rename to `.ts`, prefix a path segment with `_`, or claim the file with an explicit `scripts` entry | | `src/cli/**/*.{ts,tsx}` | Routed CLI commands compiled into one collision-checked command graph and one generated package executable named after `plugin.name` (superseding the `src/cli.ts` bin convention for the project), plus the same executable as `bin/.mjs` in every selected host artifact whose target publishes the `cli` capability (all built-in targets). Nesting is identity: `src/cli/library/audit.ts` runs as ` library audit`. Plain `.ts` commands execute directly and print one canonical JSON line; `.tsx` commands render through the dispatcher with the four output modes. | `bin: false`, `routes.cli: 'conventional'`, or prefix a path segment with `_` | @@ -1489,9 +1489,10 @@ resolve '../events'`). The route graph reports such an import first, as (`src/routes/framework-imports.ts`; the compiler-carrying entries are `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, and -`agent-bundle/test/browser`, matched exactly; `import type` and type-only -usage are not reported), while an external bare import (`AB6005 uses -unsupported specifier`) or a non-literal `import(spec)` (`AB6005 has a +`agent-bundle/test/browser`, matched exactly, so the bundle-safe entries — +`agent-bundle/app` among them — are never reported; `import type` and +type-only usage are not reported either), while an external bare import +(`AB6005 uses unsupported specifier`) or a non-literal `import(spec)` (`AB6005 has a non-literal dynamic import`) still fails artifact validation. The sanctioned shape is `spawnServeApp` from `agent-bundle/serve-app-command` (`src/serve-app-command.ts`, #558) — plain Node with no dependencies, so the @@ -1515,3 +1516,104 @@ sent; it is still running). It is a checkout command: an installed host pack has neither `node_modules/agent-bundle` nor the artifact, and the first two codes say so before anything is spawned. The worked example is in the MCP Apps guide, "Serving an App standalone". + +## `agent-bundle/app` — the App-side bridge client + +`agent-bundle/app` (`src/app/index.ts`, #594) is the half of the MCP Apps +bridge that runs inside the App document. It is a browser-safe leaf of the +package: its only imports are browser-safe strict-JSON and route-name helpers, so `dist/app.js` names no +Zod, Node built-in, Effect, route module, or compiler code, and the App +compile inlines it into the self-contained HTML from the installed package +inside the document (`tests/mcp-apps-compile.test.ts` walks the runtime import +graph and the emitted document for exactly that). It is a public package +export and a compiler-reserved App import, so a consumer `paths` mapping cannot +replace the framework runtime. It is not one of the compiler-carrying entries +`AB4837` rejects. + +The other half stays where it is: the host page, sandbox proxy, frame relay +(`McpAppFrameRelay` in the Workbench, the inline relay in `serve-app`), +`/api/mcp/...` routes, consent authority, and `createMcpAppBridge` +(`src/dev/mcp-apps/mcp-app-bridge.ts`) are host-side and owned by the +Workbench, `serve-app`, and #564's production host. The `browser-app` proof +level (`mountBrowserApp` in `agent-bundle/test/browser`, which hosts the +compiled document over `createMcpAppBridge`) exercises the client against that +host bridge — `examples/mcp-app/tests/browser-app` asserts the App-to-host +traffic is exactly the client's `ui/initialize` and +`ui/notifications/initialized` until the view acts — so there is one wire +contract, not a second host bridge. The client never decides which server a +call reaches or which capability needs consent. + +### Public surface + +`createAppClient(options?)` returns a frozen `AppClient`: + +| Member | Contract | +| --- | --- | +| `connect(options?)` | Performs the `ui/initialize` handshake once and resolves the validated `AppInitializeResult` (`protocolVersion` `2026-01-26` — `APP_PROTOCOL_VERSION` — plus `hostInfo`, `hostCapabilities`, `hostContext`), then sends `ui/notifications/initialized`. Idempotent: a connected client resolves the cached result, a connecting one returns the in-flight promise. | +| `call(routeId, input, options?)` | `tools/call` for the tool a `tool:/` route id names — the wire name is the final segment, derived the way the generated server derives it (`src/routes/protocol-name.ts`); any other shape is a `TypeError`. `input` must be a finite strict-JSON object (`invalid-message` otherwise). Resolves the result's `structuredContent` **directly**; rejects a result with `isError: true` (`rpc`, whole result on `error.data`), one without an object `structuredContent`, or a malformed envelope (`invalid-message`). Only object-rooted `resultSchema`s produce `structuredContent` in the MCP projection, so scalar and array results are not typed App calls. | +| `request(method, params?, options?)` | The typed JSON-RPC escape hatch for `resources/read` and supported `ui/*` methods; resolves the raw result. | +| `onToolInput(routeId, listener)` / `onToolResult(routeId, listener)` | The opening call's `ui/notifications/tool-input` arguments and the decoded `structuredContent` of `ui/notifications/tool-result`. The notifications carry no tool name — they are always the call that opened the App — so the route id types the payload and does not filter; a result without an object `structuredContent` is dropped. Each returns its unsubscribe function. | +| `onToolCancelled(listener)` | `ui/notifications/tool-cancelled` as `{ reason? }`; returns its unsubscribe function. | +| `rebind({ parent?, targetOrigin?, window? })` | Rejects the previous generation's pending requests with `connection-rebound`, clears the pinned origin, moves the message listener when `window` changes, adopts the new parent and trusted origin, and runs `connect()` again. | +| `dispose()` | Idempotent. Removes the message listener, rejects pending requests with `disposed`, drops every registration and the pin. A host `ui/resource-teardown` request is answered with `{}` and disposes the client; any other host request is answered `-32601`. | +| `connected` / `disposed` | Read-only state. | + +`CreateAppClientOptions` are `appInfo` (`{ name, version }`, default +`agent-bundle-app` / `1.0.0`), `appCapabilities` (a finite JSON object, +default `{}`), `timeoutMs` (integer milliseconds, 1 to 2³¹ − 1, default +15 000), and the connect options `parent`, `targetOrigin`, `window`. Every +request takes `{ signal, timeoutMs }`; a request that times out or is aborted +rejects with `timeout` / `aborted` and, once connected, sends +`notifications/cancelled` to the host. `AppClientError` is the one error +class: `code` is `timeout`, `aborted`, `disposed`, `connection-rebound`, +`invalid-message`, `rpc`, `capability-unavailable`, or `consent-required`; a +host JSON-RPC error maps `-32601` to `capability-unavailable`, `-32001` to +`consent-required`, and anything else to `rpc`, keeping `rpcCode` and `data`. +No AB diagnostic is involved: App-side failures are browser errors. + +### Opaque sandbox handshake + +The Workbench and `serve-app` render the App as `