diff --git a/.changeset/388-route-config-references.md b/.changeset/388-route-config-references.md new file mode 100644 index 000000000..d2a97380b --- /dev/null +++ b/.changeset/388-route-config-references.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Reference an MCP App from static route `config` instead of repeating its `ui://` literal: import `appResourceUri('')` 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) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 0623c48c0..9094b288e 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -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 = ''` + (optionally `as const`) declared in the route module, or an + `export const X = ''` 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('')`** 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:/'`, 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//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..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 @@ -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('')` 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. | @@ -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('')` 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:/'`, 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 `'./.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..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. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index eec831d0c..f1658bc34 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. | 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. 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/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`). | 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. | 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 ` 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 `_` | diff --git a/docs/framework-mode.md b/docs/framework-mode.md index ff42d5447..449170a17 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -53,6 +53,37 @@ export default async function Status({ input, signal }: ToolRouteProps/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 @@ -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 `[] `. 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. | diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 48fd01af3..c9e47b5d6 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -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" diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index ad240c93c..6298a57a6 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -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', diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 34a8b44f1..105c9acff 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -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'; @@ -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}`, @@ -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 }), }); } } diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 3361ed54d..69e524103 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1210,6 +1210,77 @@ const validateOutput = (loaded: LoadedConfig): Diagnostic[] => { } }; +/** The `_meta.ui.resourceUri` a route's static config advertises, when it is a string. */ +const advertisedUiResourceUri = (config: Readonly>): string | undefined => { + const meta = config['_meta']; + if (!isRecord(meta) || !isRecord(meta.ui)) return undefined; + const resourceUri = meta.ui.resourceUri; + return typeof resourceUri === 'string' ? resourceUri : undefined; +}; + +/** + * A generated route that advertises `_meta.ui.resourceUri` of one of its + * server's Apps must reach every target the server ships to with that App + * built: an App restricted through `config.targets` (or a config-declared + * App's `targets`) is skipped by the other targets' builds, and the route + * would point hosts at a resource the server never registers there. The + * check covers both `appResourceUri()` references (already resolved to the + * literal by the graph compiler) and hand-written literals. + */ +const validateRouteAppTargets = ( + loaded: LoadedConfig, + discovered: DiscoveredProject, + registry: NormalizationTargetRegistry, +): Diagnostic[] => { + const selectedTargets = selectedTargetNamesFor(loaded, registry); + const configured = isRecord(loaded.config.mcp) && isRecord(loaded.config.mcp.servers) + ? loaded.config.mcp.servers as Readonly> + : {}; + const diagnostics: Diagnostic[] = []; + for (const server of discovered.routeGraph?.servers ?? []) { + if (server.mode !== 'generated' || server.routes.length === 0) continue; + const declaration = isRecord(configured[server.name]) ? configured[server.name] as Readonly> : {}; + const serverTargets = declaredTargetsOr(declaration.targets, selectedTargets); + // Every App this server registers, by resourceUri, with the targets it is built for. + const appTargets = new Map(); + for (const route of server.routes) { + if (route.kind !== 'app') continue; + const resourceUri = route.config['resourceUri']; + if (typeof resourceUri !== 'string') continue; + appTargets.set(resourceUri, { + name: route.provenance.relativePath, + targets: declaredTargetsOr(route.config['targets'], serverTargets), + }); + } + if (isRecord(declaration.apps)) { + for (const [appName, app] of Object.entries(declaration.apps)) { + if (!isRecord(app) || typeof app.resourceUri !== 'string') continue; + appTargets.set(app.resourceUri, { + name: `config App ${JSON.stringify(appName)}`, + targets: declaredTargetsOr(app.targets, serverTargets), + }); + } + } + for (const route of server.routes) { + if (route.kind === 'app') continue; + const resourceUri = advertisedUiResourceUri(route.config); + if (resourceUri === undefined) continue; + const app = appTargets.get(resourceUri); + if (app === undefined) continue; + const missing = serverTargets.filter((target) => !app.targets.includes(target)); + if (missing.length === 0) continue; + diagnostics.push({ + code: 'AB4828', + message: `Route ${route.provenance.relativePath} advertises _meta.ui.resourceUri ${JSON.stringify(resourceUri)} of ${app.name}, which is not built for ${missing.map((target) => JSON.stringify(target)).join(', ')} although the ${JSON.stringify(server.name)} server ships there.`, + recovery: `Widen the App's targets to cover ${missing.join(', ')}, or restrict mcp.servers.${server.name}.targets to the App's targets, then inspect again.`, + severity: 'error', + sourcePath: route.source, + }); + } + } + return diagnostics; +}; + const validateEventRoutes = ( loaded: LoadedConfig, discovered: DiscoveredProject, @@ -1955,6 +2026,7 @@ export const validateSource = ( // they are project-source errors, so they gate inspect and build here. diagnostics.push(...(discovered.routeGraph?.diagnostics ?? [])); diagnostics.push(...(discovered.state?.diagnostics ?? [])); + diagnostics.push(...validateRouteAppTargets(loaded, discovered, registry)); diagnostics.push(...validateEventRoutes(loaded, discovered, registry)); // The stage-1 gate for conventional script routes rides beside the graph's // own collisions: rendered, nested, and config-conflicting script routes diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index f562c80bb..49103a942 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -37,8 +37,10 @@ export type { CliRouteProps, PromptConfig, ResourceConfig, + RouteMeta, RouteSchema, RouteSchemaOutput, + RouteUiMeta, ToolConfig, ToolRouteProps, } from './routes/public.ts'; diff --git a/packages/agent-bundle/src/routes/app-template.ts b/packages/agent-bundle/src/routes/app-template.ts new file mode 100644 index 000000000..1bdd1c624 --- /dev/null +++ b/packages/agent-bundle/src/routes/app-template.ts @@ -0,0 +1,82 @@ +import { existsSync, statSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +/** + * How one App route's `config.template` path resolved. `route-relative` is + * the documented form (the path resolves from the route module like its + * imports do); `project-relative` is the legacy form, accepted only while it + * is the sole interpretation that names an existing file. + */ +export type AppRouteTemplateResolution = + | { + readonly form: 'project-relative' | 'route-relative'; + readonly kind: 'resolved'; + /** Absolute template path. */ + readonly path: string; + } + | { + /** Both interpretations name different existing files. */ + readonly kind: 'ambiguous'; + readonly projectRelative: string; + readonly routeRelative: string; + } + | { + /** Neither interpretation names an existing file. */ + readonly kind: 'missing'; + readonly projectRelative: string; + readonly routeRelative: string; + }; + +const isFile = (path: string): boolean => { + try { + return existsSync(path) && statSync(path).isFile(); + } catch { + return false; + } +}; + +/** + * Resolves an App route's `config.template` against both candidate bases. + * An absolute template, or a relative one whose two interpretations coincide, + * has one candidate, which still has to exist; otherwise exactly one existing + * candidate wins, and the ambiguous and missing outcomes are reported for the + * compiler to diagnose (AB4827). + */ +export const resolveAppRouteTemplate = ( + projectRoot: string, + routeSource: string, + template: string, +): AppRouteTemplateResolution => { + const routeRelative = resolve(dirname(routeSource), template); + const projectRelative = resolve(projectRoot, template); + const routeExists = isFile(routeRelative); + if (routeRelative === projectRelative) { + return routeExists + ? { form: 'route-relative', kind: 'resolved', path: routeRelative } + : { kind: 'missing', projectRelative, routeRelative }; + } + const projectExists = isFile(projectRelative); + if (routeExists && projectExists) return { kind: 'ambiguous', projectRelative, routeRelative }; + if (routeExists) return { form: 'route-relative', kind: 'resolved', path: routeRelative }; + if (projectExists) return { form: 'project-relative', kind: 'resolved', path: projectRelative }; + return { kind: 'missing', projectRelative, routeRelative }; +}; + +/** + * The template path the normalized model carries: the resolved candidate, or + * the route-relative interpretation when resolution failed so the build still + * fails loudly on the documented form beside the AB4827 diagnostic. + */ +export const appRouteTemplatePath = (resolution: AppRouteTemplateResolution): string => { + switch (resolution.kind) { + case 'resolved': + return resolution.path; + case 'ambiguous': + case 'missing': + return resolution.routeRelative; + default: { + const unreachable: never = resolution; + throw new TypeError(`Unhandled template resolution ${String(unreachable)}.`); + } + } +}; diff --git a/packages/agent-bundle/src/routes/config-extract.ts b/packages/agent-bundle/src/routes/config-extract.ts index 0b48ae5f4..0c3fbf3bd 100644 --- a/packages/agent-bundle/src/routes/config-extract.ts +++ b/packages/agent-bundle/src/routes/config-extract.ts @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs'; +import { dirname, extname, isAbsolute, relative, resolve } from 'node:path'; + // Aliased: the workspace toolchain is typescript@7 (native compiler, no // single-file parse API), and a plain `typescript` dependency here would // shadow it for rslib's declaration generation. The alias ships the 5.x @@ -9,6 +12,27 @@ import { deepFreeze } from '../core/freeze.ts'; import { hasExportModifier, positionOf, unwrapExpression } from './input-schema.ts'; import { emptyRouteConfig } from './types.ts'; +/** The package subpath route modules import compile-time authoring helpers from. */ +export const routeHelpersSpecifier = 'agent-bundle/routes'; + +/** The compile-time helper that references an MCP App route's `resourceUri`. */ +export const appResourceUriHelperName = 'appResourceUri'; + +/** + * One `appResourceUri('')` call the extractor found inside `config`. The + * config carries the reference text at {@link path} until the route-graph + * compiler, which knows every App route, substitutes the target's + * `resourceUri` through {@link resolveRouteConfigAppReferences}. + */ +export interface RouteConfigAppReference { + /** Property path from the config root to the referencing value. */ + readonly path: readonly (number | string)[]; + /** `line:column` of the call inside the route module. */ + readonly position: string; + /** The App reference exactly as authored. */ + readonly reference: string; +} + /** * The statically extracted `config` export of one route module, plus the * named diagnostics extraction raised. `config` is {@link emptyRouteConfig} @@ -17,10 +41,25 @@ import { emptyRouteConfig } from './types.ts'; * leaves the accepted grammar (AB4806). */ export interface ExtractedRouteConfig { + /** Unresolved `appResourceUri()` references, in source order; empty once resolved. */ + readonly appReferences: readonly RouteConfigAppReference[]; readonly config: Readonly>; readonly diagnostics: readonly Diagnostic[]; } +export interface RouteConfigExtractionOptions { + /** + * Absolute project root. A relative import that resolves outside it is not + * project source and stays dynamic. Unset means unconstrained (tests). + */ + readonly projectRoot?: string; + /** + * Reads one sibling module's text; `undefined` when the path is not a + * readable file. Defaults to a synchronous filesystem read. + */ + readonly readModule?: (path: string) => string | undefined; +} + /** * The accepted route-config expression grammar. Extraction is fully static — * the module is parsed, never executed — so the initializer must be built @@ -34,25 +73,33 @@ export interface ExtractedRouteConfig { * - finite numeric literals, optionally wrapped in unary `+`/`-`; * - `true`, `false`, and `null`; * - `as`/`satisfies` casts, non-null assertions, and parentheses around any - * accepted form (they unwrap to their inner expression). + * accepted form (they unwrap to their inner expression); + * - two constrained reference forms for string values: an identifier bound + * to a top-level `const` whose initializer is a string literal, declared + * in the same module or `export const`-ed by a module reached through a + * relative import inside the project; and `appResourceUri('')` + * imported from `agent-bundle/routes`, which the route-graph compiler + * replaces with the referenced App route's `resourceUri`. * - * Everything else — identifier references, calls, functions, templates with - * substitutions, `undefined`, bigints, regular expressions, non-finite - * numbers such as `1e999` — is dynamic and raises AB4806 naming the - * offending construct. + * Everything else — other identifier references, calls, functions, + * templates with substitutions, `undefined`, bigints, regular expressions, + * non-finite numbers such as `1e999` — is dynamic and raises AB4806 naming + * the offending construct. */ -export const routeConfigGrammar = 'object/array/string/number/boolean/null literals, with as-const, satisfies, non-null, and parenthesis wrappers'; +export const routeConfigGrammar = 'object/array/string/number/boolean/null literals, with as-const, satisfies, non-null, and parenthesis wrappers, plus const string-literal identifiers and appResourceUri() App references'; const emptyExtraction: ExtractedRouteConfig = deepFreeze({ + appReferences: [], config: emptyRouteConfig, diagnostics: [], }); const declarationRecovery = 'Export the route config as a single top-level `export const config = { ... }` object literal, then inspect again.'; -const grammarRecovery = `Restrict the config initializer to the static grammar (${routeConfigGrammar}), then inspect again.`; +const grammarRecovery = `Restrict the config initializer to the static grammar (${routeConfigGrammar}). A string value may reference a top-level const string literal declared in this module or exported by a relative sibling module (\`import { X } from './constants'\`), or reference an MCP App route through \`${appResourceUriHelperName}('')\` imported from ${routeHelpersSpecifier}; then inspect again.`; +const appReferenceRecovery = `Reference an App route of the same generated server as '', '/', 'app:/', or a relative module path from the referencing module, and make sure that App route declares a static config.resourceUri; then inspect again.`; const routeConfigError = ( - code: 'AB4805' | 'AB4806', + code: 'AB4805' | 'AB4806' | 'AB4826', message: string, recovery: string, sourcePath: string, @@ -70,6 +117,75 @@ type Extraction = const dynamic = (description: string, node: ts.Node): Extraction => ({ dynamic: { description, node }, kind: 'dynamic' }); +/** One `import { name as local } from ''` binding of the route module. */ +interface ImportedBinding { + readonly importedName: string; + readonly node: ts.Node; + readonly specifier: string; +} + +/** The top-level bindings of one parsed module the reference forms may consult. */ +interface ModuleScope { + /** Top-level `const` declarations by local name; the flag records `export`. */ + readonly consts: ReadonlyMap; + readonly imports: ReadonlyMap; + /** Local names bound by `let`/`var`, functions, classes, or non-named imports: known, but never static. */ + readonly nonConst: ReadonlySet; + readonly sourceFile: ts.SourceFile; +} + +const collectBindingNames = (name: ts.BindingName, into: Set): void => { + if (ts.isIdentifier(name)) { + into.add(name.text); + return; + } + for (const element of name.elements) { + if (!ts.isOmittedExpression(element)) collectBindingNames(element.name, into); + } +}; + +const scopeOf = (sourceFile: ts.SourceFile): ModuleScope => { + const consts = new Map(); + const imports = new Map(); + const nonConst = new Set(); + for (const statement of sourceFile.statements) { + if (ts.isVariableStatement(statement)) { + const isConst = (statement.declarationList.flags & ts.NodeFlags.Const) !== 0; + const exported = hasExportModifier(statement); + for (const declaration of statement.declarationList.declarations) { + if (isConst && ts.isIdentifier(declaration.name)) { + consts.set(declaration.name.text, { exported, initializer: declaration.initializer }); + } else { + collectBindingNames(declaration.name, nonConst); + } + } + continue; + } + if (ts.isImportDeclaration(statement)) { + const clause = statement.importClause; + if (clause === undefined || !ts.isStringLiteral(statement.moduleSpecifier)) continue; + const specifier = statement.moduleSpecifier.text; + if (clause.name !== undefined) nonConst.add(clause.name.text); + const bindings = clause.namedBindings; + if (bindings === undefined) continue; + if (ts.isNamespaceImport(bindings)) { + nonConst.add(bindings.name.text); + continue; + } + for (const element of bindings.elements) { + if (clause.isTypeOnly || element.isTypeOnly) continue; + const importedName = element.propertyName?.text ?? element.name.text; + imports.set(element.name.text, { importedName, node: element, specifier }); + } + continue; + } + if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name !== undefined) { + nonConst.add(statement.name.text); + } + } + return { consts, imports, nonConst, sourceFile }; +}; + /** Names one rejected construct for the AB4806 message. */ const describeExpression = (node: ts.Node): string => { if (ts.isIdentifier(node)) { @@ -98,6 +214,12 @@ const literalPropertyName = (name: ts.PropertyName): string | undefined => { return undefined; }; +const stringLiteralText = (expression: ts.Expression | undefined): string | undefined => { + if (expression === undefined) return undefined; + const node = unwrapExpression(expression); + return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : undefined; +}; + /** * Numeric literals must extract to finite numbers: an overflowing literal * such as `1e999` evaluates to `Infinity`, which `JSON.stringify` collapses @@ -109,7 +231,187 @@ const finiteNumber = (value: number, node: ts.Node): Extraction => ? { kind: 'value', value } : dynamic(`the non-finite number \`${String(value)}\``, node); -const extractExpression = (expression: ts.Expression): Extraction => { +const scriptKindOf = (relativePath: string): ts.ScriptKind => { + if (relativePath.endsWith('.tsx')) return ts.ScriptKind.TSX; + if (relativePath.endsWith('.jsx')) return ts.ScriptKind.JSX; + return ts.ScriptKind.TS; +}; + +const parseModule = (path: string, text: string): ts.SourceFile => + ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, scriptKindOf(path)); + +const readModuleFromDisk = (path: string): string | undefined => { + try { + return readFileSync(path, 'utf8'); + } catch { + // Missing, unreadable, or a directory: the specifier names no module. + return undefined; + } +}; + +const moduleExtensions: Readonly> = { + '.cjs': ['.cts', '.cjs'], + '.cts': ['.cts'], + '.js': ['.ts', '.tsx', '.js'], + '.jsx': ['.tsx', '.jsx'], + '.mjs': ['.mts', '.mjs'], + '.mts': ['.mts'], + '.ts': ['.ts'], + '.tsx': ['.tsx'], +}; + +/** + * The on-disk candidates one relative specifier may name, in TypeScript + * resolution order: an explicit `.ts`/`.tsx` extension is exact, a `.js`-style + * extension maps onto its TypeScript source, and an extensionless specifier + * probes `.ts`, `.tsx`, and an index module. + */ +const moduleCandidates = (fromDirectory: string, specifier: string): readonly string[] => { + const base = resolve(fromDirectory, specifier); + const extension = extname(specifier).toLowerCase(); + const mapped = moduleExtensions[extension]; + if (mapped !== undefined) { + const stem = base.slice(0, -extension.length); + return mapped.map((candidate) => `${stem}${candidate}`); + } + return [`${base}.ts`, `${base}.tsx`, resolve(base, 'index.ts'), resolve(base, 'index.tsx')]; +}; + +const isRelativeSpecifier = (specifier: string): boolean => + specifier.startsWith('./') || specifier.startsWith('../'); + +const insideProject = (projectRoot: string | undefined, path: string): boolean => { + if (projectRoot === undefined) return true; + const relativePath = relative(projectRoot, path); + return relativePath !== '' && !relativePath.startsWith('..') && !isAbsolute(relativePath); +}; + +/** Per-extraction state: the route module's scope, the reference sink, and a sibling-module cache. */ +interface ExtractionContext { + readonly appReferences: RouteConfigAppReference[]; + readonly options: RouteConfigExtractionOptions; + readonly readModule: (path: string) => string | undefined; + readonly scope: ModuleScope; + readonly siblingScopes: Map; + readonly sourceDirectory: string; +} + +type ImportedConstResolution = + | { readonly kind: 'value'; readonly value: string } + | { readonly kind: 'rejected'; readonly reason: string }; + +const resolveImportedConst = ( + binding: ImportedBinding, + context: ExtractionContext, +): ImportedConstResolution => { + const from = JSON.stringify(binding.specifier); + if (!isRelativeSpecifier(binding.specifier)) { + return { kind: 'rejected', reason: `imported from ${from}, which is not a relative module path` }; + } + const candidates = moduleCandidates(context.sourceDirectory, binding.specifier); + let scope: ModuleScope | undefined; + for (const candidate of candidates) { + if (!insideProject(context.options.projectRoot, candidate)) { + return { kind: 'rejected', reason: `imported from ${from}, which resolves outside the project` }; + } + if (context.siblingScopes.has(candidate)) { + scope = context.siblingScopes.get(candidate); + } else { + const text = context.readModule(candidate); + scope = text === undefined ? undefined : scopeOf(parseModule(candidate, text)); + context.siblingScopes.set(candidate, scope); + } + if (scope !== undefined) break; + } + if (scope === undefined) { + return { kind: 'rejected', reason: `imported from ${from}, which does not resolve to a module inside the project` }; + } + const declaration = scope.consts.get(binding.importedName); + if (declaration === undefined || !declaration.exported) { + return { + kind: 'rejected', + reason: `imported from ${from}, which does not declare a top-level \`export const ${binding.importedName}\``, + }; + } + const value = stringLiteralText(declaration.initializer); + if (value === undefined) { + return { + kind: 'rejected', + reason: `imported from ${from}, whose \`export const ${binding.importedName}\` initializer is not a string literal`, + }; + } + return { kind: 'value', value }; +}; + +/** Resolves one identifier through the two constrained reference forms. */ +const extractIdentifier = (node: ts.Identifier, context: ExtractionContext): Extraction => { + if (node.text === 'undefined') return dynamic(describeExpression(node), node); + const reference = `a reference to the identifier ${JSON.stringify(node.text)}`; + const local = context.scope.consts.get(node.text); + if (local !== undefined) { + const value = stringLiteralText(local.initializer); + return value === undefined + ? dynamic(`${reference}, whose top-level const initializer is not a string literal`, node) + : { kind: 'value', value }; + } + const imported = context.scope.imports.get(node.text); + if (imported !== undefined) { + const resolved = resolveImportedConst(imported, context); + return resolved.kind === 'value' + ? { kind: 'value', value: resolved.value } + : dynamic(`${reference}, ${resolved.reason}`, node); + } + if (context.scope.nonConst.has(node.text)) { + return dynamic(`${reference}, which is not a top-level \`const\` string literal`, node); + } + return dynamic(`${reference}, which is neither a top-level const string literal in this module nor a named import from a relative module`, node); +}; + +/** Recognizes `appResourceUri('')` imported from the route helpers subpath. */ +const extractAppReferenceCall = ( + node: ts.CallExpression, + path: readonly (number | string)[], + context: ExtractionContext, +): Extraction => { + const callee = unwrapExpression(node.expression); + if (!ts.isIdentifier(callee)) return dynamic(describeExpression(node), node); + const binding = context.scope.imports.get(callee.text); + if (binding === undefined || binding.importedName !== appResourceUriHelperName) { + if (callee.text === appResourceUriHelperName) { + return dynamic(`a call to ${JSON.stringify(callee.text)} that is not imported from ${routeHelpersSpecifier}`, node); + } + return dynamic(describeExpression(node), node); + } + if (binding.specifier !== routeHelpersSpecifier) { + return dynamic( + `a call to ${appResourceUriHelperName} imported from ${JSON.stringify(binding.specifier)} instead of ${routeHelpersSpecifier}`, + node, + ); + } + const [argument] = node.arguments; + if (argument === undefined || node.arguments.length !== 1) { + return dynamic(`a call to ${appResourceUriHelperName} without exactly one string argument`, node); + } + const extracted = extractExpression(argument, path, context); + if (extracted.kind === 'dynamic') return extracted; + if (typeof extracted.value !== 'string' || extracted.value.trim() === '') { + return dynamic(`a call to ${appResourceUriHelperName} whose argument is not a non-empty string`, argument); + } + context.appReferences.push({ + path, + position: positionOf(context.scope.sourceFile, node), + reference: extracted.value, + }); + // The reference text stands in until the graph compiler substitutes the + // App's resourceUri; it is also what the helper returns at run time. + return { kind: 'value', value: extracted.value }; +}; + +const extractExpression = ( + expression: ts.Expression, + path: readonly (number | string)[], + context: ExtractionContext, +): Extraction => { const node = unwrapExpression(expression); switch (node.kind) { case ts.SyntaxKind.TrueKeyword: @@ -125,6 +427,8 @@ const extractExpression = (expression: ts.Expression): Extraction => { return { kind: 'value', value: node.text }; } if (ts.isNumericLiteral(node)) return finiteNumber(Number(node.text), node); + if (ts.isIdentifier(node)) return extractIdentifier(node, context); + if (ts.isCallExpression(node)) return extractAppReferenceCall(node, path, context); if (ts.isPrefixUnaryExpression(node)) { const operand = unwrapExpression(node.operand); if ( @@ -138,11 +442,11 @@ const extractExpression = (expression: ts.Expression): Extraction => { } if (ts.isArrayLiteralExpression(node)) { const values: unknown[] = []; - for (const element of node.elements) { + for (const [index, element] of node.elements.entries()) { if (ts.isSpreadElement(element) || ts.isOmittedExpression(element)) { return dynamic(describeExpression(element), element); } - const extracted = extractExpression(element); + const extracted = extractExpression(element, [...path, index], context); if (extracted.kind === 'dynamic') return extracted; values.push(extracted.value); } @@ -157,7 +461,7 @@ const extractExpression = (expression: ts.Expression): Extraction => { if (!ts.isPropertyAssignment(property)) return dynamic(describeExpression(property), property); const name = literalPropertyName(property.name); if (name === undefined) return dynamic(describeExpression(property.name), property.name); - const extracted = extractExpression(property.initializer); + const extracted = extractExpression(property.initializer, [...path, name], context); if (extracted.kind === 'dynamic') return extracted; value[name] = extracted.value; } @@ -211,23 +515,19 @@ const findConfigExport = (sourceFile: ts.SourceFile): ConfigExportSite | undefin return undefined; }; -const scriptKindOf = (relativePath: string): ts.ScriptKind => { - if (relativePath.endsWith('.tsx')) return ts.ScriptKind.TSX; - if (relativePath.endsWith('.jsx')) return ts.ScriptKind.JSX; - return ts.ScriptKind.TS; -}; - /** * Statically extracts the `export const config = ` declaration of * one route module. The module is parsed with the TypeScript compiler and * never executed, so only the accepted grammar (see * {@link routeConfigGrammar}) produces a value; a module without a config - * export extracts silently to {@link emptyRouteConfig}. + * export extracts silently to {@link emptyRouteConfig}. Sibling modules a + * const reference imports are parsed the same way, never executed. */ export const extractRouteConfig = ( moduleText: string, relativePath: string, sourcePath: string, + options: RouteConfigExtractionOptions = {}, ): ExtractedRouteConfig => { const sourceFile = ts.createSourceFile( relativePath, @@ -240,6 +540,7 @@ export const extractRouteConfig = ( if (site === undefined) return emptyExtraction; if (site.initializer === undefined) { return deepFreeze({ + appReferences: [], config: emptyRouteConfig, diagnostics: [routeConfigError( 'AB4805', @@ -249,9 +550,18 @@ export const extractRouteConfig = ( )], }); } - const extracted = extractExpression(site.initializer); + const context: ExtractionContext = { + appReferences: [], + options, + readModule: options.readModule ?? readModuleFromDisk, + scope: scopeOf(sourceFile), + siblingScopes: new Map(), + sourceDirectory: dirname(sourcePath), + }; + const extracted = extractExpression(site.initializer, [], context); if (extracted.kind === 'dynamic') { return deepFreeze({ + appReferences: [], config: emptyRouteConfig, diagnostics: [routeConfigError( 'AB4806', @@ -263,6 +573,7 @@ export const extractRouteConfig = ( } if (typeof extracted.value !== 'object' || extracted.value === null || Array.isArray(extracted.value)) { return deepFreeze({ + appReferences: [], config: emptyRouteConfig, diagnostics: [routeConfigError( 'AB4805', @@ -273,7 +584,149 @@ export const extractRouteConfig = ( }); } return deepFreeze({ + appReferences: context.appReferences, config: extracted.value as Record, diagnostics: [], }); }; + +/** One App route the reference resolver may target. */ +export interface AppReferenceTarget { + /** The route id, `app:/`. */ + readonly id: string; + /** The App's statically extracted `config.resourceUri`. */ + readonly resourceUri: string; + /** Absolute App route module path. */ + readonly source: string; +} + +/** The referencing route module, as the resolver needs to see it. */ +export interface AppReferenceSite { + readonly relativePath: string; + /** The owning MCP server name; absent for non-MCP routes, which cannot use the bare `''` form. */ + readonly serverName?: string; + /** Absolute route module path. */ + readonly source: string; +} + +/** + * The extensions an App route module can carry (the discovery globs admit + * `.ts`/`.tsx`) and the TypeScript-style specifier spellings that map onto + * them. Only these count as an extension of a relative reference, so a + * dotted App name such as `foo.bar` keeps its dot and a mistyped suffix + * (`dashboard.tss`) matches nothing. + */ +const appModuleExtensions: Readonly> = { + '.js': '.ts', + '.jsx': '.tsx', + '.ts': '.ts', + '.tsx': '.tsx', +}; + +/** The App sources one relative reference may name: exact (after `.js`-style mapping) or extensionless. */ +const relativeAppCandidates = (site: AppReferenceSite, reference: string): readonly string[] => { + const candidate = resolve(dirname(site.source), reference); + const extension = extname(reference).toLowerCase(); + const mapped = appModuleExtensions[extension]; + if (mapped !== undefined) return [`${candidate.slice(0, -extension.length)}${mapped}`]; + return [`${candidate}.ts`, `${candidate}.tsx`]; +}; + +const findAppTarget = ( + reference: string, + site: AppReferenceSite, + apps: readonly AppReferenceTarget[], +): AppReferenceTarget | undefined => { + if (isRelativeSpecifier(reference)) { + const candidates = relativeAppCandidates(site, reference); + return apps.find((app) => candidates.includes(app.source)); + } + if (reference.startsWith('app:')) return apps.find((app) => app.id === reference); + if (reference.includes('/')) return apps.find((app) => app.id === `app:${reference}`); + if (site.serverName === undefined) return undefined; + return apps.find((app) => app.id === `app:${site.serverName}/${reference}`); +}; + +const cloneWithSubstitutions = ( + value: unknown, + path: readonly (number | string)[], + substitutions: ReadonlyMap, +): unknown => { + const substitute = substitutions.get(JSON.stringify(path)); + if (substitute !== undefined) return substitute; + if (Array.isArray(value)) { + return value.map((element, index) => cloneWithSubstitutions(element, [...path, index], substitutions)); + } + if (typeof value === 'object' && value !== null) { + const clone: Record = Object.create(null) as Record; + for (const key of Object.keys(value)) { + clone[key] = cloneWithSubstitutions((value as Record)[key], [...path, key], substitutions); + } + return clone; + } + return value; +}; + +/** Why one `appResourceUri()` reference did not resolve, for the AB4826 message. */ +const describeUnresolvedAppReference = ( + reference: string, + site: AppReferenceSite, + local: readonly AppReferenceTarget[], + apps: readonly AppReferenceTarget[], +): string => { + if (site.serverName === undefined) { + return 'App references resolve only from MCP route modules (src/mcp//{tools,resources,prompts,apps}/*), whose generated server registers the App'; + } + const foreign = findAppTarget(reference, site, apps); + if (foreign !== undefined) { + return `which is ${foreign.id} on another server; a generated server registers only its own Apps, so ${JSON.stringify(site.serverName)} cannot serve it`; + } + const known = local.length === 0 + ? `no App route of the generated ${JSON.stringify(site.serverName)} server declares a static config.resourceUri` + : `known App routes of ${JSON.stringify(site.serverName)}: ${local.map((app) => app.id).sort((left, right) => left.localeCompare(right)).join(', ')}`; + return `which matches no App route of the same server; ${known}`; +}; + +/** + * Substitutes every `appResourceUri()` reference of an extraction with the + * target App route's `resourceUri`. Only Apps of the referencing route's own + * server are targets — a generated server registers exactly its own Apps, so + * a URI from another server could never be read through it. A reference that + * matches no such App (an unknown name, 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 — like every dynamic config — the route compiles + * with the empty config beside the diagnostic rather than a half-resolved one. + */ +export const resolveRouteConfigAppReferences = ( + extracted: ExtractedRouteConfig, + site: AppReferenceSite, + apps: readonly AppReferenceTarget[], +): ExtractedRouteConfig => { + if (extracted.appReferences.length === 0) return extracted; + const local = site.serverName === undefined + ? [] + : apps.filter((app) => app.id.startsWith(`app:${site.serverName}/`)); + const substitutions = new Map(); + const diagnostics: Diagnostic[] = [...extracted.diagnostics]; + for (const reference of extracted.appReferences) { + const target = findAppTarget(reference.reference, site, local); + if (target === undefined) { + diagnostics.push(routeConfigError( + 'AB4826', + `Route module ${site.relativePath} references MCP App ${JSON.stringify(reference.reference)} at ${reference.position}, ${describeUnresolvedAppReference(reference.reference, site, local, apps)}.`, + appReferenceRecovery, + site.source, + )); + continue; + } + substitutions.set(JSON.stringify(reference.path), target.resourceUri); + } + if (diagnostics.length > extracted.diagnostics.length) { + return deepFreeze({ appReferences: [], config: emptyRouteConfig, diagnostics }); + } + return deepFreeze({ + appReferences: [], + config: cloneWithSubstitutions(extracted.config, [], substitutions) as Record, + diagnostics, + }); +}; diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index e2b1f5fa4..73dbcb2b0 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -5,12 +5,18 @@ import { extname, relative, resolve } from 'node:path'; import fastGlob from 'fast-glob'; import { isProjectPathIgnored, readProjectIgnoreRules, toPosixPath } from '../config/ignore.ts'; +import { resolveAppRouteTemplate } from './app-template.ts'; import { compileCliCommands, compileMcpCliCommands, type McpCommandSelection, } from './cli-commands.ts'; -import { extractRouteConfig } from './config-extract.ts'; +import { + type AppReferenceTarget, + type ExtractedRouteConfig, + extractRouteConfig, + resolveRouteConfigAppReferences, +} from './config-extract.ts'; import { validateEventRouteModuleContract, validateProviderModuleContract, @@ -348,6 +354,38 @@ const declaredMcpServer = ( return isRecord(server) ? server : undefined; }; +interface ServerModeDecision { + /** The conventional `src/mcp/.{ts,tsx}` entry module, when one exists. */ + readonly conventionalEntry?: string; + readonly mode: CompiledServerMode; +} + +/** + * Decides how one MCP server that owns route modules is packaged: an + * explicit `routes.servers.` override wins; otherwise the routes + * generate the server unless an existing entry claim (conventional entry + * module, or declared `entry`/`command`/`url`) makes the choice a + * `conflict` the caller reports as AB4800. Shared between App-reference + * resolution and server assembly so both see the same mode. + */ +const decideServerMode = ( + projectRoot: string, + config: Readonly, + overrides: RouteModeOverrides, + name: string, +): ServerModeDecision => { + const override = overrides.servers.get(name); + const declared = declaredMcpServer(config, name); + const declaredEntry = declared !== undefined && + (declared.entry !== undefined || declared.command !== undefined || declared.url !== undefined); + const conventionalEntry = conventionalEntryAt(projectRoot, 'src', 'mcp', name); + const withEntry = (mode: CompiledServerMode): ServerModeDecision => + conventionalEntry === undefined ? { mode } : { conventionalEntry, mode }; + if (override === 'custom' || override === 'command' || override === 'remote') return withEntry(override); + if (override === 'generated' || (conventionalEntry === undefined && !declaredEntry)) return withEntry('generated'); + return withEntry('conflict'); +}; + const compiledRoute = ( module: DiscoveredRouteModule, config: Readonly>, @@ -377,32 +415,59 @@ const readRouteModuleText = async (source: string): Promise /** * Statically extracts one route module's `config` export from already-read - * source text. A module a racing deletion removed simply has no config; - * extraction diagnostics (AB4805/AB4806) accumulate beside the discovery - * diagnostics. + * source text. A module a racing deletion removed simply has no config. + * Extraction diagnostics (AB4805/AB4806) are reported once every module is + * extracted, beside the App-reference resolution (AB4826) that needs the + * whole discovered tree. */ interface ExtractedModuleMetadata { - readonly config: Readonly>; + readonly extracted: ExtractedRouteConfig; readonly inputSchema?: RouteInputSchema; } +const emptyExtractedRouteConfig: ExtractedRouteConfig = deepFreeze({ + appReferences: [], + config: emptyRouteConfig, + diagnostics: [], +}); + const extractedModuleMetadata = ( module: DiscoveredRouteModule, moduleText: string | undefined, - diagnostics: Diagnostic[], + projectRoot: string, ): ExtractedModuleMetadata => { if (moduleText === undefined) { - return { config: emptyRouteConfig }; + return { extracted: emptyExtractedRouteConfig }; } - const extracted = extractRouteConfig(moduleText, module.relativePath, module.source); - diagnostics.push(...extracted.diagnostics); + const extracted = extractRouteConfig(moduleText, module.relativePath, module.source, { projectRoot }); const inputSchema = extractInputSchema(moduleText, module.relativePath); return { - config: extracted.config, + extracted, ...(inputSchema === undefined ? {} : { inputSchema }), }; }; +/** + * Every App route of a generated server whose `resourceUri` extracted to a + * non-empty literal string, so `appResourceUri()` references elsewhere in + * the tree resolve to it. Apps of servers packaged as `custom`, `command`, + * `remote`, or left in `conflict` are never built or registered, and Apps + * whose own config was rejected (or whose `resourceUri` is itself an App + * reference) have no literal URI: a reference to any of them is AB4826 + * rather than a silently unavailable resource. + */ +const appReferenceTargets = ( + pending: readonly { readonly metadata: ExtractedModuleMetadata; readonly module: DiscoveredRouteModule }[], + serverModes: ReadonlyMap, +): readonly AppReferenceTarget[] => pending.flatMap(({ metadata, module }) => { + if (module.kind !== 'app' || serverModes.get(module.serverName!)?.mode !== 'generated') return []; + if (metadata.extracted.appReferences.some((reference) => reference.path[0] === 'resourceUri')) return []; + const resourceUri = metadata.extracted.config['resourceUri']; + return typeof resourceUri === 'string' && resourceUri.trim() !== '' + ? [{ id: module.id, resourceUri, source: module.source }] + : []; +}); + const routeIdentity = (route: CompiledAgentRoute): Readonly> => ({ config: route.config, ...(route.event === undefined ? {} : { event: route.event }), @@ -535,6 +600,10 @@ export const compileRouteGraph = async ( const cliRoutes: CompiledAgentRoute[] = []; const providers: CompiledProvider[] = []; const moduleTextBySource = new Map(); + // Config extraction runs over the whole tree before any route compiles: + // an `appResourceUri()` reference resolves against every App route the + // tree declares, wherever the App module sorts relative to its referrer. + const pending: { readonly metadata: ExtractedModuleMetadata; readonly module: DiscoveredRouteModule }[] = []; for (const module of modules) { if (module.surface === 'provider') { providers.push({ @@ -557,8 +626,35 @@ export const compileRouteGraph = async ( if (moduleText !== undefined) { moduleTextBySource.set(module.source, moduleText); } - const metadata = extractedModuleMetadata(module, moduleText, diagnostics); - const route = compiledRoute(module, metadata.config, metadata.inputSchema); + pending.push({ metadata: extractedModuleMetadata(module, moduleText, projectRoot), module }); + } + const serverModes = new Map(); + for (const { module } of pending) { + if (module.serverName !== undefined && !serverModes.has(module.serverName)) { + serverModes.set(module.serverName, decideServerMode(projectRoot, config, overrides, module.serverName)); + } + } + const appTargets = appReferenceTargets(pending, serverModes); + for (const { metadata, module } of pending) { + const moduleText = moduleTextBySource.get(module.source); + // Routes of a server that is not generated never ship their config: the + // mode diagnostic (AB4800) or explicit override is the actionable fact, + // so their App references are left as authored rather than reported. + const shipsConfig = module.serverName === undefined + || serverModes.get(module.serverName)?.mode === 'generated'; + const resolved = shipsConfig + ? resolveRouteConfigAppReferences( + metadata.extracted, + { + relativePath: module.relativePath, + ...(module.serverName === undefined ? {} : { serverName: module.serverName }), + source: module.source, + }, + appTargets, + ) + : metadata.extracted; + diagnostics.push(...resolved.diagnostics); + const route = compiledRoute(module, resolved.config, metadata.inputSchema); if (route.kind === 'event-route' && moduleText !== undefined) { diagnostics.push(...validateEventRouteModuleContract( moduleText, @@ -594,18 +690,9 @@ export const compileRouteGraph = async ( const servers: CompiledServerSurface[] = []; for (const [name, routes] of [...serverRoutes.entries()].sort(([left], [right]) => left.localeCompare(right))) { - const override = overrides.servers.get(name); - const declared = declaredMcpServer(config, name); - const declaredEntry = declared !== undefined && - (declared.entry !== undefined || declared.command !== undefined || declared.url !== undefined); - const conventionalEntry = conventionalEntryAt(projectRoot, 'src', 'mcp', name); - let mode: CompiledServerMode; - if (override === 'custom' || override === 'command' || override === 'remote') { - mode = override; - } else if (override === 'generated' || (conventionalEntry === undefined && !declaredEntry)) { - mode = 'generated'; - } else { - mode = 'conflict'; + // Every server with routes was decided before App references resolved. + const { conventionalEntry, mode } = serverModes.get(name)!; + if (mode === 'conflict') { const claim = conventionalEntry === undefined ? 'an explicit entry, command, or url in config' : `the conventional src/mcp/${name} entry module`; @@ -628,6 +715,39 @@ export const compileRouteGraph = async ( route.source, )); } + const template = route.config['template']; + if (typeof template === 'string') { + const resolution = resolveAppRouteTemplate(projectRoot, route.source, template); + switch (resolution.kind) { + case 'resolved': + break; + case 'ambiguous': + diagnostics.push(routeError( + 'AB4827', + `MCP App route ${route.provenance.relativePath} declares config.template ${JSON.stringify(template)}, which names two different existing files: ${resolution.routeRelative} (route-relative) and ${resolution.projectRelative} (project-root-relative).`, + 'Templates resolve relative to the route module; rewrite the path so it names the route-relative file only (or remove the project-root-relative duplicate), then inspect again.', + route.source, + )); + break; + case 'missing': { + // An absolute template has one candidate; name it once. + const candidates = resolution.routeRelative === resolution.projectRelative + ? `${resolution.routeRelative} does not exist` + : `neither ${resolution.routeRelative} (route-relative) nor ${resolution.projectRelative} (project-root-relative) exists`; + diagnostics.push(routeError( + 'AB4827', + `MCP App route ${route.provenance.relativePath} declares config.template ${JSON.stringify(template)}, but ${candidates}.`, + 'Templates resolve relative to the route module; point config.template at an existing HTML file beside the route (for example \'./dashboard.html\'), then inspect again.', + route.source, + )); + break; + } + default: { + const unreachable: never = resolution; + throw new TypeError(`Unhandled template resolution ${String(unreachable)}.`); + } + } + } continue; } const moduleText = moduleTextBySource.get(route.source); diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts index 5a9c52931..f338af4e2 100644 --- a/packages/agent-bundle/src/routes/index.ts +++ b/packages/agent-bundle/src/routes/index.ts @@ -3,8 +3,22 @@ export { cliArgvGrammar, extractCliArgv, reservedCliOptionNames } from './cli-ar export type { ExtractedCliArgv } from './cli-argv.ts'; export { cliCommandPath, compileCliCommands, isRenderedCliRoute } from './cli-commands.ts'; export type { CompiledCliCommandSurface } from './cli-commands.ts'; -export { extractRouteConfig, routeConfigGrammar } from './config-extract.ts'; -export type { ExtractedRouteConfig } from './config-extract.ts'; +export { + appResourceUriHelperName, + extractRouteConfig, + resolveRouteConfigAppReferences, + routeConfigGrammar, + routeHelpersSpecifier, +} from './config-extract.ts'; +export type { + AppReferenceSite, + AppReferenceTarget, + ExtractedRouteConfig, + RouteConfigAppReference, + RouteConfigExtractionOptions, +} from './config-extract.ts'; +export { appRouteTemplatePath, resolveAppRouteTemplate } from './app-template.ts'; +export type { AppRouteTemplateResolution } from './app-template.ts'; export { inspectRouteGraph } from './inspect.ts'; export type { RouteGraphInspection } from './inspect.ts'; export { emptyRouteConfig } from './types.ts'; @@ -31,7 +45,7 @@ export { validateRouteModuleContract, } from './contract.ts'; export type { RouteModuleExports } from './contract.ts'; -export { canonicalAgentEvents } from './public.ts'; +export { appResourceUri, canonicalAgentEvents } from './public.ts'; export type { AgentEventCanonicalIdentity, AgentEventDelivery, @@ -49,8 +63,10 @@ export type { CliRouteProps, PromptConfig, ResourceConfig, + RouteMeta, RouteSchema, RouteSchemaOutput, + RouteUiMeta, ToolConfig, ToolRouteProps, } from './public.ts'; diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 8c241f506..2d045b109 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -127,8 +127,51 @@ export interface ToolRouteProps { readonly signal: AbortSignal; } +/** + * The MCP Apps `_meta.ui` block a tool, resource, or prompt stamps on its + * listing so hosts open the referenced App beside the result. Set + * `resourceUri` to {@link appResourceUri} of the App route instead of + * repeating the App's `ui://` literal: the compiler resolves the reference + * to the App's static `config.resourceUri`, so the two can never drift. The + * block stays open for the rest of the MCP Apps `ui` vocabulary + * (`prefersBorder`, `csp`, `permissions`, …). + */ +export interface RouteUiMeta { + readonly [key: string]: unknown; + readonly resourceUri?: string; +} + +/** + * Listing-level `_meta` of an MCP route. It stays inside the static + * route-config grammar (see the diagnostics reference), with `ui` typed so + * `_meta.ui.resourceUri` can reference an App route. + */ +export type RouteMeta = Readonly> & { + readonly ui?: RouteUiMeta; +}; + +/** + * References an MCP App route's `config.resourceUri` from another route's + * static `config` (typically `_meta.ui.resourceUri`). The App must belong to + * the same generated server as the referencing route — a generated server + * registers only its own Apps. Accepted references: + * + * - `''` — the App route `src/mcp//apps/.{ts,tsx}` of the + * referencing route's server; + * - `'/'` or `'app:/'` — the same App by route id; + * - `'./…'` or `'../…'` — the App route module relative to the referencing + * module, with or without its `.ts`/`.tsx` extension. + * + * The compiler evaluates the call statically while extracting `config` and + * replaces it with the target App's `resourceUri`; a reference to no App of + * the same server is `AB4826`. The route module still evaluates at run time (generated entries + * import it for its default export), where the call returns the reference + * unchanged — generated servers read the compiled config, never this value. + */ +export const appResourceUri = (reference: string): string => reference; + export interface ToolConfig { - readonly _meta?: Readonly>; + readonly _meta?: RouteMeta; readonly annotations?: Readonly>; readonly description?: string; /** Project a validated result's integer `exitCode` when this tool is exposed through the generated CLI. */ @@ -137,7 +180,7 @@ export interface ToolConfig { } export interface ResourceConfig { - readonly _meta?: Readonly>; + readonly _meta?: RouteMeta; readonly description?: string; readonly mimeType?: string; readonly title?: string; @@ -145,15 +188,22 @@ export interface ResourceConfig { } export interface PromptConfig { - readonly _meta?: Readonly>; + readonly _meta?: RouteMeta; readonly description?: string; readonly title?: string; } export interface AppRouteConfig { - readonly _meta?: Readonly>; + readonly _meta?: RouteMeta; readonly resourceUri: string; readonly targets?: readonly string[]; + /** + * Optional HTML shell for the compiled App. The path resolves relative to + * the route module, the way its imports do (`'./dashboard.html'`); the + * older project-root-relative form is still accepted while only one of the + * two interpretations names an existing file. When both exist and differ, + * or neither exists, the build fails with `AB4827` naming both candidates. + */ readonly template?: string; } diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index 028d175be..734366eea 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -400,6 +400,99 @@ it('augments a generated server from config and projects result _meta and text-o } }); +it('compiles appResourceUri() and imported-const references to the App route resourceUri and a route-relative template (#388)', { retry: 2, timeout: 60_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-app-refs-')); + roots.push(root); + await writeGeneratedProject(root, { + // The one literal: the App route's own resourceUri, itself an imported const. + 'src/mcp/curator/constants.ts': "export const DASHBOARD_URI = 'ui://generated-routes-fixture/dashboard.html';\n", + 'src/mcp/curator/apps/dashboard.ts': [ + "import { DASHBOARD_URI } from '../constants.ts';", + "export const config = { resourceUri: DASHBOARD_URI, template: './dashboard.html' };", + "document.getElementById('shell').textContent = 'Curator dashboard';", + '', + ].join('\n'), + // Route-relative template: resolves beside the route module like its imports. + 'src/mcp/curator/apps/dashboard.html': 'route-relative-shell
\n', + // The tool references the App through the compile-time helper (run-time + // import from the light routes subpath, never the compiler root). + 'src/mcp/curator/tools/open.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { appResourceUri } from 'agent-bundle/routes';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + "export const config = { _meta: { ui: { resourceUri: appResourceUri('dashboard') } }, description: 'Open the dashboard.' };", + 'export const inputSchema = z.object({}).strict();', + "export const resultSchema = z.object({ status: z.literal('ready') }).strict();", + 'export default async function Open() {', + " return createElement(Agent.Result, { value: { status: 'ready' } }, createElement(Agent.Text, null, 'ready'));", + '}', + '', + ].join('\n'), + // The resource references the same constant module the App reads. + 'src/mcp/curator/resources/catalog.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + "import { DASHBOARD_URI } from '../constants';", + "export const config = { _meta: { ui: { resourceUri: DASHBOARD_URI } }, description: 'Read the catalog.', mimeType: 'application/json', uri: 'catalog://books' };", + 'export const inputSchema = z.object({ uri: z.string() }).strict();', + 'export const resultSchema = z.object({ contents: z.array(z.object({ mimeType: z.string(), text: z.string(), uri: z.string() })) }).strict();', + 'export default async function Catalog({ input }) {', + " return createElement(Agent.Result, { value: { contents: [{ mimeType: 'application/json', text: '{}', uri: input.uri }] } }, createElement(Agent.Text, null, 'Catalog ready.'));", + '}', + '', + ].join('\n'), + }); + + const output = join(root, 'artifact'); + const compiled = await build({ output, root, targets: ['portable'] }); + expect(compiled.model.mcpApps?.map((app) => ({ id: app.id, resourceUri: app.resourceUri, template: app.template }))).toEqual([{ + id: 'mcp-app:curator:dashboard', + resourceUri: 'ui://generated-routes-fixture/dashboard.html', + template: join(root, 'src/mcp/curator/apps/dashboard.html'), + }]); + const compiledTool = compiled.model.mcpServers[0]!.generatedRoutes!.find((route) => route.id === 'tool:curator/open'); + expect(compiledTool?.config).toEqual({ + _meta: { ui: { resourceUri: 'ui://generated-routes-fixture/dashboard.html' } }, + description: 'Open the dashboard.', + }); + // The compiled App HTML came from the route-relative template. + const html = await readFile(join(output, 'portable', 'mcp-apps', 'dashboard.html'), 'utf8'); + expect(html).toContain('route-relative-shell'); + expect(html).toContain('Curator dashboard'); + + const server = compiled.model.mcpServers[0]!; + const client = new Client({ name: 'generated-app-refs-test', version: '0.0.0' }); + const transport = new StdioClientTransport({ + args: [join(output, 'portable', server.args![0]!)], + command: process.execPath, + stderr: 'pipe', + }); + let diagnostics = ''; + transport.stderr?.on('data', (chunk) => { diagnostics += String(chunk); }); + try { + try { + await client.connect(transport); + } catch (error) { + throw new Error(`Generated route server failed to connect: ${diagnostics}`, { cause: error }); + } + const listed = await client.listTools(); + expect(listed.tools.find((tool) => tool.name === 'open')).toMatchObject({ + _meta: { ui: { resourceUri: 'ui://generated-routes-fixture/dashboard.html' } }, + }); + const resources = await client.listResources(); + expect(resources.resources.find((resource) => resource.uri === 'catalog://books')).toMatchObject({ + _meta: { ui: { resourceUri: 'ui://generated-routes-fixture/dashboard.html' } }, + }); + await expect(client.readResource({ uri: 'ui://generated-routes-fixture/dashboard.html' })).resolves.toMatchObject({ + contents: [{ text: expect.stringContaining('route-relative-shell'), uri: 'ui://generated-routes-fixture/dashboard.html' }], + }); + } finally { + await client.close(); + } +}); + it('observes one process-lifetime provider across consecutive generated tool calls', { retry: 2, timeout: 60_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-generated-warm-')); roots.push(root); diff --git a/packages/agent-bundle/tests/route-config-extract.test.ts b/packages/agent-bundle/tests/route-config-extract.test.ts index b98381b10..2c6a8f5e9 100644 --- a/packages/agent-bundle/tests/route-config-extract.test.ts +++ b/packages/agent-bundle/tests/route-config-extract.test.ts @@ -1,10 +1,39 @@ import { expect, it } from '@rstest/core'; -import { extractRouteConfig } from '../src/routes/config-extract.ts'; +import { + extractRouteConfig, + resolveRouteConfigAppReferences, + type RouteConfigExtractionOptions, +} from '../src/routes/config-extract.ts'; +import { appResourceUri, type AppRouteConfig, type ToolConfig } from '../src/routes/public.ts'; import { emptyRouteConfig } from '../src/routes/types.ts'; -const extract = (text: string, relativePath = 'src/mcp/notes/tools/search.ts') => - extractRouteConfig(text, relativePath, `/project/${relativePath}`); +it('types _meta.ui.resourceUri while keeping the MCP Apps ui block open, and returns the reference at run time', () => { + const tool = { + _meta: { + 'openai/outputTemplate': 'ui://notes/dashboard.html', + ui: { csp: { connectDomains: [] }, prefersBorder: true, resourceUri: appResourceUri('dashboard') }, + }, + description: 'Open the dashboard.', + } satisfies ToolConfig; + const app = { resourceUri: 'ui://notes/dashboard.html', template: './dashboard.html' } satisfies AppRouteConfig; + expect(tool._meta.ui.resourceUri).toBe('dashboard'); + expect(app.template).toBe('./dashboard.html'); +}); + +const extract = ( + text: string, + relativePath = 'src/mcp/notes/tools/search.ts', + options: RouteConfigExtractionOptions = {}, +) => extractRouteConfig(text, relativePath, `/project/${relativePath}`, options); + +const codes = (diagnostics: readonly { readonly code: string }[]): string[] => diagnostics.map((diagnostic) => diagnostic.code); + +/** An in-memory project tree standing in for the sibling modules a const reference imports. */ +const virtualProject = (files: Readonly>): RouteConfigExtractionOptions => ({ + projectRoot: '/project', + readModule: (path) => files[path], +}); it('extracts the accepted literal grammar into a frozen config', () => { const { config, diagnostics } = extract([ @@ -77,8 +106,256 @@ it('extracts silently to the empty config when no config export exists', () => { expect(config).toBe(emptyRouteConfig); }); +it('resolves a same-module top-level const string literal, exported or not', () => { + const { appReferences, config, diagnostics } = extract([ + "const APP_URI = 'ui://notes/panel.html' as const;", + 'export const TITLE = (`Search notes`);', + 'export const config = { _meta: { ui: { resourceUri: APP_URI } }, title: TITLE };', + 'export default () => null;', + ].join('\n')); + expect(diagnostics).toEqual([]); + expect(appReferences).toEqual([]); + expect(config).toEqual({ _meta: { ui: { resourceUri: 'ui://notes/panel.html' } }, title: 'Search notes' }); +}); + +it('resolves an exported const string literal imported from a relative sibling module', () => { + const project = virtualProject({ + '/project/src/mcp/notes/constants.ts': [ + "export const APP_RESOURCE_URI = 'ui://notes/panel.html';", + "export const OTHER = 'unused';", + '', + ].join('\n'), + '/project/src/shared/index.ts': "export const SHARED_TITLE = 'Shared' as const;\n", + }); + const { config, diagnostics } = extract([ + "import { APP_RESOURCE_URI as URI } from '../constants.js';", + "import { SHARED_TITLE } from '../../../shared';", + "import type { ToolConfig } from 'agent-bundle';", + 'export const config = { _meta: { ui: { resourceUri: URI } }, title: SHARED_TITLE } satisfies ToolConfig;', + 'export default () => null;', + ].join('\n'), 'src/mcp/notes/tools/search.ts', project); + expect(diagnostics).toEqual([]); + expect(config).toEqual({ _meta: { ui: { resourceUri: 'ui://notes/panel.html' } }, title: 'Shared' }); +}); + +it.each([ + [ + 'a bare package specifier', + "import { URI } from 'my-constants';", + {}, + 'imported from "my-constants", which is not a relative module path', + ], + [ + 'a missing sibling module', + "import { URI } from './missing';", + {}, + 'imported from "./missing", which does not resolve to a module inside the project', + ], + [ + 'a module outside the project root', + "import { URI } from '../../../../../outside';", + { '/outside.ts': "export const URI = 'ui://x/y.html';\n" }, + 'imported from "../../../../../outside", which resolves outside the project', + ], + [ + 'a sibling without that export', + "import { URI } from './constants';", + { '/project/src/mcp/notes/tools/constants.ts': "const URI = 'ui://x/y.html';\nexport const OTHER = 1;\n" }, + 'which does not declare a top-level `export const URI`', + ], + [ + 'a sibling whose const is not a string literal', + "import { URI } from './constants';", + { '/project/src/mcp/notes/tools/constants.ts': "export const URI = `ui://${'x'}/y.html`;\n" }, + 'whose `export const URI` initializer is not a string literal', + ], + [ + 'a type-only import', + "import type { URI } from './constants';", + { '/project/src/mcp/notes/tools/constants.ts': "export const URI = 'ui://x/y.html';\n" }, + 'neither a top-level const string literal in this module nor a named import', + ], + [ + 'a default import', + "import URI from './constants';", + { '/project/src/mcp/notes/tools/constants.ts': "export default 'ui://x/y.html';\n" }, + 'which is not a top-level `const` string literal', + ], +])('keeps an identifier through %s dynamic (AB4806) and names both supported forms', (_name, importLine, files, fragment) => { + const { config, diagnostics } = extract([ + importLine, + 'export const config = { _meta: { ui: { resourceUri: URI } } };', + ].join('\n'), 'src/mcp/notes/tools/search.ts', virtualProject(files)); + expect(config).toBe(emptyRouteConfig); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ code: 'AB4806', severity: 'error' }); + expect(diagnostics[0]!.message).toContain('a reference to the identifier "URI"'); + expect(diagnostics[0]!.message).toContain(fragment); + expect(diagnostics[0]!.recovery).toContain("appResourceUri('')"); + expect(diagnostics[0]!.recovery).toContain('agent-bundle/routes'); + expect(diagnostics[0]!.recovery).toContain('const string literal'); +}); + +it('records appResourceUri() references for the graph compiler and resolves them to the App resourceUri', () => { + const extracted = extract([ + "import { appResourceUri as ref } from 'agent-bundle/routes';", + "const DASHBOARD = 'dashboard';", + 'export const config = {', + " _meta: { ui: { resourceUri: ref('dashboard') } },", + " related: [ref('notes/dashboard'), ref(DASHBOARD)],", + " title: 'Search',", + '};', + 'export default () => null;', + ].join('\n')); + expect(extracted.diagnostics).toEqual([]); + expect(extracted.appReferences).toEqual([ + { path: ['_meta', 'ui', 'resourceUri'], position: '4:31', reference: 'dashboard' }, + { path: ['related', 0], position: '5:13', reference: 'notes/dashboard' }, + { path: ['related', 1], position: '5:37', reference: 'dashboard' }, + ]); + // Until resolution the reference text stands in, matching the run-time helper. + expect(extracted.config).toEqual({ + _meta: { ui: { resourceUri: 'dashboard' } }, + related: ['notes/dashboard', 'dashboard'], + title: 'Search', + }); + expect(Object.isFrozen(extracted.appReferences)).toBe(true); + + const site = { relativePath: 'src/mcp/notes/tools/search.ts', serverName: 'notes', source: '/project/src/mcp/notes/tools/search.ts' }; + const apps = [{ id: 'app:notes/dashboard', resourceUri: 'ui://notes/dashboard.html', source: '/project/src/mcp/notes/apps/dashboard.tsx' }]; + const resolved = resolveRouteConfigAppReferences(extracted, site, apps); + expect(resolved.diagnostics).toEqual([]); + expect(resolved.appReferences).toEqual([]); + expect(resolved.config).toEqual({ + _meta: { ui: { resourceUri: 'ui://notes/dashboard.html' } }, + related: ['ui://notes/dashboard.html', 'ui://notes/dashboard.html'], + title: 'Search', + }); + expect(Object.isFrozen(resolved.config)).toBe(true); + expect(Object.isFrozen((resolved.config as { _meta: { ui: object } })._meta.ui)).toBe(true); +}); + +const notesApps = [ + { id: 'app:notes/dashboard', resourceUri: 'ui://notes/dashboard.html', source: '/project/src/mcp/notes/apps/dashboard.tsx' }, + { id: 'app:notes/foo.bar', resourceUri: 'ui://notes/foo.bar.html', source: '/project/src/mcp/notes/apps/foo.bar.ts' }, +]; + +it.each([ + ['the route id', "appResourceUri('app:notes/dashboard')", 'ui://notes/dashboard.html'], + ['a relative module path without extension', "appResourceUri('../apps/dashboard')", 'ui://notes/dashboard.html'], + ['a relative module path with extension', "appResourceUri('../apps/dashboard.tsx')", 'ui://notes/dashboard.html'], + ['a relative module path with a .js-style extension', "appResourceUri('../apps/dashboard.jsx')", 'ui://notes/dashboard.html'], + ['a dotted App name without extension', "appResourceUri('../apps/foo.bar')", 'ui://notes/foo.bar.html'], + ['a dotted App name with extension', "appResourceUri('./../apps/foo.bar.ts')", 'ui://notes/foo.bar.html'], +])('resolves an App reference written as %s', (_name, call, resourceUri) => { + const extracted = extract([ + "import { appResourceUri } from 'agent-bundle/routes';", + `export const config = { _meta: { ui: { resourceUri: ${call} } } };`, + ].join('\n')); + const resolved = resolveRouteConfigAppReferences( + extracted, + { relativePath: 'src/mcp/notes/tools/search.ts', serverName: 'notes', source: '/project/src/mcp/notes/tools/search.ts' }, + notesApps, + ); + expect(resolved.diagnostics).toEqual([]); + expect(resolved.config).toEqual({ _meta: { ui: { resourceUri } } }); +}); + +it.each([ + ['a mistyped extension', "appResourceUri('../apps/dashboard.tss')"], + ['the wrong route-module extension', "appResourceUri('../apps/dashboard.ts')"], + ['a path into another directory', "appResourceUri('./dashboard')"], +])('rejects a relative App reference with %s (AB4826)', (_name, call) => { + const extracted = extract([ + "import { appResourceUri } from 'agent-bundle/routes';", + `export const config = { _meta: { ui: { resourceUri: ${call} } } };`, + ].join('\n')); + const resolved = resolveRouteConfigAppReferences( + extracted, + { relativePath: 'src/mcp/notes/tools/search.ts', serverName: 'notes', source: '/project/src/mcp/notes/tools/search.ts' }, + notesApps, + ); + expect(codes(resolved.diagnostics)).toEqual(['AB4826']); + expect(resolved.config).toBe(emptyRouteConfig); +}); + +it('diagnoses an App reference that matches no App route (AB4826) and drops the config', () => { + const extracted = extract([ + "import { appResourceUri } from 'agent-bundle/routes';", + "export const config = { _meta: { ui: { resourceUri: appResourceUri('missing') } }, title: 'Search' };", + ].join('\n')); + expect(extracted.diagnostics).toEqual([]); + const resolved = resolveRouteConfigAppReferences( + extracted, + { relativePath: 'src/mcp/notes/tools/search.ts', serverName: 'notes', source: '/project/src/mcp/notes/tools/search.ts' }, + [{ id: 'app:notes/dashboard', resourceUri: 'ui://notes/dashboard.html', source: '/project/src/mcp/notes/apps/dashboard.tsx' }], + ); + expect(resolved.config).toBe(emptyRouteConfig); + expect(resolved.diagnostics).toHaveLength(1); + expect(resolved.diagnostics[0]).toMatchObject({ + code: 'AB4826', + severity: 'error', + sourcePath: '/project/src/mcp/notes/tools/search.ts', + }); + expect(resolved.diagnostics[0]!.message).toContain('references MCP App "missing" at 2:53'); + expect(resolved.diagnostics[0]!.message).toContain('known App routes of "notes": app:notes/dashboard'); + + // Outside an MCP server there is no generated server to register an App on. + const fromScript = resolveRouteConfigAppReferences( + extracted, + { relativePath: 'src/scripts/report.ts', source: '/project/src/scripts/report.ts' }, + notesApps, + ); + expect(codes(fromScript.diagnostics)).toEqual(['AB4826']); + expect(fromScript.diagnostics[0]!.message).toContain('App references resolve only from MCP route modules'); + + // Another server's App is never a target, even through the qualified forms. + const crossServer = extract([ + "import { appResourceUri } from 'agent-bundle/routes';", + "export const config = { _meta: { ui: { resourceUri: appResourceUri('app:notes/dashboard') } } };", + ].join('\n'), 'src/mcp/reporter/tools/summarize.ts'); + const fromOtherServer = resolveRouteConfigAppReferences( + crossServer, + { relativePath: 'src/mcp/reporter/tools/summarize.ts', serverName: 'reporter', source: '/project/src/mcp/reporter/tools/summarize.ts' }, + notesApps, + ); + expect(codes(fromOtherServer.diagnostics)).toEqual(['AB4826']); + expect(fromOtherServer.diagnostics[0]!.message).toContain('which is app:notes/dashboard on another server'); + expect(fromOtherServer.config).toBe(emptyRouteConfig); + + // A server without any App names that in the message. + const noApps = resolveRouteConfigAppReferences( + extracted, + { relativePath: 'src/mcp/notes/tools/search.ts', serverName: 'notes', source: '/project/src/mcp/notes/tools/search.ts' }, + [], + ); + expect(noApps.diagnostics[0]!.message).toContain('no App route of the generated "notes" server declares a static config.resourceUri'); +}); + +it.each([ + ['not imported', '', "appResourceUri('dashboard')", 'a call to "appResourceUri" that is not imported from agent-bundle/routes'], + ['imported from the wrong specifier', "import { appResourceUri } from 'agent-bundle';", "appResourceUri('dashboard')", 'imported from "agent-bundle" instead of agent-bundle/routes'], + ['called with a non-string', "import { appResourceUri } from 'agent-bundle/routes';", 'appResourceUri(1)', 'whose argument is not a non-empty string'], + ['called with two arguments', "import { appResourceUri } from 'agent-bundle/routes';", "appResourceUri('a', 'b')", 'without exactly one string argument'], + ['called with a dynamic argument', "import { appResourceUri } from 'agent-bundle/routes';", 'appResourceUri(name)', 'a reference to the identifier "name"'], +])('keeps an appResourceUri call that is %s dynamic (AB4806)', (_name, importLine, call, fragment) => { + const { config, diagnostics } = extract([ + importLine, + `export const config = { _meta: { ui: { resourceUri: ${call} } } };`, + ].join('\n')); + expect(config).toBe(emptyRouteConfig); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ code: 'AB4806' }); + expect(diagnostics[0]!.message).toContain(fragment); +}); + it.each([ ['identifier reference', "const base = {};\nexport const config = base;", 'AB4806', 'reference to the identifier "base"'], + ['let-bound identifier', "let title = 'x';\nexport const config = { title };", 'AB4806', 'a shorthand property reference'], + ['let-bound identifier value', "let title = 'x';\nexport const config = { title: title };", 'AB4806', 'which is not a top-level `const` string literal'], + ['unknown identifier', 'export const config = { title: missing };', 'AB4806', 'neither a top-level const string literal in this module nor a named import'], + ['non-string const', 'const limit = 3;\nexport const config = { limit };', 'AB4806', 'a shorthand property reference'], + ['non-string const value', 'const limit = 3;\nexport const config = { limit: limit };', 'AB4806', 'whose top-level const initializer is not a string literal'], ['call expression', 'export const config = make();', 'AB4806', 'a call expression'], ['template substitution', 'export const config = { title: `v${1}` };', 'AB4806', 'a template literal with substitutions'], ['object spread', 'export const config = { ...rest };', 'AB4806', 'a spread'], diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 4367e7a17..f302afa02 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -679,6 +679,329 @@ it('compiles dynamic-config routes with an empty config beside the named error', expect(graph.servers[0]!.routes[0]!.config).toBe(emptyRouteConfig); }); +it('resolves appResourceUri() references and imported const identifiers to the App route resourceUri', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/mcp/curator/apps/dashboard.tsx': [ + "import { APP_RESOURCE_URI } from '../constants.ts';", + "export const config = { resourceUri: APP_RESOURCE_URI, template: './dashboard.html' };", + moduleSource, + ].join('\n'), + 'src/mcp/curator/apps/dashboard.html': '\n', + 'src/mcp/curator/constants.ts': "export const APP_RESOURCE_URI = 'ui://curator/dashboard.html';\n", + 'src/mcp/curator/prompts/curate.ts': [ + "import { appResourceUri } from 'agent-bundle/routes';", + "export const config = { _meta: { ui: { resourceUri: appResourceUri('app:curator/dashboard') } } };", + moduleSource, + ].join('\n'), + 'src/mcp/curator/resources/catalog.ts': [ + "import { APP_RESOURCE_URI as URI } from '../constants';", + "export const config = { _meta: { ui: { resourceUri: URI } }, uri: 'catalog://books' };", + moduleSource, + ].join('\n'), + 'src/mcp/curator/tools/inspect.ts': [ + "import { appResourceUri as app } from 'agent-bundle/routes';", + "export const config = { _meta: { ui: { resourceUri: app('dashboard') } }, related: [app('../apps/dashboard'), app('curator/dashboard')] };", + moduleSource, + ].join('\n'), + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + expect(graph.diagnostics).toEqual([]); + const configs = Object.fromEntries(graph.servers.flatMap((server) => server.routes.map((route) => [route.id, route.config]))); + expect(configs).toEqual({ + 'app:curator/dashboard': { resourceUri: 'ui://curator/dashboard.html', template: './dashboard.html' }, + 'prompt:curator/curate': { _meta: { ui: { resourceUri: 'ui://curator/dashboard.html' } } }, + 'resource:curator/catalog': { _meta: { ui: { resourceUri: 'ui://curator/dashboard.html' } }, uri: 'catalog://books' }, + 'tool:curator/inspect': { _meta: { ui: { resourceUri: 'ui://curator/dashboard.html' } }, related: ['ui://curator/dashboard.html', 'ui://curator/dashboard.html'] }, + }); + expect(Object.isFrozen(configs['tool:curator/inspect'])).toBe(true); + + // The resolved URI, not the reference text, is what the digest and the + // generated server see: renaming the App's URI changes the graph identity. + const renamed = await createRoot(); + await writeTree(renamed, { + 'src/mcp/curator/apps/dashboard.tsx': [ + "export const config = { resourceUri: 'ui://curator/panel.html' };", + moduleSource, + ].join('\n'), + 'src/mcp/curator/tools/inspect.ts': [ + "import { appResourceUri } from 'agent-bundle/routes';", + "export const config = { _meta: { ui: { resourceUri: appResourceUri('dashboard') } } };", + moduleSource, + ].join('\n'), + }); + const renamedGraph = await compileRouteGraph(renamed, fixtureConfig()); + expect(renamedGraph.diagnostics).toEqual([]); + expect(renamedGraph.servers[0]!.routes.find((route) => route.kind === 'tool')!.config) + .toEqual({ _meta: { ui: { resourceUri: 'ui://curator/panel.html' } } }); +}); + +it('diagnoses an appResourceUri() reference to an unknown App with AB4826 and keeps non-literal identifiers AB4806', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/mcp/curator/apps/dashboard.tsx': [ + "export const config = { resourceUri: 'ui://curator/dashboard.html' };", + moduleSource, + ].join('\n'), + 'src/mcp/curator/tools/inspect.ts': [ + "import { appResourceUri } from 'agent-bundle/routes';", + "export const config = { _meta: { ui: { resourceUri: appResourceUri('panel') } } };", + moduleSource, + ].join('\n'), + 'src/mcp/curator/tools/search.ts': [ + "import { APP_RESOURCE_URI } from '../constants.ts';", + 'export const config = { _meta: { ui: { resourceUri: APP_RESOURCE_URI } } };', + moduleSource, + ].join('\n'), + 'src/mcp/curator/constants.ts': "export const APP_RESOURCE_URI = process.env.APP_URI ?? 'ui://curator/dashboard.html';\n", + }); + + const graph = await compileRouteGraph(root, fixtureConfig()); + expect(codesOf(graph.diagnostics).sort()).toEqual(['AB4806', 'AB4826']); + const unknownApp = graph.diagnostics.find((diagnostic) => diagnostic.code === 'AB4826')!; + expect(unknownApp.sourcePath).toBe(join(root, 'src/mcp/curator/tools/inspect.ts')); + expect(unknownApp.message).toContain('references MCP App "panel"'); + expect(unknownApp.message).toContain('known App routes of "curator": app:curator/dashboard'); + const nonLiteral = graph.diagnostics.find((diagnostic) => diagnostic.code === 'AB4806')!; + expect(nonLiteral.sourcePath).toBe(join(root, 'src/mcp/curator/tools/search.ts')); + expect(nonLiteral.message).toContain('whose `export const APP_RESOURCE_URI` initializer is not a string literal'); + expect(nonLiteral.recovery).toContain("appResourceUri('')"); + const routes = graph.servers[0]!.routes; + expect(routes.find((route) => route.id === 'tool:curator/inspect')!.config).toBe(emptyRouteConfig); + expect(routes.find((route) => route.id === 'tool:curator/search')!.config).toBe(emptyRouteConfig); + expect(routes.find((route) => route.kind === 'app')!.config).toEqual({ resourceUri: 'ui://curator/dashboard.html' }); +}); + +it('resolves appResourceUri() references only against Apps of the same generated server', async () => { + const app = "export const config = { resourceUri: 'ui://curator/dashboard.html' };\n" + moduleSource; + const referencing = (reference: string): string => [ + "import { appResourceUri } from 'agent-bundle/routes';", + `export const config = { _meta: { ui: { resourceUri: appResourceUri('${reference}') } } };`, + moduleSource, + ].join('\n'); + + // A generated server registers only its own Apps, so a route on another + // server can never serve this URI: the qualified form is rejected too. + const crossServer = await createRoot(); + await writeTree(crossServer, { + 'src/mcp/curator/apps/dashboard.tsx': app, + 'src/mcp/reporter/tools/summarize.ts': referencing('curator/dashboard'), + }); + const crossServerGraph = await compileRouteGraph(crossServer, fixtureConfig()); + expect(codesOf(crossServerGraph.diagnostics)).toEqual(['AB4826']); + expect(crossServerGraph.diagnostics[0]!.sourcePath).toBe(join(crossServer, 'src/mcp/reporter/tools/summarize.ts')); + expect(crossServerGraph.diagnostics[0]!.message).toContain('which is app:curator/dashboard on another server'); + expect(crossServerGraph.diagnostics[0]!.message).toContain('"reporter" cannot serve it'); + expect(crossServerGraph.servers.find((server) => server.name === 'reporter')!.routes[0]!.config).toBe(emptyRouteConfig); + + // Non-MCP routes have no generated server to register an App on. + const script = await createRoot(); + await writeTree(script, { + 'src/mcp/curator/apps/dashboard.tsx': app, + 'src/scripts/report.ts': referencing('curator/dashboard'), + }); + const scriptGraph = await compileRouteGraph(script, fixtureConfig()); + expect(codesOf(scriptGraph.diagnostics)).toEqual(['AB4826']); + expect(scriptGraph.diagnostics[0]!.message).toContain('App references resolve only from MCP route modules'); + + // A server kept custom by override ships no route config at all, so its + // references are neither resolved nor reported; the override is the fact. + const custom = await createRoot(); + await writeTree(custom, { + 'src/mcp/curator/apps/dashboard.tsx': app, + 'src/mcp/curator/tools/open.ts': referencing('dashboard'), + }); + const customGraph = await compileRouteGraph(custom, fixtureConfig({ routes: { servers: { curator: 'custom' } } })); + expect(customGraph.diagnostics).toEqual([]); + expect(customGraph.servers[0]).toMatchObject({ mode: 'custom', routes: [] }); + + // An unresolved entry conflict reports AB4800 alone; the routes stay visible + // with their authored reference until the mode is decided. + const conflict = await createRoot(); + await writeTree(conflict, { + 'src/mcp/curator.ts': moduleSource, + 'src/mcp/curator/apps/dashboard.tsx': app, + 'src/mcp/curator/tools/open.ts': referencing('dashboard'), + }); + const conflictGraph = await compileRouteGraph(conflict, fixtureConfig()); + expect(codesOf(conflictGraph.diagnostics)).toEqual(['AB4800']); + expect(conflictGraph.servers[0]!.routes.find((route) => route.kind === 'tool')!.config) + .toEqual({ _meta: { ui: { resourceUri: 'dashboard' } } }); + + // The explicit generated override resolves the reference. + const generated = await createRoot(); + await writeTree(generated, { + 'src/mcp/curator.ts': moduleSource, + 'src/mcp/curator/apps/dashboard.tsx': app, + 'src/mcp/curator/tools/open.ts': referencing('app:curator/dashboard'), + }); + const generatedGraph = await compileRouteGraph(generated, fixtureConfig({ routes: { servers: { curator: 'generated' } } })); + expect(generatedGraph.diagnostics).toEqual([]); + expect(generatedGraph.servers[0]!.routes.find((route) => route.kind === 'tool')!.config) + .toEqual({ _meta: { ui: { resourceUri: 'ui://curator/dashboard.html' } } }); +}); + +it('resolves an App route template relative to the route module, accepting the legacy root-relative form only when unambiguous', async () => { + const app = (template: string): string => [ + `export const config = { resourceUri: 'ui://curator/dashboard.html', template: '${template}' };`, + moduleSource, + ].join('\n'); + const html = '\n'; + const appOf = (graph: CompiledRouteGraph) => graph.servers[0]!.routes.find((route) => route.kind === 'app')!; + + // Route-relative: the documented form, resolved like the module's imports. + const routeRelative = await createRoot(); + await writeTree(routeRelative, { + 'src/mcp/curator/apps/dashboard.html': html, + 'src/mcp/curator/apps/dashboard.tsx': app('./dashboard.html'), + }); + const routeRelativeGraph = await compileRouteGraph(routeRelative, fixtureConfig()); + expect(routeRelativeGraph.diagnostics).toEqual([]); + expect(appOf(routeRelativeGraph).config).toEqual({ resourceUri: 'ui://curator/dashboard.html', template: './dashboard.html' }); + + // Legacy project-root-relative: still accepted while it is the only match, without a diagnostic. + const rootRelative = await createRoot(); + await writeTree(rootRelative, { + 'src/mcp/curator/apps/dashboard.tsx': app('./views/dashboard.html'), + 'views/dashboard.html': html, + }); + expect((await compileRouteGraph(rootRelative, fixtureConfig())).diagnostics).toEqual([]); + + // Both interpretations name different existing files: AB4827 names both. + const ambiguous = await createRoot(); + await writeTree(ambiguous, { + 'src/mcp/curator/apps/dashboard.tsx': app('./views/dashboard.html'), + 'src/mcp/curator/apps/views/dashboard.html': html, + 'views/dashboard.html': html, + }); + const ambiguousGraph = await compileRouteGraph(ambiguous, fixtureConfig()); + expect(codesOf(ambiguousGraph.diagnostics)).toEqual(['AB4827']); + expect(ambiguousGraph.diagnostics[0]).toMatchObject({ severity: 'error', sourcePath: join(ambiguous, 'src/mcp/curator/apps/dashboard.tsx') }); + expect(ambiguousGraph.diagnostics[0]!.message).toContain('names two different existing files'); + expect(ambiguousGraph.diagnostics[0]!.message).toContain(`${join(ambiguous, 'src/mcp/curator/apps/views/dashboard.html')} (route-relative)`); + expect(ambiguousGraph.diagnostics[0]!.message).toContain(`${join(ambiguous, 'views/dashboard.html')} (project-root-relative)`); + expect(ambiguousGraph.diagnostics[0]!.recovery).toContain('relative to the route module'); + + // Neither exists: AB4827 names both candidates and the fix. + const missing = await createRoot(); + await writeTree(missing, { 'src/mcp/curator/apps/dashboard.tsx': app('./dashboard.html') }); + const missingGraph = await compileRouteGraph(missing, fixtureConfig()); + expect(codesOf(missingGraph.diagnostics)).toEqual(['AB4827']); + expect(missingGraph.diagnostics[0]!.message).toContain('but neither'); + expect(missingGraph.diagnostics[0]!.message).toContain(`${join(missing, 'src/mcp/curator/apps/dashboard.html')} (route-relative)`); + expect(missingGraph.diagnostics[0]!.message).toContain(`${join(missing, 'dashboard.html')} (project-root-relative)`); + + // An absolute template has a single candidate, which still has to exist. + const absolute = await createRoot(); + await writeTree(absolute, { 'src/mcp/curator/apps/dashboard.tsx': app(join(absolute, 'shell', 'missing.html')) }); + const absoluteGraph = await compileRouteGraph(absolute, fixtureConfig()); + expect(codesOf(absoluteGraph.diagnostics)).toEqual(['AB4827']); + expect(absoluteGraph.diagnostics[0]!.message).toContain(`but ${join(absolute, 'shell', 'missing.html')} does not exist.`); + const absolutePresent = await createRoot(); + await writeTree(absolutePresent, { + 'shell/dashboard.html': html, + 'src/mcp/curator/apps/dashboard.tsx': app(join(absolutePresent, 'shell', 'dashboard.html')), + }); + expect((await compileRouteGraph(absolutePresent, fixtureConfig())).diagnostics).toEqual([]); + + // The same tree in another checkout digests identically: the template stays + // the authored path in the IR, and only its resolution is machine-specific. + const twin = await createRoot(); + await writeTree(twin, { + 'src/mcp/curator/apps/dashboard.html': html, + 'src/mcp/curator/apps/dashboard.tsx': app('./dashboard.html'), + }); + expect((await compileRouteGraph(twin, fixtureConfig())).digest).toBe(routeRelativeGraph.digest); +}); + +it('rejects a route that advertises an App the server does not build for every target with AB4828', async () => { + const tool = (resourceUri: string): string => [ + `export const config = { _meta: { ui: { resourceUri: ${resourceUri} } } };`, + moduleSource, + ].join('\n'); + const restrictedApp = "export const config = { resourceUri: 'ui://curator/dashboard.html', targets: ['codex'] };\ndocument.body.textContent = 'dashboard';\n"; + const configWith = (lines: readonly string[]): string => [ + 'export default {', + ...lines, + " plugin: { name: 'routes-fixture', version: '1.0.0' },", + " targets: ['portable', 'codex'],", + '};', + '', + ].join('\n'); + + // The App ships to codex only; the server (and its tool) ship to portable too. + const referenced = await createInspectProject({ + 'agent-bundle.config.ts': configWith([]), + 'src/mcp/curator/apps/dashboard.tsx': restrictedApp, + 'src/mcp/curator/tools/open.ts': tool("appResourceUri('dashboard')").replace('export const config', "import { appResourceUri } from 'agent-bundle/routes';\nexport const config"), + }); + const referencedErrors = (await validate({ root: referenced })).diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); + expect(codesOf(referencedErrors)).toEqual(['AB4828']); + expect(referencedErrors[0]).toMatchObject({ sourcePath: join(referenced, 'src/mcp/curator/tools/open.ts') }); + expect(referencedErrors[0]!.message).toContain('is not built for "portable"'); + expect(referencedErrors[0]!.recovery).toContain('mcp.servers.curator.targets'); + + // A hand-written literal is held to the same rule. + const literal = await createInspectProject({ + 'agent-bundle.config.ts': configWith([]), + 'src/mcp/curator/apps/dashboard.tsx': restrictedApp, + 'src/mcp/curator/tools/open.ts': tool("'ui://curator/dashboard.html'"), + }); + expect(codesOf((await validate({ root: literal })).diagnostics.filter((diagnostic) => diagnostic.severity === 'error'))).toEqual(['AB4828']); + + // Restricting the server to the App's targets makes the reference sound. + const restrictedServer = await createInspectProject({ + 'agent-bundle.config.ts': configWith([" mcp: { servers: { curator: { targets: ['codex'] } } },"]), + 'src/mcp/curator/apps/dashboard.tsx': restrictedApp, + 'src/mcp/curator/tools/open.ts': tool("appResourceUri('dashboard')").replace('export const config', "import { appResourceUri } from 'agent-bundle/routes';\nexport const config"), + }); + expect((await validate({ root: restrictedServer })).diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + + // A config-declared App of the same server is covered too. + const configApp = await createInspectProject({ + 'agent-bundle.config.ts': configWith([ + " mcp: { servers: { curator: { apps: { panel: { entry: './views/panel.ts', resourceUri: 'ui://curator/panel.html', targets: ['codex'] } } } } },", + ]), + 'src/mcp/curator/tools/open.ts': tool("'ui://curator/panel.html'"), + 'views/panel.ts': "document.body.textContent = 'panel';\n", + }); + const configAppErrors = (await validate({ root: configApp })).diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); + expect(codesOf(configAppErrors)).toEqual(['AB4828']); + expect(configAppErrors[0]!.message).toContain('config App "panel"'); +}); + +it('normalizes the App route template to its resolved path for the build', async () => { + const html = '\n'; + const routeRelative = await createInspectProject({ + 'src/mcp/curator/apps/dashboard.html': html, + 'src/mcp/curator/apps/dashboard.tsx': "export const config = { resourceUri: 'ui://curator/dashboard.html', template: './dashboard.html' };\ndocument.body.textContent = 'dashboard';\n", + 'src/mcp/curator/tools/inspect.ts': moduleSource, + }); + const ready = (await inspect({ root: routeRelative })) as ReadyInspectResult; + expect(ready.state).toBe('ready'); + expect(ready.model.mcpApps?.map((app) => app.template)).toEqual([join(routeRelative, 'src/mcp/curator/apps/dashboard.html')]); + + const legacy = await createInspectProject({ + 'src/mcp/curator/apps/dashboard.tsx': "export const config = { resourceUri: 'ui://curator/dashboard.html', template: './views/dashboard.html' };\ndocument.body.textContent = 'dashboard';\n", + 'src/mcp/curator/tools/inspect.ts': moduleSource, + 'views/dashboard.html': html, + }); + const legacyReady = (await inspect({ root: legacy })) as ReadyInspectResult; + expect(legacyReady.state).toBe('ready'); + expect(legacyReady.model.mcpApps?.map((app) => app.template)).toEqual([join(legacy, 'views/dashboard.html')]); + + const ambiguous = await createInspectProject({ + 'src/mcp/curator/apps/dashboard.tsx': "export const config = { resourceUri: 'ui://curator/dashboard.html', template: './views/dashboard.html' };\ndocument.body.textContent = 'dashboard';\n", + 'src/mcp/curator/apps/views/dashboard.html': html, + 'src/mcp/curator/tools/inspect.ts': moduleSource, + 'views/dashboard.html': html, + }); + const validation = await validate({ root: ambiguous }); + expect(codesOf(validation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error'))).toEqual(['AB4827']); + expect((await inspect({ root: ambiguous })).state).toBe('invalid'); +}); + it('covers the route config in the graph digest', async () => { const withTitle = async (title: string): Promise => { const root = await createRoot();