diff --git a/.changeset/xref-registered-route-ids.md b/.changeset/xref-registered-route-ids.md
new file mode 100644
index 000000000..5f276a8c8
--- /dev/null
+++ b/.changeset/xref-registered-route-ids.md
@@ -0,0 +1,6 @@
+---
+"agent-bundle": patch
+"@agent-bundle/runtime": patch
+---
+
+Carry the route registration that `.agent-bundle/routes.d.ts` places on `@agent-bundle/runtime`'s `Register` through the rest of the public API, not only `renderRoute`, the way TanStack Router's one `Register` reaches `Link to`, `useNavigate`, and `RoutesByPath`. In `agent-bundle/test`, `invokeMcpTool` and `getMcpPrompt` now check a literal wire name against the registered tool/prompt names and type `input` from that route — of the literal `server` when one is passed, which is itself checked against the compiled server names (`McpInvocationOptions`, `McpRouteNameConstraint`, `McpRouteInput`, `McpServerConstraint`, `McpRouteServer`); the `fixtures` of `runContractMatrix`, `runPackedContractMatrix`, `runDevEpochContractMatrix`, and `runInstalledHostContractMatrix` type each registered key's `input`, `inputs`, `cancellation.input`, and lifecycle transitions (`ContractRouteFixtures`, `ContractRouteFixture`, `ContractLifecycleFixture`, `ContractLifecycleTransition`) while MCP App keys and dynamic records stay legal; and `invokeCli` reports `CliInvocation.routeId` as a `RegisteredRouteId` (`argv` is unchanged). In `agent-bundle/eval`, `expectMcpCall` and `expectNoMcpCall` check a literal `tool` against the registered tools of a literal project `server` (`ExpectMcpCallOptions`, `ExpectNoMcpCallOptions`, `EvalMcpToolConstraint`); third-party servers stay free. `@agent-bundle/runtime` adds `RegisteredMcpRouteKind`, `RegisteredMcpServerName`, `RegisteredMcpRouteName`, and `RegisteredMcpRouteId` for the server and protocol names a registered id encodes. Type-only: nothing changes at run time, and every surface keeps its `string`/`unknown` shape when no project has registered. (#494)
diff --git a/docs/framework-mode.md b/docs/framework-mode.md
index 101d74682..9ab03b946 100644
--- a/docs/framework-mode.md
+++ b/docs/framework-mode.md
@@ -180,6 +180,24 @@ lookups, a directly imported module target is unaffected, and a project that
excludes the generated declarations (or has not built yet) sees the
unregistered types — any string, `unknown` input, `unknown` result.
+That one registration is read by every public surface that takes or yields a
+route id or route payload, the way TanStack Router's `Register` reaches `Link
+to`, `useNavigate`, and `RoutesByPath`: `invokeMcpTool` and `getMcpPrompt`
+check their wire name against the registered tool/prompt names (the last
+segment of a `tool:`/`prompt:` id) — of the literal `server` when one is
+passed, since the session mounts only that server's routes — and type
+`input` from that route;
+`runContractMatrix` and the packed, dev-epoch, and installed-host matrices type
+the inputs of each registered `fixtures` key (an App route key stays untyped —
+Apps register no contract); `invokeCli` reports `routeId` as a registered id;
+and `agent-bundle/eval`'s `expectMcpCall`/`expectNoMcpCall` check a literal
+`tool` against the registered tools of a literal project `server`.
+`RegisteredMcpServerName`, `RegisteredMcpRouteName`, and `RegisteredMcpRouteId`
+expose the server and protocol names an id encodes. `readMcpResource` (a wire
+URI), `runScript` (no registered contract), the wire `structuredContent`
+(object-valued documents only), and `agent-bundle/api` (an arbitrary project
+`root`) stay `string`/`unknown` on purpose.
+
### What reaches the MCP wire
The final Agent Document of a tool route lowers to one `CallToolResult`:
diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md
index 70785152a..86af637ee 100644
--- a/packages/agent-bundle/README.md
+++ b/packages/agent-bundle/README.md
@@ -511,6 +511,20 @@ name that surface for wrappers. A value typed `string`, a module target, or a
program without the generated file sees the previous types — any id, `unknown`
input and result.
+The registration flows to every harness surface that takes a route id or
+payload, not only `renderRoute`: `invokeMcpTool('find', { input })` and
+`getMcpPrompt` check the wire name against the registered tool/prompt names and
+type `input` from that route — of the literal `server`, when passed
+(`RegisteredMcpServerName`, `RegisteredMcpRouteName`,
+and `RegisteredMcpRouteId` name what a `tool:/` id encodes); the
+contract matrices type each registered key of `fixtures` while an MCP App key
+or a dynamic `Record` stays legal; `invokeCli`
+reports `routeId` as a registered id (`argv` is untouched); and
+`agent-bundle/eval`'s `expectMcpCall` checks a literal `tool` against the
+registered tools of a literal project `server`. `readMcpResource` (a URI),
+`runScript` (scripts register no contract), and `structuredContent` (carried
+only for object-valued documents) deliberately stay untyped.
+
Conventional request context providers (`src/providers/*`, see
[entry conventions](../../docs/entry-conventions.md#request-context-providers-power-tier))
are mounted automatically for every manifest-backed helper — `renderRoute`,
diff --git a/packages/agent-bundle/src/eval/assertions.ts b/packages/agent-bundle/src/eval/assertions.ts
index 62194512b..8c950e167 100644
--- a/packages/agent-bundle/src/eval/assertions.ts
+++ b/packages/agent-bundle/src/eval/assertions.ts
@@ -1,3 +1,5 @@
+import type { RegisteredMcpRouteName, RegisteredMcpServerName } from '@agent-bundle/runtime';
+
import { digest } from '../core/digest.ts';
import { EvalDefinitionError } from './errors.ts';
import { claudeSemanticGraderId } from './graders.ts';
@@ -18,15 +20,36 @@ export interface EvalEvidenceOptions {
readonly minimumEvidence?: ActivationEvidence;
}
-export interface ExpectMcpCallOptions extends EvalEvidenceOptions {
+/**
+ * The constraint an MCP-call assertion's `tool` must satisfy for `Server`.
+ * Once the generated `.agent-bundle/routes.d.ts` registers the project's
+ * routes and `server` is a literal naming one of the project's own compiled
+ * MCP servers, a literal `tool` must be one of that server's registered tool
+ * names (`find` for `tool:curator/find`) — a typo is rejected naming the
+ * alternatives. A server outside the registration (an assertion about a
+ * third-party MCP server the host also exposes), a value typed `string`, or an
+ * unregistered project keeps `string`, exactly as before. The `& string`
+ * reduces the alias instantiation to a literal union so a rejection lists the
+ * server's tool names.
+ */
+export type EvalMcpToolConstraint = Server extends RegisteredMcpServerName
+ ? string extends Tool ? string : RegisteredMcpRouteName<'tool', Server> & string
+ : string;
+
+/**
+ * `server` is the MCP server name as the host trace records it and `tool` the
+ * wire tool name; both are inferred from literals so `tool` checks against the
+ * project's registered tools of that server (see {@link EvalMcpToolConstraint}).
+ */
+export interface ExpectMcpCallOptions extends EvalEvidenceOptions {
readonly atLeast?: number;
- readonly server: string;
- readonly tool: string;
+ readonly server: Server;
+ readonly tool: (Tool & EvalMcpToolConstraint) | EvalMcpToolConstraint;
}
-export interface ExpectNoMcpCallOptions extends EvalEvidenceOptions {
- readonly server: string;
- readonly tool?: string;
+export interface ExpectNoMcpCallOptions extends EvalEvidenceOptions {
+ readonly server: Server;
+ readonly tool?: (Tool & EvalMcpToolConstraint) | EvalMcpToolConstraint;
}
export interface ExpectOutcomeOptions extends EvalEvidenceOptions {
@@ -95,7 +118,9 @@ export const expectExitCode = (
return Object.freeze({ ...expectation, id: assertionId(expectation.kind, expectation) });
};
-export const expectMcpCall = (options: ExpectMcpCallOptions): EvalMcpCallAssertion => {
+export const expectMcpCall = (
+ options: ExpectMcpCallOptions,
+): EvalMcpCallAssertion => {
const expectation = {
atLeast: requireCount(options.atLeast ?? 1, 'Expected MCP call count', 1),
kind: 'mcp-call' as const,
@@ -106,7 +131,9 @@ export const expectMcpCall = (options: ExpectMcpCallOptions): EvalMcpCallAsserti
return Object.freeze({ ...expectation, id: assertionId(expectation.kind, expectation) });
};
-export const expectNoMcpCall = (options: ExpectNoMcpCallOptions): EvalNoMcpCallAssertion => {
+export const expectNoMcpCall = (
+ options: ExpectNoMcpCallOptions,
+): EvalNoMcpCallAssertion => {
const expectation = {
kind: 'no-mcp-call' as const,
minimumEvidence: requireMinimumEvidence(options.minimumEvidence, 'observed'),
diff --git a/packages/agent-bundle/src/eval/index.ts b/packages/agent-bundle/src/eval/index.ts
index df5242f64..5cc8ee8c3 100644
--- a/packages/agent-bundle/src/eval/index.ts
+++ b/packages/agent-bundle/src/eval/index.ts
@@ -32,6 +32,7 @@ export {
} from './assertions.ts';
export type {
EvalEvidenceOptions,
+ EvalMcpToolConstraint,
ExpectMcpCallOptions,
ExpectNoMcpCallOptions,
ExpectNoSkillActivationOptions,
diff --git a/packages/agent-bundle/src/test/cli.ts b/packages/agent-bundle/src/test/cli.ts
index 514c7dcaa..c8c0e880b 100644
--- a/packages/agent-bundle/src/test/cli.ts
+++ b/packages/agent-bundle/src/test/cli.ts
@@ -18,6 +18,7 @@
* wire behavior.
*/
import type * as AgentRuntime from '@agent-bundle/runtime';
+import type { RegisteredRouteId } from '@agent-bundle/runtime';
import { CliInputError, runGeneratedCliEntry } from '../cli-entry.ts';
import type { CliRenderedEvent } from '../cli-entry.ts';
@@ -63,7 +64,15 @@ export interface CliInvocation {
*/
readonly exitCode: number;
readonly provenance: CliDispatchProvenance;
- readonly routeId?: string;
+ /**
+ * The compiled route the shell executed: a `cli:` route, or the `tool:`
+ * route behind a projected MCP command. Typed from the project's route
+ * registration once `.agent-bundle/routes.d.ts` is in the program (both
+ * kinds register), `string` without it; absent for help, `--version`, and
+ * usage failures. `argv` itself stays `readonly string[]` — it is the shell's
+ * input, not a route id.
+ */
+ readonly routeId?: RegisteredRouteId;
/** Everything the shell wrote to its diagnostic stream. */
readonly stderr: string;
/** Everything the shell wrote to stdout, including rendered Markdown, TTY, JSON, or NDJSON output. */
@@ -292,7 +301,8 @@ export const invokeCli = async (
return Object.freeze({
argv: Object.freeze([...argv]),
- ...(executed === undefined ? {} : { command: commandPath(executed), routeId: executed.routeId }),
+ // The compiled command graph's ids are the ones the registration lists.
+ ...(executed === undefined ? {} : { command: commandPath(executed), routeId: executed.routeId as RegisteredRouteId }),
exitCode,
provenance,
stderr: err,
diff --git a/packages/agent-bundle/src/test/contract.ts b/packages/agent-bundle/src/test/contract.ts
index 272901454..49f3193d2 100644
--- a/packages/agent-bundle/src/test/contract.ts
+++ b/packages/agent-bundle/src/test/contract.ts
@@ -52,6 +52,7 @@
* Boundaries without an event runtime report identity as not-applicable.
*/
import type { Client } from '@modelcontextprotocol/client';
+import type { RegisteredRouteId, RegisteredRouteInput } from '@agent-bundle/runtime';
import {
requestEventRuntimeStatus,
@@ -92,20 +93,24 @@ export type ContractLifecyclePhase =
| 'repeated-progress'
| 'terminal';
-export interface ContractLifecycleTransition {
+/**
+ * One lifecycle phase's invocation. `Input` is the route's input, bound to
+ * its registered type by {@link ContractRouteFixtures} for a registered id.
+ */
+export interface ContractLifecycleTransition {
readonly expectedStructuredContent: unknown;
- readonly input: unknown;
+ readonly input: Input;
readonly phase: ContractLifecyclePhase;
readonly progressNotifications: number;
readonly renderedTextIncludes?: string;
}
-export interface ContractLifecycleFixture {
+export interface ContractLifecycleFixture {
readonly state?: {
readonly budget?: {
readonly codePath: readonly string[];
readonly expectedCode: string;
- readonly input: unknown;
+ readonly input: Input;
readonly revisionPath: readonly string[];
};
readonly catalog?: {
@@ -114,7 +119,7 @@ export interface ContractLifecycleFixture {
};
readonly durability?: {
readonly expectedStructuredContent: unknown;
- readonly input: unknown;
+ readonly input: Input;
};
readonly idempotency?: {
readonly phase: ContractLifecyclePhase;
@@ -132,7 +137,7 @@ export interface ContractLifecycleFixture {
};
};
/** Pure deterministic phase driver; transport and assertions remain framework-owned. */
- readonly transitionDriver: () => readonly ContractLifecycleTransition[];
+ readonly transitionDriver: () => readonly ContractLifecycleTransition[];
}
/**
@@ -144,7 +149,15 @@ export interface ContractResourceFixture {
readonly kind: 'resource';
}
-export interface ContractRouteFixture {
+/**
+ * One route's contract fixture. Every `input` here is handed to the route as
+ * its tool or prompt input, so `Input` is that route's input type:
+ * {@link ContractRouteFixtures} binds it to the registered input for a
+ * registered route id and leaves it `unknown` otherwise. `previousResults`
+ * stays `unknown` by design — those payloads come from previous server
+ * versions and need not match the current schema's type.
+ */
+export interface ContractRouteFixture {
/**
* `'resource'` marks a resource/MCP App fixture (see `ContractResourceFixture`).
* Omit it for tool and prompt fixtures; a legacy `{}` still covers a
@@ -152,9 +165,9 @@ export interface ContractRouteFixture {
*/
readonly kind?: ContractResourceFixture['kind'];
/** Valid input for the sweep invocation (tools/prompts; resources need none). */
- readonly input?: unknown;
+ readonly input?: Input;
/** Additional valid inputs — e.g. one per declared status/discriminant value. */
- readonly inputs?: readonly unknown[];
+ readonly inputs?: readonly Input[];
/** Declared serialized-result compatibility policy. REQUIRED for tool routes. */
readonly resultCompat?: ResultCompatPolicy;
/**
@@ -168,11 +181,28 @@ export interface ContractRouteFixture {
* `abortAfterMs` (default 50ms); an invocation that settles before the abort
* fires is reported `not-applicable`, not `failed`.
*/
- readonly cancellation?: { readonly abortAfterMs?: number; readonly input?: unknown };
+ readonly cancellation?: { readonly abortAfterMs?: number; readonly input?: Input };
/** Optional stateful replay over this matrix run's single open client. */
- readonly lifecycle?: ContractLifecycleFixture;
+ readonly lifecycle?: ContractLifecycleFixture;
}
+/**
+ * Route id -> fixture, the `fixtures` member of every contract-matrix entry
+ * point. Once the generated `.agent-bundle/routes.d.ts` registers the
+ * project's routes, a registered id's fixture carries that route's registered
+ * input in `input`, `inputs`, `cancellation.input`, and its lifecycle
+ * transitions (a mistyped literal is rejected at the key), while any other
+ * key — an MCP App route, which the registration never lists, or a value typed
+ * `Record` built dynamically — stays legal with
+ * `unknown` inputs, exactly as before. Without a registration every key is a
+ * string and every input `unknown`.
+ */
+export type ContractRouteFixtures = {
+ readonly [Id in RegisteredRouteId]?: ContractRouteFixture>;
+} & {
+ readonly [routeId: string]: ContractRouteFixture;
+};
+
/**
* How MCP App routes are covered at boundaries that register app resources.
*
@@ -217,7 +247,7 @@ export interface ContractMatrixOptions extends InMemoryMcpSessionOptions {
* the server must be covered. App routes are not registered at
* `mcp-in-memory`; entries for them are accepted and ignored.
*/
- readonly fixtures: Readonly>;
+ readonly fixtures: ContractRouteFixtures;
/** Accepted for parity with the other entry points; apps are never registered here. */
readonly apps?: ContractAppCoverage;
/** Reopens the same durable store after the matrix closes its initial in-memory session. */
@@ -261,7 +291,7 @@ export interface PackedContractMatrixOptions {
* the server must be covered; app routes are auto-covered unless
* `apps: 'explicit'`.
*/
- readonly fixtures: Readonly>;
+ readonly fixtures: ContractRouteFixtures;
readonly manifest: AgentBundleTestManifest;
readonly server?: string;
/** An already-open packed session; this entry point never opens or closes it. */
@@ -286,7 +316,7 @@ export interface DevEpochContractMatrixSession {
export interface DevEpochContractMatrixOptions {
/** App route coverage at this boundary; defaults to `'auto'`. */
readonly apps?: ContractAppCoverage;
- readonly fixtures: Readonly>;
+ readonly fixtures: ContractRouteFixtures;
readonly manifest: AgentBundleTestManifest;
readonly server?: string;
/** An already-open epoch-pinned generated stdio session; this entry point never opens or closes it. */
@@ -296,7 +326,7 @@ export interface DevEpochContractMatrixOptions {
export interface InstalledHostContractMatrixOptions {
/** App route coverage at this boundary; defaults to `'auto'`. */
readonly apps?: ContractAppCoverage;
- readonly fixtures: Readonly>;
+ readonly fixtures: ContractRouteFixtures;
readonly manifest: AgentBundleTestManifest;
readonly server?: string;
/** An already-open installed-host session; this entry point never opens or closes it. */
diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts
index 3bfb6ee68..55b37511e 100644
--- a/packages/agent-bundle/src/test/index.ts
+++ b/packages/agent-bundle/src/test/index.ts
@@ -117,6 +117,7 @@ export type {
ContractCheckStatus,
ContractEventRuntimeAddress,
ContractResourceFixture,
+ ContractRouteFixtures,
ContractLifecycleFixture,
ContractLifecyclePhase,
ContractLifecycleTransition,
@@ -147,6 +148,10 @@ export type {
McpProjectionProvenance,
McpPromptResult,
McpResourceRead,
+ McpRouteInput,
+ McpRouteNameConstraint,
+ McpRouteServer,
+ McpServerConstraint,
McpSurfaceListing,
McpToolInvocation,
} from './mcp.ts';
diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts
index 787696e5f..d8ec29b63 100644
--- a/packages/agent-bundle/src/test/mcp.ts
+++ b/packages/agent-bundle/src/test/mcp.ts
@@ -20,7 +20,14 @@ import type {
AgentStateDriver,
AgentStateEventSchemas,
} from '@agent-bundle/runtime/state';
-import type { LineageHost } from '@agent-bundle/runtime';
+import type {
+ LineageHost,
+ RegisteredMcpRouteId,
+ RegisteredMcpRouteKind,
+ RegisteredMcpRouteName,
+ RegisteredMcpServerName,
+ RegisteredRouteInput,
+} from '@agent-bundle/runtime';
import type { AgentLineageRegistry } from '@agent-bundle/runtime/lineage';
import type { createGeneratedRuntimeState } from '@agent-bundle/runtime/mount';
import type { ReactNode } from 'react';
@@ -115,10 +122,65 @@ export interface InMemoryMcpSession extends AsyncDisposable {
readonly provenance: McpProjectionProvenance;
}
-export type McpInvocationOptions = InMemoryMcpSessionOptions & {
- readonly input?: unknown;
+/**
+ * The constraint a `server` option must satisfy: once the project's routes
+ * are registered, a literal must name a compiled server (`curator` for
+ * `tool:curator/find`; a typo is rejected naming the alternatives), while a
+ * value typed `string` stays legal. `string` for an unregistered project.
+ */
+export type McpServerConstraint = string extends Server ? string : RegisteredMcpServerName & string;
+
+/**
+ * The server the wire helpers look a name up on: a registered literal narrows
+ * the lookup to that server's routes; `string` (omitted or dynamic) and an
+ * unregistered literal — already rejected by {@link McpServerConstraint} —
+ * match every compiled server, so the one error lands on `server`.
+ */
+export type McpRouteServer = Server extends RegisteredMcpServerName ? Server : string;
+
+/**
+ * Options of one wire invocation. `Input` is the payload the generated server
+ * hands the route: `invokeMcpTool` and `getMcpPrompt` bind it to the route's
+ * registered input ({@link McpRouteInput}) when the name is a literal, and
+ * it stays `unknown` — the previous shape — for a dynamic name or an
+ * unregistered project. `Server` is the literal `server` option, when given;
+ * it selects which server's route the name resolves to.
+ */
+export type McpInvocationOptions = InMemoryMcpSessionOptions & {
+ readonly input?: Input;
+ readonly server?: (Server & McpServerConstraint) | McpServerConstraint;
};
+/**
+ * The constraint one protocol name must satisfy — `RouteTargetConstraint` for
+ * the wire helpers. Once the generated `.agent-bundle/routes.d.ts` registers
+ * the project's routes, a string literal must be the protocol name of a
+ * registered `Kind` route on `Server` (`find` for `tool:curator/find`; the
+ * editor completes them and a typo is rejected naming the alternatives) —
+ * on any compiled server when `server` is omitted or dynamic — while a value
+ * typed `string` stays legal for dynamic lookups. Without a registration
+ * `RegisteredMcpRouteName` is `string`, so every name is legal, as before.
+ *
+ * The `& string` is not a widening: it makes the compiler reduce the alias
+ * instantiation to a fresh literal union, so a rejection lists the registered
+ * names rather than printing `RegisteredMcpRouteName<"tool">`.
+ */
+export type McpRouteNameConstraint =
+ string extends Name ? string : RegisteredMcpRouteName> & string;
+
+/**
+ * The registered input of the `Kind` route named `Name` on `Server` — on any
+ * compiled server when `Server` is `string`, a union when two of them register
+ * the same name; `unknown` for a dynamic name or an unregistered project.
+ */
+export type McpRouteInput<
+ Name extends string,
+ Kind extends RegisteredMcpRouteKind,
+ Server extends string = string,
+> = Name extends RegisteredMcpRouteName>
+ ? RegisteredRouteInput, Name>>
+ : unknown;
+
const serverRoutes = (
manifest: AgentBundleTestManifest,
serverName: string,
@@ -524,10 +586,19 @@ const asContentBlocks = (value: unknown): readonly McpContentBlock[] =>
/**
* Calls one compiled tool through the real protocol and returns the projected
* result. `mcp-in-memory` level: protocol contract proof, not process proof.
+ *
+ * `tool` is the wire name (`find` for the route `tool:curator/find`). Once
+ * the project's routes are registered, a literal is checked against the
+ * compiled tool names — of the literal `server` when one is passed, since
+ * the session mounts only that server's routes — and `input` is typed from
+ * that route's `inputSchema` (see {@link McpRouteNameConstraint}). The
+ * projected `structuredContent` stays `unknown`: the wire carries it only for
+ * object-valued documents, so the route's `resultSchema` output is not what
+ * every call yields.
*/
-export const invokeMcpTool = async (
- tool: string,
- ...[options = {}]: HarnessOptionsArguments
+export const invokeMcpTool = async (
+ tool: (Name & McpRouteNameConstraint) | McpRouteNameConstraint,
+ ...[options = {}]: HarnessOptionsArguments, Server>>
): Promise => withSession(options, async (session) => {
const result = await session.client.callTool({
arguments: (options.input ?? {}) as Record,
@@ -564,10 +635,16 @@ export interface McpPromptResult {
readonly provenance: McpProjectionProvenance;
}
-/** Gets one compiled prompt route through the real protocol. */
-export const getMcpPrompt = async (
- prompt: string,
- ...[options = {}]: HarnessOptionsArguments
+/**
+ * Gets one compiled prompt route through the real protocol. `prompt` is the
+ * wire name (`brief` for `prompt:curator/brief`); once registered, a literal
+ * is checked against the compiled prompt names (of the literal `server`, when
+ * passed) and `input` is typed from that route's `inputSchema`, exactly as
+ * {@link invokeMcpTool} types a tool.
+ */
+export const getMcpPrompt = async (
+ prompt: (Name & McpRouteNameConstraint) | McpRouteNameConstraint,
+ ...[options = {}]: HarnessOptionsArguments, Server>>
): Promise => withSession(options, async (session) => {
const result = await session.client.getPrompt({
arguments: (options.input ?? {}) as Record,
diff --git a/packages/agent-bundle/tests/route-register-typegen.test.ts b/packages/agent-bundle/tests/route-register-typegen.test.ts
index 214b9dfd1..8b1597480 100644
--- a/packages/agent-bundle/tests/route-register-typegen.test.ts
+++ b/packages/agent-bundle/tests/route-register-typegen.test.ts
@@ -49,14 +49,26 @@ const equalityHelpers = [
'type Assert = Value;',
];
+const registeredIds = [
+ 'cli:report',
+ 'event:tool/after',
+ 'prompt:curator/brief',
+ 'tool:curator/find',
+ 'tool:curator/status',
+ 'tool:shelf/find',
+] as const;
+
/**
* The generated declarations register the project's route contracts on
* `@agent-bundle/runtime`'s `Register` (TanStack Router's registration
- * pattern), so `renderRoute` narrows its id, `input`, and `result` from the
+ * pattern), so every route-aware public surface — `renderRoute`, the wire
+ * helpers `invokeMcpTool`/`getMcpPrompt`, the contract-matrix `fixtures`,
+ * `invokeCli`'s reported `routeId`, and `agent-bundle/eval`'s
+ * `expectMcpCall` — narrows its id or name, `input`, and `result` from the
* route modules' own schemas with no per-route declaration file — and the
* same program without the generated file degrades to `string` / `unknown`.
*/
-it('types renderRoute ids, inputs, and results from the generated route registration', { timeout: 60_000 }, async () => {
+it('types every route-aware public surface from the generated route registration', { timeout: 60_000 }, async () => {
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-register-'));
roots.push(root);
// The audiobook example's installed tree supplies the built agent-bundle, @agent-bundle/runtime, and zod.
@@ -90,6 +102,28 @@ it('types renderRoute ids, inputs, and results from the generated route registra
'export default async function Find() { return { hits: 1 }; }',
'',
].join('\n')),
+ // A second server registering the same tool name: the wire helpers see the union of both inputs.
+ writeProjectFile(root, 'src/mcp/shelf/tools/find.ts', [
+ "import { z } from 'zod';",
+ 'export const inputSchema = z.object({ isbn: z.string() }).strict();',
+ 'export const resultSchema = z.object({ shelved: z.boolean() }).strict();',
+ 'export default async function Find() { return { shelved: true }; }',
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'src/mcp/curator/prompts/brief.ts', [
+ "import { z } from 'zod';",
+ 'export const inputSchema = z.object({ topic: z.string() }).strict();',
+ "export const resultSchema = z.object({ messages: z.array(z.object({ content: z.object({ text: z.string(), type: z.literal('text') }).strict(), role: z.literal('user') }).strict()) }).strict();",
+ "export default async function Brief() { return { messages: [{ content: { text: 'brief', type: 'text' as const }, role: 'user' as const }] }; }",
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'src/cli/report.ts', [
+ "import { z } from 'zod';",
+ 'export const inputSchema = z.object({ verbose: z.boolean().optional() }).strict();',
+ 'export const resultSchema = z.object({ lines: z.number() }).strict();',
+ 'export default async function Report() { return { lines: 1 }; }',
+ '',
+ ].join('\n')),
writeProjectFile(root, 'src/events/tool/after.ts', [
"import type { AgentEventRouteProps } from 'agent-bundle';",
'export default async function ToolAfter(props: AgentEventRouteProps) { return props.canonical.event; }',
@@ -97,12 +131,33 @@ it('types renderRoute ids, inputs, and results from the generated route registra
].join('\n')),
writeProjectFile(root, 'assertions.ts', [
"import type { AgentEventCanonicalIdentity, AgentEventNativePayload } from 'agent-bundle';",
- "import type { RegisteredRouteId, RegisteredRouteInput, RegisteredRouteResult } from '@agent-bundle/runtime';",
- "import { renderRoute, renderRouteEvents } from 'agent-bundle/test';",
+ "import { expectMcpCall, expectNoMcpCall } from 'agent-bundle/eval';",
+ 'import type {',
+ ' RegisteredMcpRouteId,',
+ ' RegisteredMcpRouteName,',
+ ' RegisteredMcpServerName,',
+ ' RegisteredRouteId,',
+ ' RegisteredRouteInput,',
+ ' RegisteredRouteResult,',
+ "} from '@agent-bundle/runtime';",
+ 'import {',
+ ' getMcpPrompt,',
+ ' invokeCli,',
+ ' invokeMcpTool,',
+ ' renderRoute,',
+ ' renderRouteEvents,',
+ ' runContractMatrix,',
+ ' runPackedContractMatrix,',
+ ' type ContractRouteFixture,',
+ ' type ContractRouteFixtures,',
+ ' type McpRouteInput,',
+ ' type PackedMcpSession,',
+ ' type PackedContractMatrixOptions,',
+ "} from 'agent-bundle/test';",
'',
...equalityHelpers,
'',
- "export type Ids = Assert>;",
+ `export type Ids = Assert `'${id}'`).join(' | ')}>>;`,
"export type FindInput = Assert, { query: string }>>;",
"export type FindResult = Assert, { hits: number }>>;",
"export type Unregistered = Assert, unknown>>;",
@@ -112,6 +167,15 @@ it('types renderRoute ids, inputs, and results from the generated route registra
"export type EventCanonical = Assert['canonical'], AgentEventCanonicalIdentity>>;",
"export type EventNative = Assert['native'], AgentEventNativePayload>>;",
"export type EventResult = Assert, undefined>>;",
+ '// The MCP server and protocol names a registered id encodes (TanStack\'s `RoutesByPath` shape).',
+ "export type Servers = Assert>;",
+ "export type ToolNames = Assert, 'find' | 'status'>>;",
+ "export type ShelfToolNames = Assert, 'find'>>;",
+ "export type PromptNames = Assert, 'brief'>>;",
+ "export type FindIds = Assert, 'tool:curator/find' | 'tool:shelf/find'>>;",
+ "export type FindWireInput = Assert, { query: string } | { isbn: string }>>;",
+ "export type ShelfFindWireInput = Assert, { isbn: string }>>;",
+ "export type DynamicWireInput = Assert, unknown>>;",
'',
'export const typed = async (canonical: AgentEventCanonicalIdentity, native: AgentEventNativePayload): Promise => {',
" const found = await renderRoute('tool:curator/find', { input: { query: 'dune' } });",
@@ -126,10 +190,98 @@ it('types renderRoute ids, inputs, and results from the generated route registra
" const dynamic: string = ['tool:curator/status'].join('');",
' const loose = await renderRoute(dynamic);',
' const anything: unknown = loose.result;',
- ' void hits; void status; void none; void anything;',
+ ' // The wire helpers take the protocol name; `input` is the registered input of every route with that name,',
+ ' // or of the one route on a literal `server`.',
+ " await invokeMcpTool('status');",
+ " await invokeMcpTool('find', { input: { query: 'dune' } });",
+ " await invokeMcpTool('find', { input: { query: 'dune' }, server: 'curator' });",
+ " await invokeMcpTool('find', { input: { isbn: '9780441172719' }, server: 'shelf' });",
+ " await invokeMcpTool('find', { input: { isbn: '9780441172719' }, server: dynamic });",
+ " await invokeMcpTool(dynamic, { input: { anything: true }, server: dynamic });",
+ " await getMcpPrompt('brief', { input: { topic: 'dune' } });",
+ " await getMcpPrompt('brief', { input: { topic: 'dune' }, server: 'curator' });",
+ ' // A contract-matrix fixture map types each registered key\'s inputs; unregistered keys (MCP App routes) stay legal.',
+ ' const fixtures: ContractRouteFixtures = {',
+ " 'tool:curator/find': { cancellation: { input: { query: 'slow' } }, input: { query: 'dune' }, inputs: [{ query: 'arrakis' }], resultCompat: 'closed' },",
+ " 'tool:curator/status': { input: {}, resultCompat: 'closed' },",
+ " 'prompt:curator/brief': { input: { topic: 'dune' } },",
+ " 'app:curator/dashboard': { kind: 'resource' },",
+ ' };',
+ ' await runContractMatrix({ fixtures });',
+ ' // A record built dynamically stays legal with unknown inputs.',
+ ' const dynamicFixtures: Readonly> = {};',
+ " await runContractMatrix({ fixtures: dynamicFixtures, server: 'curator' });",
+ ' const packed = (session: PackedMcpSession, manifest: PackedContractMatrixOptions[\'manifest\']) =>',
+ " runPackedContractMatrix({ fixtures: { 'tool:shelf/find': { input: { isbn: '1' }, resultCompat: 'additive' } }, manifest, session });",
+ " // `invokeCli` reports the executed route's registered id; argv itself is untyped.",
+ " const ran = await invokeCli(['report', '--verbose']);",
+ ' const executed: RegisteredRouteId | undefined = ran.routeId;',
+ " const isReport: boolean = ran.routeId === 'cli:report';",
+ ' // Eval assertions check a literal tool against the registered tools of that server; other servers stay free.',
+ " expectMcpCall({ server: 'curator', tool: 'find' });",
+ " expectMcpCall({ server: 'shelf', tool: 'find', atLeast: 2 });",
+ " expectNoMcpCall({ server: 'curator' });",
+ " expectNoMcpCall({ server: 'github', tool: 'search_issues' });",
+ " expectMcpCall({ server: dynamic, tool: dynamic });",
+ ' void hits; void status; void none; void anything; void packed; void executed; void isReport;',
'};',
'',
].join('\n')),
+ writeProjectFile(root, 'wrong-tool-name.ts', [
+ "import { invokeMcpTool } from 'agent-bundle/test';",
+ "export const missing = invokeMcpTool('missing');",
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'wrong-tool-input.ts', [
+ "import { invokeMcpTool } from 'agent-bundle/test';",
+ "export const mistyped = invokeMcpTool('status', { input: { query: 'dune' } });",
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'wrong-prompt-input.ts', [
+ "import { getMcpPrompt } from 'agent-bundle/test';",
+ "export const mistyped = getMcpPrompt('brief', { input: { topic: 7 } });",
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'wrong-server-tool.ts', [
+ "import { invokeMcpTool } from 'agent-bundle/test';",
+ "export const elsewhere = invokeMcpTool('status', { server: 'shelf' });",
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'wrong-server-input.ts', [
+ "import { invokeMcpTool } from 'agent-bundle/test';",
+ "export const mistyped = invokeMcpTool('find', { input: { query: 'dune' }, server: 'shelf' });",
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'wrong-server-name.ts', [
+ "import { getMcpPrompt } from 'agent-bundle/test';",
+ "export const missing = getMcpPrompt('brief', { input: { topic: 'dune' }, server: 'librarian' });",
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'wrong-fixture-input.ts', [
+ "import { runContractMatrix } from 'agent-bundle/test';",
+ "export const mistyped = runContractMatrix({ fixtures: { 'tool:curator/find': { input: { query: 7 }, resultCompat: 'closed' } } });",
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'wrong-fixture-transition.ts', [
+ "import { runContractMatrix } from 'agent-bundle/test';",
+ 'export const mistyped = runContractMatrix({ fixtures: {',
+ " 'tool:curator/find': {",
+ " lifecycle: { transitionDriver: () => [{ expectedStructuredContent: {}, input: { isbn: '1' }, phase: 'terminal', progressNotifications: 0 }] },",
+ " resultCompat: 'closed',",
+ ' },',
+ '} });',
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'wrong-cli-route.ts', [
+ "import { invokeCli } from 'agent-bundle/test';",
+ "export const compared = async (): Promise => (await invokeCli(['report'])).routeId === 'cli:missing';",
+ '',
+ ].join('\n')),
+ writeProjectFile(root, 'wrong-eval-tool.ts', [
+ "import { expectMcpCall } from 'agent-bundle/eval';",
+ "export const mistyped = expectMcpCall({ server: 'shelf', tool: 'status' });",
+ '',
+ ].join('\n')),
writeProjectFile(root, 'wrong-event-input.ts', [
"import { renderRoute } from 'agent-bundle/test';",
"export const mistyped = renderRoute('event:tool/after', { input: { canonical: 'tool/after', native: {} } });",
@@ -151,15 +303,24 @@ it('types renderRoute ids, inputs, and results from the generated route registra
'',
].join('\n')),
writeProjectFile(root, 'unregistered.ts', [
- "import type { RegisteredRouteId, RegisteredRouteResult } from '@agent-bundle/runtime';",
- "import { renderRoute } from 'agent-bundle/test';",
+ "import { expectMcpCall } from 'agent-bundle/eval';",
+ "import type { RegisteredMcpRouteName, RegisteredMcpServerName, RegisteredRouteId, RegisteredRouteResult } from '@agent-bundle/runtime';",
+ "import { getMcpPrompt, invokeCli, invokeMcpTool, renderRoute, runContractMatrix, type McpRouteInput } from 'agent-bundle/test';",
'',
...equalityHelpers,
'',
- '// Without the generated file in the program, ids are string and contracts are unknown.',
+ '// Without the generated file in the program, ids and names are string and contracts are unknown.',
'export type Ids = Assert>;',
"export type Result = Assert, unknown>>;",
+ 'export type Servers = Assert>;',
+ "export type ToolNames = Assert, string>>;",
+ "export type WireInput = Assert, unknown>>;",
"export const anyId = renderRoute('tool:curator/missing', { input: { query: 7 } });",
+ "export const anyTool = invokeMcpTool('missing', { input: { query: 7 }, server: 'librarian' });",
+ "export const anyPrompt = getMcpPrompt('missing', { input: { topic: 7 } });",
+ "export const anyFixture = runContractMatrix({ fixtures: { 'tool:curator/missing': { input: { query: 7 }, resultCompat: 'closed' } } });",
+ "export const anyRoute = async (): Promise => (await invokeCli(['report'])).routeId;",
+ "export const anyAssertion = expectMcpCall({ server: 'curator', tool: 'missing' });",
'',
].join('\n')),
]);
@@ -174,7 +335,7 @@ it('types renderRoute ids, inputs, and results from the generated route registra
const wrongId = typecheck(root, 'wrong-id.ts', true);
expect(wrongId).toHaveLength(1);
// The rejection names the registered ids, not `never`.
- expect(wrongId[0]).toContain('Argument of type \'"tool:curator/missing"\' is not assignable to parameter of type \'"event:tool/after" | "tool:curator/find" | "tool:curator/status"\'');
+ expect(wrongId[0]).toContain(`Argument of type '"tool:curator/missing"' is not assignable to parameter of type '${registeredIds.map((id) => `"${id}"`).join(' | ')}'`);
const wrongInput = typecheck(root, 'wrong-input.ts', true);
expect(wrongInput).toHaveLength(1);
expect(wrongInput[0]).toContain("Type 'number' is not assignable to type 'string'");
@@ -185,5 +346,48 @@ it('types renderRoute ids, inputs, and results from the generated route registra
expect(wrongResult).toHaveLength(1);
expect(wrongResult[0]).toContain("Type 'number | undefined' is not assignable to type 'string | undefined'");
+ // The wire helpers: a tool name is checked against the registered protocol names of that kind, and
+ // `input` against the named route's own schema.
+ const wrongToolName = typecheck(root, 'wrong-tool-name.ts', true);
+ expect(wrongToolName).toHaveLength(1);
+ expect(wrongToolName[0]).toContain('Argument of type \'"missing"\' is not assignable to parameter of type \'"find" | "status"\'');
+ const wrongToolInput = typecheck(root, 'wrong-tool-input.ts', true);
+ expect(wrongToolInput).toHaveLength(1);
+ // `status` registers `z.object({}).strict()`, so a stray key is rejected against `Record`.
+ expect(wrongToolInput[0]).toContain("Type 'string' is not assignable to type 'never'");
+ const wrongPromptInput = typecheck(root, 'wrong-prompt-input.ts', true);
+ expect(wrongPromptInput).toHaveLength(1);
+ expect(wrongPromptInput[0]).toContain("Type 'number' is not assignable to type 'string'");
+ // A literal `server` binds the lookup to that server's routes, since the session mounts only those:
+ // a name another server registers, and the other server's input, are rejected; an unknown server is
+ // rejected on `server` itself, naming the compiled ones.
+ const wrongServerTool = typecheck(root, 'wrong-server-tool.ts', true);
+ expect(wrongServerTool).toHaveLength(1);
+ expect(wrongServerTool[0]).toContain('Argument of type \'"status"\' is not assignable to parameter of type \'"find"\'');
+ const wrongServerInput = typecheck(root, 'wrong-server-input.ts', true);
+ expect(wrongServerInput).toHaveLength(1);
+ expect(wrongServerInput[0]).toContain("'query' does not exist in type '{ isbn: string; }'");
+ const wrongServerName = typecheck(root, 'wrong-server-name.ts', true);
+ expect(wrongServerName).toHaveLength(1);
+ expect(wrongServerName[0]).toContain('Type \'"librarian"\' is not assignable to type \'"curator" | "shelf" | undefined\'');
+
+ // Contract-matrix fixtures: a registered key's `input` and lifecycle transitions carry that route's input.
+ const wrongFixtureInput = typecheck(root, 'wrong-fixture-input.ts', true);
+ expect(wrongFixtureInput).toHaveLength(1);
+ expect(wrongFixtureInput[0]).toContain("Type 'number' is not assignable to type 'string'");
+ const wrongFixtureTransition = typecheck(root, 'wrong-fixture-transition.ts', true);
+ expect(wrongFixtureTransition).toHaveLength(1);
+ expect(wrongFixtureTransition[0]).toContain("'isbn' does not exist in type '{ query: string; }'");
+
+ // `invokeCli` reports a registered id, so comparing it with an unregistered literal is rejected.
+ const wrongCliRoute = typecheck(root, 'wrong-cli-route.ts', true);
+ expect(wrongCliRoute).toHaveLength(1);
+ expect(wrongCliRoute[0]).toContain('This comparison appears to be unintentional');
+
+ // Eval assertions: a literal tool is checked against the registered tools of that literal server.
+ const wrongEvalTool = typecheck(root, 'wrong-eval-tool.ts', true);
+ expect(wrongEvalTool).toHaveLength(1);
+ expect(wrongEvalTool[0]).toContain('Type \'"status"\' is not assignable to type \'"find"\'');
+
expect(typecheck(root, 'unregistered.ts', false)).toEqual([]);
});
diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts
index 64aa0ce34..9f7025dd4 100644
--- a/packages/rsc-runtime/src/agent-request.ts
+++ b/packages/rsc-runtime/src/agent-request.ts
@@ -195,10 +195,21 @@ export interface AgentProviderValues {
* `resultSchema`, and for an event route its `{ canonical, native }` payload
* with an `undefined` result — in the same `declare module
* '@agent-bundle/runtime'` block that declares
- * provider keys on {@link AgentProviderValues}. Route-aware types such as
- * {@link RegisteredRouteId} read through it and degrade to their unregistered
- * shape (`string`, `unknown`) when the file is absent or excluded from the
- * program, so nothing here is required for a project to type-check.
+ * provider keys on {@link AgentProviderValues}.
+ *
+ * Every route-aware public type reads through this one registration, the way
+ * TanStack's `RegisteredRouter` reaches `Link to`, `useNavigate`, and
+ * `RoutesByPath`: {@link RegisteredRouteId}, {@link RegisteredRouteInput}, and
+ * {@link RegisteredRouteResult} for a route id; {@link RegisteredMcpServerName},
+ * {@link RegisteredMcpRouteName}, and {@link RegisteredMcpRouteId} for the MCP
+ * server and protocol names a registered `tool:`/`prompt:`/`resource:` id
+ * encodes. `agent-bundle/test` types `renderRoute`, `renderRouteEvents`,
+ * `invokeMcpTool`, `getMcpPrompt`, the contract-matrix `fixtures`, and
+ * `invokeCli`'s reported `routeId` from them, and `agent-bundle/eval` types
+ * `expectMcpCall`/`expectNoMcpCall`'s `tool` from them. All of them degrade to
+ * their unregistered shape (`string`, `unknown`) when the file is absent or
+ * excluded from the program, so nothing here is required for a project to
+ * type-check.
*/
// rslint-disable-next-line @typescript-eslint/no-empty-object-type -- declaration-merge extension point
export interface Register {}
@@ -227,6 +238,42 @@ export type RegisteredRouteResult = Id extends keyof Register
? RegisteredRoutes[Id] extends RegisteredRouteContract ? RegisteredRoutes[Id]['result'] : unknown
: unknown;
+/** The route kinds whose ids encode an MCP server and protocol name: `:/`. */
+export type RegisteredMcpRouteKind = 'prompt' | 'resource' | 'tool';
+
+/**
+ * The registered MCP route ids of `Kind` on `Server` whose protocol name is
+ * `Name` (`tool:curator/find` for `<'tool', 'curator', 'find'>`; the `string`
+ * defaults match every server or name), after TanStack Router's
+ * `RoutesByPath`. `string` when no project has registered.
+ */
+export type RegisteredMcpRouteId<
+ Kind extends RegisteredMcpRouteKind = RegisteredMcpRouteKind,
+ Server extends string = string,
+ Name extends string = string,
+> = unknown extends RegisteredRoutes ? string : Extract;
+
+/** Distributes over a route-id union so each member yields its own server segment. */
+type McpServerSegment = Id extends `${RegisteredMcpRouteKind}:${infer Server}/${string}` ? Server : never;
+
+/** The MCP server names the registered routes belong to (`curator` for `tool:curator/find`); `string` when no project has registered. */
+export type RegisteredMcpServerName = unknown extends RegisteredRoutes ? string : McpServerSegment;
+
+/** Distributes over a route-id union so each member yields its own protocol name. */
+type McpNameSegment =
+ Id extends `${Kind}:${Server}/${infer Name}` ? Name : never;
+
+/**
+ * The protocol names (the wire `tools/call` or `prompts/get` name) of the
+ * registered MCP routes of `Kind` on `Server`: `find | status` for the tool
+ * routes `tool:curator/find` and `tool:curator/status`. `string` when no
+ * project has registered.
+ */
+export type RegisteredMcpRouteName<
+ Kind extends RegisteredMcpRouteKind = RegisteredMcpRouteKind,
+ Server extends string = string,
+> = unknown extends RegisteredRoutes ? string : McpNameSegment;
+
export interface AgentInvocation {
readonly artifactEpoch?: string;
readonly hostContractRevision?: string;
diff --git a/packages/rsc-runtime/src/plugin.ts b/packages/rsc-runtime/src/plugin.ts
index a1f7f658e..b52e9247c 100644
--- a/packages/rsc-runtime/src/plugin.ts
+++ b/packages/rsc-runtime/src/plugin.ts
@@ -29,6 +29,10 @@ export type {
AgentRenderInvocation,
AgentProviderValues,
Register,
+ RegisteredMcpRouteId,
+ RegisteredMcpRouteKind,
+ RegisteredMcpRouteName,
+ RegisteredMcpServerName,
RegisteredRouteContract,
RegisteredRouteId,
RegisteredRouteInput,
diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx
index c4c91a03f..bab53862e 100644
--- a/website/docs/en/guide/development/testing.mdx
+++ b/website/docs/en/guide/development/testing.mdx
@@ -82,6 +82,26 @@ types: any string id, `unknown` input, `unknown` result. `RegisteredRouteId`,
`RegisteredRouteInput`, and `RegisteredRouteResult` from `@agent-bundle/runtime` name the
registered surface directly, for a wrapper of your own.
+The same registration reaches every other harness surface that takes a route id or a route
+payload, the way TanStack Router's one `Register` reaches `Link to`, `useNavigate`, and
+`RoutesByPath`. `invokeMcpTool('find', { input })` and `getMcpPrompt('brief', { input })` check
+their wire name against the registered tool or prompt names (`find` for `tool:curator/find`) and
+type `input` from that route's `inputSchema` — a union when two servers register the same name and
+`server` is omitted, that one server's route when a literal `server` is passed (and `server` itself is
+checked against the compiled server names);
+`runContractMatrix`, `runPackedContractMatrix`, `runDevEpochContractMatrix`, and
+`runInstalledHostContractMatrix` type each registered key of `fixtures` (`input`, `inputs`,
+`cancellation.input`, and lifecycle transitions) from that route, while an MCP App route key or a
+`Record` built dynamically stays legal; `invokeCli` reports the
+executed command's `routeId` as a registered `cli:` or `tool:` id (`argv` stays
+`readonly string[]`); and `agent-bundle/eval`'s `expectMcpCall({ server, tool })` checks a
+literal `tool` against the registered tools of a literal `server` of this project, leaving a
+third-party server free. `RegisteredMcpServerName`, `RegisteredMcpRouteName`, and
+`RegisteredMcpRouteId` name the server and protocol names a registered id encodes. Not typed,
+deliberately: `readMcpResource` (a wire URI, which the registration does not carry), `runScript`
+(script routes register no contract), `structuredContent` (the wire carries it only for
+object-valued documents), and `agent-bundle/api`, which addresses an arbitrary project `root`.
+
`testManifest()` exposes the compiled route inventory, so a suite can iterate every route in
process rather than paying for a build per route. Every failure — an unknown route, a refused
route kind, a rejected input, a render error — names the route id, the target kind, and the
diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx
index 677ff51f2..59d08eaa8 100644
--- a/website/docs/zh/guide/development/testing.mdx
+++ b/website/docs/zh/guide/development/testing.mdx
@@ -71,6 +71,22 @@ const chapters: number | undefined = result?.chapters; // 无需强制类型转
`RegisteredRouteId`、`RegisteredRouteInput` 与 `RegisteredRouteResult` 直接命名这一注册表面,便于
你自己封装。
+同一份注册会流向测试工具中每一个接受 route id 或路由载荷的表面,正如 TanStack Router 的那一个
+`Register` 会流向 `Link to`、`useNavigate` 与 `RoutesByPath`。`invokeMcpTool('find', { input })` 与
+`getMcpPrompt('brief', { input })` 会把线上名称对照已注册的工具或提示名称做检查(`tool:curator/find`
+对应 `find`),并根据该路由的 `inputSchema` 为 `input` 定型——省略 `server` 且两个服务器注册了同名路由时为二者的
+联合类型,传入字面量 `server` 时则只取该服务器上的那条路由(`server` 本身也会对照已编译的服务器名称
+做检查);`runContractMatrix`、`runPackedContractMatrix`、`runDevEpochContractMatrix` 与
+`runInstalledHostContractMatrix` 会根据对应路由为 `fixtures` 中每个已注册键定型(`input`、`inputs`、
+`cancellation.input` 与生命周期 transition),而 MCP App 路由的键或动态构造的
+`Record` 仍然合法;`invokeCli` 把已执行命令的 `routeId` 报告为已注册的
+`cli:` 或 `tool:` id(`argv` 仍为 `readonly string[]`);`agent-bundle/eval` 的
+`expectMcpCall({ server, tool })` 会把字面量 `tool` 对照本项目字面量 `server` 上已注册的工具做检查,
+第三方服务器则不受约束。`RegisteredMcpServerName`、`RegisteredMcpRouteName` 与 `RegisteredMcpRouteId`
+命名一个已注册 id 所编码的服务器与协议名称。以下表面有意保持不定型:`readMcpResource`(线上 URI,注册
+表中并不携带)、`runScript`(脚本路由不注册契约)、`structuredContent`(线上仅在文档取值为对象时携带)
+以及 `agent-bundle/api`(它面向任意项目 `root`)。
+
`testManifest()` 暴露编译后的路由清单,因此一个测试套件可以在进程内遍历每个路由,而不必为每个路由付出
一次构建的代价。任何失败——未知路由、被拒绝的路由种类、被拒的输入、渲染错误——都会指明 route id、target
种类与模块 provenance。