Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/388-route-config-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Reference an MCP App from static route `config` instead of repeating its `ui://` literal: import `appResourceUri('<app>')` from the new `agent-bundle/routes` subpath and the route-graph compiler resolves it to the App route's `config.resourceUri` (`AB4826` for an unknown App or one on another server, `AB4828` when the App is not built for every target the server ships to), or use a `const` string-literal identifier declared in the route module or `export const`-ed by a relative sibling module; `AB4806` now names both supported forms, and `ToolConfig`/`ResourceConfig`/`PromptConfig`/`AppRouteConfig` type `_meta.ui.resourceUri` through `RouteMeta`/`RouteUiMeta`. Resolve an App route's `config.template` relative to the route module like its imports, keep accepting the project-root-relative form while unambiguous, and report `AB4827` with both candidate paths when they conflict or neither exists (#418)
61 changes: 57 additions & 4 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,59 @@ references, methods, or accessors); array literals without spreads or holes;
string literals and substitution-free template literals; numeric literals,
optionally wrapped in unary `+`/`-`; `true`, `false`, and `null`; and
`as`/`satisfies` casts, non-null assertions, and parentheses around any
accepted form. Anything else is dynamic: the route compiles with an empty
config beside a named `AB4806` error. A module without a `config` export
compiles silently with an empty config.
accepted form. Two constrained reference forms are accepted for string
values, so an MCP App's `resourceUri` never has to be repeated as a literal
in every tool that opens it:

- **A `const` string-literal identifier.** A top-level `const X = '<literal>'`
(optionally `as const`) declared in the route module, or an
`export const X = '<literal>'` of a module reached through a *relative*
import (`import { X } from '../constants'`; `.ts`/`.tsx` resolution,
`.js`-style specifiers map onto their TypeScript source, index modules
resolve) inside the project root. The sibling module is parsed, never
executed, and only that one hop is followed: the exported const's
initializer must itself be a string literal. Because the identifier is a
real import, the same value is available at run time (for example in
`Agent.Result metadata`).
- **`appResourceUri('<app>')`** imported from `agent-bundle/routes`. The
compiler resolves the reference to the target App route's static
`config.resourceUri` while compiling the graph. The App must belong to the
referencing route's own generated server — a generated server registers
exactly its own Apps, so another server's URI could never be read through
it. References are `'<app>'`, `'<server>/<app>'`,
`'app:<server>/<app>'`, or a module path relative to the referencing file
(`'../apps/dashboard'`, with or without its `.ts`/`.tsx` extension — a
`.js`/`.jsx` spelling maps onto the TypeScript source, and any other suffix
is part of the App name). The argument may be a
string literal or a const identifier of the first form. An unknown
reference — another server's App, an App whose own `resourceUri` is not a
static string, or any reference from a non-MCP route — is `AB4826`, and the
route compiles with the empty config beside it. Routes of a server that is
not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) never
ship their config, so their references are left as authored rather than
reported. Whether referenced or
written as a literal, an advertised `_meta.ui.resourceUri` must name an App
the server builds for every target it ships to (`AB4828` otherwise). At run time the helper returns the reference unchanged: generated
servers read the compiled config, never the module's evaluated `config`, so
use the const form when the URI is also needed inside the component.

Anything else — any other identifier, a call, a package import, a relative
import that leaves the project or does not export a string-literal const —
is dynamic: the route compiles with an empty config beside a named `AB4806`
error whose recovery names both reference forms. A module without a `config`
export compiles silently with an empty config.

An MCP App route's `config.template` resolves **relative to the route
module**, the way its imports do (`template: './dashboard.html'`). The older
project-root-relative form (`'./src/mcp/<server>/apps/dashboard.html'`) is
still accepted, without a diagnostic, while it is the only interpretation
that names an existing file. When both interpretations name different
existing files, or neither exists, `AB4827` names both candidate paths; the
fix is to make the path route-relative. The IR keeps the authored path (so the
graph digest stays machine-independent) and the normalized model carries the
resolved absolute file. Config-declared Apps (`mcp.servers.<server>.apps`)
keep resolving `entry` and `template` from the project root, where the config
file lives.
Generated route declarations are published at `.agent-bundle/routes.d.ts` from
the same graph. Development writes a sibling temporary file and renames it over
the prior complete declaration atomically; invalid source retains the prior
Expand Down Expand Up @@ -452,7 +502,7 @@ schema constants), unions, nested objects, transforms, coercions — raises
| `AB4803` | error | A route path derives an unsafe identity segment (each segment must match `^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$`). |
| `AB4804` | error | A `routes` mode override is not `generated`/`custom`/`command`/`remote` for a server, or `generated`/`conventional` for the CLI. |
| `AB4805` | error | A route module exports `config` through a rejected declaration shape (`let`/`var`, destructuring, `export { config }`, a function or class, a missing initializer), or the extracted value is not an object. |
| `AB4806` | error | A route module's `config` initializer is dynamic — the message names the offending construct and position. |
| `AB4806` | error | A route module's `config` initializer is dynamic — the message names the offending construct and position, and the recovery names the two accepted reference forms (a top-level `const` string literal declared locally or `export const`-ed by a relative sibling module, and `appResourceUri('<app>')` from `agent-bundle/routes`). |
| `AB4807` | retired | The stage-1 rendered-script gate. Rendered script routes ship through the Agent renderer pipeline since #102 stage 3; the code is never reused. |
| `AB4808` | error | A conventional `src/scripts/` route nests below the scripts root; conventional scripts ship as direct children only. Move it up, prefix a path segment with `_`, or declare it under `scripts` in config with a flat name. |
| `AB4809` | error | A conventional `src/scripts/` route and a configured `scripts` entry share one script identity through different files. Point the config entry at the module to claim it, or rename one of the two. |
Expand All @@ -472,6 +522,9 @@ schema constants), unions, nested objects, transforms, coercions — raises
| `AB4823` | error | An event route declares an event outside the v1 event vocabulary. |
| `AB4824` | error | An event route selects an unknown target or requires an event capability that the selected target does not support. |
| `AB4825` | error | An event route's `config.targets` is not a nonempty array of nonempty target names. |
| `AB4826` | error | A route's static `config` calls `appResourceUri('<app>')` with a reference that matches no App route of the route's own generated server with a static `config.resourceUri`: an unknown name, another server's App (a generated server registers only its own Apps), or a reference from a non-MCP route. The message names the cause and lists the server's known App route ids; reference the App as `'<app>'`, `'<server>/<app>'`, `'app:<server>/<app>'`, or a relative module path. |
| `AB4827` | error | An MCP App route's `config.template` is ambiguous or missing: both the route-relative and the project-root-relative interpretation name different existing files, or neither exists. The message names both candidate paths; templates resolve relative to the route module, so rewrite the path as `'./<file>.html'` beside the route. |
| `AB4828` | error | A generated MCP route advertises `_meta.ui.resourceUri` of an App on its server (through `appResourceUri()` or a literal) that is not built for every target the server ships to, because the App's `config.targets` (or a config-declared App's `targets`) is narrower. Widen the App's targets or restrict `mcp.servers.<server>.targets`. |
| `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, 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. |
Expand Down
2 changes: 1 addition & 1 deletion docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ entries carry `provenance.kind: 'conventional'` in the normalized model.
| `src/index.ts` | Library output with declarations. | `lib: false` |
| `src/mcp/<server-id>.ts` | Stdio entry for the declared MCP server `<server-id>` that names no `entry`, `command`, or `url`. | Declare `entry` explicitly |
| `src/mcp/<server>/{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.<server>` to `custom`, `command`, or `remote` |
| `src/mcp/<server>/apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required. | Use a custom server or prefix the file with `_` |
| `src/mcp/<server>/apps/*.{ts,tsx}` | Browser MCP App entry compiled to self-contained HTML and registered on the generated server; static `config.resourceUri` is required. 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('<app>')` 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/scripts/<name>.ts` | Plain script compiled to `scripts/<name>.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`). | Prefix a path segment with `_`, or claim the file with an explicit `scripts` entry |
| `src/scripts/<name>.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/<name>.mjs` plus a `scripts/<name>-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. | 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). Nesting is identity: `src/cli/library/audit.ts` runs as `<bin> 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 `_` |
Expand Down
33 changes: 32 additions & 1 deletion docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,37 @@ export default async function Status({ input, signal }: ToolRouteProps<typeof in
}
```

An MCP App is one browser entry under `src/mcp/<server>/apps/`, and a tool
that opens it references the App instead of repeating its `ui://` literal:

```ts
// src/mcp/runtime/apps/dashboard.ts
import type { AppRouteConfig } from 'agent-bundle';

export const config = {
resourceUri: 'ui://my-plugin/dashboard.html',
template: './dashboard.html', // resolves beside this file, like an import
} satisfies AppRouteConfig;
```

```tsx
// src/mcp/runtime/tools/open-dashboard.tsx
import type { ToolConfig } from 'agent-bundle';
import { appResourceUri } from 'agent-bundle/routes';

export const config = {
_meta: { ui: { resourceUri: appResourceUri('dashboard') } },
description: 'Open the dashboard.',
} satisfies ToolConfig;
```

`appResourceUri('dashboard')` is resolved by the compiler to the App route's
`config.resourceUri` (`AB4826` when no such App exists); a `const` string
literal imported from a relative sibling module is accepted in static `config`
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).

The compiler statically reads `config`, imports schemas and implementations
only into generated entries, installs `runAgentRequest`, and derives the real
MCP server from the route graph. Each call renders through a warm internal
Expand Down Expand Up @@ -122,7 +153,7 @@ The final Agent Document of a tool route lowers to one `CallToolResult`:
| `Agent.Text`, `Agent.Markdown`, `Agent.Context`, `Agent.Json` children | Ordered `content` text blocks (`Agent.Json` as its JSON text). |
| `Agent.Image`, `Agent.Audio`, `Agent.Resource` | Native `image`, `audio`, and `resource_link` blocks; a host without that capability fails the projection closed unless a text fallback is selected. |
| `Agent.Result value` | `structuredContent` when the value is a JSON object; a non-object value emits none and is never wrapped. |
| `Agent.Result metadata` | `CallToolResult._meta`. It must be a JSON object (snapshotted through the same wire boundary as `structuredContent`); anything else fails the projection closed with `McpProjectionError('invalid-result-metadata')`. Listing-level `_meta` still comes from static `config._meta`, so the MCP Apps convention stamps `_meta.ui.resourceUri` on both halves. |
| `Agent.Result metadata` | `CallToolResult._meta`. It must be a JSON object (snapshotted through the same wire boundary as `structuredContent`); anything else fails the projection closed with `McpProjectionError('invalid-result-metadata')`. Listing-level `_meta` still comes from static `config._meta`, so the MCP Apps convention stamps `_meta.ui.resourceUri` on both halves. In `config._meta.ui.resourceUri`, reference the App route instead of repeating its `ui://` literal: `appResourceUri('dashboard')` from `agent-bundle/routes` resolves at compile time to that App route's `config.resourceUri`, and a `const` string literal imported from a relative sibling module (`import { DASHBOARD_URI } from '../constants'`) is accepted too and stays available at run time for the result half. |
| `Agent.Error code message` | `isError: true` plus one text block `[<code>] <message>`. The wire has no error-code field, so the code is deliberately kept in the text (the routed CLI prints the same `**[code]** message` form); choose codes that read well to the model. |
| `resultSchema` | `outputSchema` in `tools/list` **only when the schema describes an object** (`z.object`, `z.record`, a discriminated union of objects). The MCP specification requires every result of a tool that declares `outputSchema` to carry `structuredContent`, so a text-only route declares `resultSchema = z.undefined()` (or any non-object schema), advertises no `outputSchema`, and returns no `structuredContent`. An object schema keeps the SDK's fail-closed output validation on every call. |

Expand Down
4 changes: 4 additions & 0 deletions packages/agent-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@
"types": "./dist/mcp-entry.d.ts",
"import": "./dist/mcp-entry.js"
},
"./routes": {
"types": "./dist/routes/public.d.ts",
"import": "./dist/routes.js"
},
"./rstest": {
"types": "./dist/rstest/index.d.ts",
"import": "./dist/rstest.js"
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-bundle/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export default defineConfig({
'mcp-entry': './src/mcp-entry.ts',
meta: './src/meta.ts',
'mcp-server-runtime': './src/mcp-server-runtime.ts',
// The route authoring surface: types plus the compile-time helpers a
// route module may import at run time without pulling the compiler
// into its generated bundle.
routes: './src/routes/public.ts',
rstest: './src/rstest/index.ts',
test: './src/test/index.ts',
'test/browser': './src/test/browser.ts',
Expand Down
8 changes: 7 additions & 1 deletion packages/agent-bundle/src/config/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import type {
NormalizedStateDefinition,
SourceProvenance,
} from '../core/types.ts';
import { appRouteTemplatePath, resolveAppRouteTemplate } from '../routes/app-template.ts';
import type { CompiledCliSurface } from '../routes/types.ts';
import { type DiscoveredProject, payloadDeclarationSource } from './discover.ts';
import type { LoadedConfig } from './load.ts';
Expand Down Expand Up @@ -878,6 +879,11 @@ const normalizeMcpApps = (
: server.targets;
const metadata = route.config['_meta'];
const template = route.config['template'];
// Route-relative first, legacy project-root-relative when unambiguous;
// the route-graph compiler already reported AB4827 for the other cases.
const templatePath = typeof template === 'string'
? appRouteTemplatePath(resolveAppRouteTemplate(loaded.context.projectRoot, route.source, template))
: undefined;
apps.push({
...(isRecord(metadata) ? { _meta: structuredClone(metadata) } : {}),
id: `mcp-app:${surface.name}:${name}`,
Expand All @@ -888,7 +894,7 @@ const normalizeMcpApps = (
serverName: surface.name,
source: route.source,
targets: sortedUnique(targets),
...(typeof template === 'string' ? { template: resolve(loaded.context.projectRoot, template) } : {}),
...(templatePath === undefined ? {} : { template: templatePath }),
});
}
}
Expand Down
Loading
Loading