diff --git a/.changeset/748-752-route-caller-input-types.md b/.changeset/748-752-route-caller-input-types.md new file mode 100644 index 000000000..5664e0917 --- /dev/null +++ b/.changeset/748-752-route-caller-input-types.md @@ -0,0 +1,7 @@ +--- +"agent-bundle": minor +"@agent-bundle/runtime": minor +"create-agent-bundle": patch +--- + +Type route callers by schema input and route components by schema output in the generated `.agent-bundle/routes.d.ts`: `createAppClient().call`, `onToolInput`, `renderRoute`, `invokeMcpTool`, and the contract matrix accept what a caller sends (a `.default()`ed field is optional, a `.transform()`ed field is spelled as the wire carries it), while `ToolRouteProps` keeps the parsed output. The registration carries both sides: `RegisteredRouteInput` and the new `RegisteredRouteParsedInput` (`@agent-bundle/runtime`), the latter typing `loadRouteModule(id).inputSchema.parse`; `agent-bundle/test` exports `RouteTargetParsedInput`. `getMcpPrompt` and `McpInvocationOptions` type their `input` as the caller's side too. A structural schema declaring only `_output` uses it for both. `renderRoute` now parses its input through the route's own `inputSchema` — for MCP and `cli:` routes alike — before the component runs and fails with an `invalid-input` harness error on rejected input. `agent-bundle validate` reports `AB4834` once per TypeScript program that imports `agent-bundle/app`, `agent-bundle/test`, `agent-bundle/eval`, or `@agent-bundle/runtime` and omits the generated declaration — following `references` transitively — instead of accepting any one referenced program that includes it. The `mcp-server` and `cli-tool` starters run `agent-bundle validate` inside `npm run typecheck`, so a clean checkout type-checks against current route declarations. (#757) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 14cc2b6ce..2c645bedc 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1072,13 +1072,19 @@ it: `create-agent-bundle` templates and the `examples/*` projects list because `**/*` never descends into dot-directories), while the file itself stays gitignored. After publishing the declaration, `agent-bundle validate` resolves the root `tsconfig.json` program the way `tsc -p` does — `extends`, -`files`, `include`, `exclude`, and one level of `references` for a -solution-style root — and reports `AB4834` (a **warning**, surfaced by -`validate` only) when the published file is not among its root files. A -project with no root `tsconfig.json`, no published declaration (route-free -and provider-free), or a `tsconfig.json` TypeScript cannot parse gets no -diagnostic: there is no program to be missing from, or `tsc` already -reports the parse failure itself. +`files`, `include`, `exclude`, then the modules those roots import, so a +narrow `files: ["src/index.ts"]` still reaches the consumer it imports — and +every program it `references`, transitively, and reports `AB4834` (a +**warning**, surfaced by `validate` only) once per program that *consumes* +the registration but does not compile the published file. A program consumes +it when one of the project's files in it imports `agent-bundle/app`, `agent-bundle/test`, `agent-bundle/eval`, or +`@agent-bundle/runtime`; a build-only project that imports none of them is +left alone, and a solution whose server project includes the file cannot +hide a browser project that omits it. A project with no root +`tsconfig.json`, no published declaration (route-free and provider-free), or +a `tsconfig.json` TypeScript cannot parse gets no diagnostic: there is no +program to be missing from, or `tsc` already reports the parse failure +itself. Conventional `src/scripts/` routes ship through the same pipeline as explicit `scripts` entries (#102 stage 1): a plain module directly under @@ -1312,7 +1318,7 @@ resolving a provider set the author did not write. | `AB4831` | error | Two layout modules declare one layout scope (for example `src/layout.ts` beside `src/layout.tsx`). Keep exactly one module per scope. | | `AB4832` | error | A server layout (`src/mcp//layout.*`) names an MCP server that declares no tool, resource, or prompt route modules — the server directory is missing or holds only `apps/` routes, which never take a layout. Add routes under that server directory, move the layout, or rename it `_layout.*` to opt out. A server pinned to `custom`, `command`, or `remote` via `routes.servers.` is skipped entirely: its layout is neither validated (`AB4830`) nor retained, because no generated worker composes it. | | `AB4833` | error | `notices.retention` is malformed: `notices` or `retention` is not an object, carries an unknown key, `terminalTtl` is not a positive integer of milliseconds or a duration such as `"7d"`, `"12h"`, `"30m"`, or `"90s"`, `maxTerminal` / `maxJournalBytes` is not a positive integer — or the policy is declared by a project without a conventional `src/state.ts`, which has no co-mounted notice ledger to retain. Omit a field to keep the runtime default (`7d`, `500`, `16777216`). | -| `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but the root `tsconfig.json` program — resolved like `tsc -p`, including `extends` and one level of project `references` — does not compile it, so `renderRoute` / `renderRouteEvents` type-check route ids as `string` and `input` / `result` as `unknown`. Reported on `tsconfig.json`; never for a project without one. Add `".agent-bundle/routes.d.ts"` to `tsconfig.json` `include` (not `files`: an `include` entry is inert until the first build publishes the file, while a missing `files` entry is a `tsc` error); `build`, `dev`, and `validate` keep the file current and it stays gitignored. | +| `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but a TypeScript program that consumes the registration — the root `tsconfig.json` or any project it `references`, transitively, resolved like `tsc -p` with `extends`, whose source imports `agent-bundle/app`, `agent-bundle/test`, `agent-bundle/eval`, or `@agent-bundle/runtime` — does not compile it, so that program type-checks route ids as `string` and `input` / `result` / provider values as `unknown`. Reported once per such program, on its tsconfig; never for a program that imports none of those modules, nor for a project without a root `tsconfig.json`. Add the file to that tsconfig's `include`, spelled relative to that tsconfig (not `files`: an `include` entry is inert until the first `validate` publishes the file, while a missing `files` entry is a `tsc` error); a tsconfig without its own `include` array must declare one that also lists the patterns it inherits or the `**/*` default, since an `include` array replaces them; `validate`, `build`, and `dev` keep the file current and it stays gitignored. | | `AB4835` | error | A route's static `config.render` (the render budget of one call, #454) is malformed: `render` is not an object, carries a key other than `maxElapsedMs`, `maxElapsedMs` is not a positive integer of milliseconds, or it exceeds the framework ceiling of `86400000` (24 hours) — or a plain `.ts` CLI command declares one, although it executes without a render session. Reported once per route: on an MCP tool, resource, or prompt route with its server (the tool's projected CLI command inherits the value), or on a `src/cli/**` command route; a route with a rejected budget compiles no command. Omit `render` to keep the runtime default (`60000`). Declare `config.render = { maxElapsedMs: }` on a rendered route, or remove it. The budget bounds the framework's render session only: Codex's `tool_timeout_sec` (60 s by default) and any per-server host timeout must be raised by the operator separately, while Claude Code's default per-call wall clock is about 28 hours and its idle timer is kept alive by the `notifications/progress` the projector forwards. | | `AB4836` | error | A route's static `config.execution` (MCP task support, #369) is malformed: `execution` is not an object, carries a key other than `taskSupport`, or `taskSupport` is not one of `forbidden`, `optional`, `required` — or a resource or prompt route declares it, although the `2025-11-25` Tasks utility augments `tools/call` only. Reported once per route with its server. Omit `execution` to keep the wire default (`forbidden`: every call is an ordinary request), or declare `config.execution = { taskSupport: 'optional' }` so a task-aware client may receive a `CreateTaskResult` and poll `tasks/get` / `tasks/result` while the render continues, or `'required'` to refuse ordinary calls with JSON-RPC `-32601`. The generated server advertises the value in `tools/list` and declares the `tasks` capability only when at least one tool opted in. | | `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Keep framework calls in a host process: expose an MCP App with `web.apps` and open it from the installed artifact with ` web`; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/app` (the browser MCP App client, a leaf with no Zod, Node, or compiler import), `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/web-host`. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 380355e0b..dbc528c6a 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1814,8 +1814,12 @@ injected ports. `generateRouteTypes` (`src/routes/typegen.ts`) emits, for a graph with at least one tool route, `AppToolRouteId` (the `tool:/` subset of `RouteId`), `AgentBundleAppRouteContracts` (one `{ input: RouteInput; -result: RouteResult }` per tool from the module's own `inputSchema` and -`resultSchema` output), and exactly one augmentation: +result: RouteResult }` per tool — `input` from the module's own +`inputSchema` *input* type, what a caller sends before the server parses it, +so a defaulted field is optional and a transformed field is spelled as the +wire carries it; `result` from the `resultSchema` output; a structural schema +declaring only `_output` uses it for both, #752), and exactly one +augmentation: ```ts declare module 'agent-bundle/app' { diff --git a/examples/host-test/tests/route-unit/routes.test.ts b/examples/host-test/tests/route-unit/routes.test.ts index 8c55f3fad..4eccf17d4 100644 --- a/examples/host-test/tests/route-unit/routes.test.ts +++ b/examples/host-test/tests/route-unit/routes.test.ts @@ -91,15 +91,15 @@ it('compiles every canonical event family plus the MCP and CLI surfaces', () => }); it('holds the slow probe open for the requested time, reporting one progress tick per tickMs, and records the call', async () => { - const slow = await render('tool:host-test/slow', { holdMs: 120, tickMs: 40 }); + const slow = await render('tool:host-test/slow', { holdMs: 150, tickMs: 50 }); expect(slow.document.value).toMatchObject({ ticks: 3 }); - expect((slow.document.value as { heldMs: number }).heldMs).toBeGreaterThanOrEqual(100); + expect((slow.document.value as { heldMs: number }).heldMs).toBeGreaterThanOrEqual(130); expect(slow.progress.map((update) => update.completed)).toEqual([1, 2, 3]); // The probe is recorded like every other MCP call; the dump that reads it records itself too. const dumped = await render('tool:host-test/dump', {}); expect(dumped.document.value).toMatchObject({ records: [ - expect.objectContaining({ event: 'mcp:slow', kind: 'mcp', observed: { holdMs: 120, tickMs: 40, tool: 'slow' } }), + expect.objectContaining({ event: 'mcp:slow', kind: 'mcp', observed: { holdMs: 150, tickMs: 50, tool: 'slow' } }), expect.objectContaining({ event: 'mcp:dump', kind: 'mcp' }), ], }); diff --git a/packages/agent-bundle/src/app/index.ts b/packages/agent-bundle/src/app/index.ts index 05afde5ca..9783d34f5 100644 --- a/packages/agent-bundle/src/app/index.ts +++ b/packages/agent-bundle/src/app/index.ts @@ -62,10 +62,12 @@ export type AppRouteId = unknown extends AppRoutes ? `tool:${string}/${string}` : Extract & string; +/** What a caller sends for one route: the registered `inputSchema` input type (defaults optional, transforms as the wire carries them), before the server parses it. */ export type AppRouteInput = Id extends keyof AppRoutes ? AppRoutes[Id] extends AppRouteContract ? AppRoutes[Id]['input'] : unknown : unknown; +/** The structured result one route resolves with: the registered `resultSchema` output. */ export type AppRouteResult = Id extends keyof AppRoutes ? AppRoutes[Id] extends AppRouteContract ? AppRoutes[Id]['result'] : unknown : unknown; diff --git a/packages/agent-bundle/src/routes/typegen-program.ts b/packages/agent-bundle/src/routes/typegen-program.ts index c6ddc8730..6c4245d56 100644 --- a/packages/agent-bundle/src/routes/typegen-program.ts +++ b/packages/agent-bundle/src/routes/typegen-program.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { dirname, join, 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 @@ -13,24 +13,64 @@ import { routeTypesRelativePath } from './typegen.ts'; /** The file `tsc -p` and every editor read as the project's TypeScript program. */ const projectTsconfigFilename = 'tsconfig.json'; +/** + * The entries whose declarations the generated file augments or narrows: + * a program that imports one of them observes the registration, and one that + * omits the file type-checks those imports with `string` ids and `unknown` + * input/result/provider values, silently. Route modules import + * `@agent-bundle/runtime` (providers), App views `agent-bundle/app`, tests + * `agent-bundle/test` and `agent-bundle/eval`. Exact specifiers: no other + * entry (`agent-bundle/test/browser`, `agent-bundle/routes`) reads the + * registration. + */ +const consumerEntries: ReadonlySet = new Set(['agent-bundle/app', 'agent-bundle/eval', 'agent-bundle/test', '@agent-bundle/runtime']); + const comparablePath = (path: string): string => { const resolved = resolve(path); return ts.sys.useCaseSensitiveFileNames ? resolved : resolved.toLowerCase(); }; +interface Program { + /** The program `tsc -p` would build: the configured roots plus the modules they import, transitively. */ + readonly program: ts.Program; + /** Whether the config file itself declares `include` (not inherited through `extends`, not the `**` default). */ + readonly ownInclude: boolean; + readonly references: readonly string[]; + readonly tsconfigPath: string; +} + +/** + * The project's own files in the program `tsc -p` would build from the parsed + * config: the roots plus the modules they import, transitively. Installed + * packages, the lib files, and automatic `@types` are not the project's + * consumers, so the host declines to parse them — a program a few hundred + * files smaller than the real one, with the same import closure over the + * project's sources. + */ +const projectProgram = (parsed: ts.ParsedCommandLine): ts.Program => { + const options: ts.CompilerOptions = { ...parsed.options, noLib: true, types: [] }; + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (fileName, ...rest) => (fileName.includes('/node_modules/') ? undefined : getSourceFile(fileName, ...rest)); + // With the references, an import into a referenced project reads that + // project's emitted declaration, as `tsc -p` does, not its source. + return ts.createProgram({ host, options, projectReferences: parsed.projectReferences, rootNames: parsed.fileNames }); +}; + /** - * The root file names of one tsconfig's program, resolved the way `tsc -p` - * resolves them (`extends`, `files`, `include`, `exclude`, against the real - * file system), plus the referenced projects a solution-style root delegates - * to. `undefined` when the file cannot be read or parsed as a config: `tsc` + * One tsconfig's program, built the way `tsc -p` builds it: the roots from + * `extends`, `files`, `include`, and `exclude` against the real file system, + * then every module those roots import — a narrow `files: ["src/index.ts"]` + * still compiles the consumer it imports — plus the referenced projects a + * solution-style root delegates to. `undefined` when the file cannot be read or parsed as a config: `tsc` * reports that failure itself, and a broken tsconfig has no program to be * missing from. */ -const programRootFiles = ( - tsconfigPath: string, -): { readonly fileNames: readonly string[]; readonly references: readonly string[] } | undefined => { +const program = (tsconfigPath: string): Program | undefined => { const read = ts.readConfigFile(tsconfigPath, ts.sys.readFile); if (read.error !== undefined || read.config === undefined) return undefined; + // Read before parsing: the parser writes the inherited `include` back onto the raw config. + const ownInclude = Array.isArray((read.config as { readonly include?: unknown }).include); const parsed = ts.parseJsonConfigFileContent( read.config, ts.sys, @@ -39,41 +79,83 @@ const programRootFiles = ( tsconfigPath, ); return { - fileNames: parsed.fileNames, + program: projectProgram(parsed), + ownInclude, references: (parsed.projectReferences ?? []).map((reference) => ts.resolveProjectReferencePath(reference)), + tsconfigPath, + }; +}; + +/** The root program and every program it references, transitively, each once. */ +const programs = (rootTsconfigPath: string): readonly Program[] => { + const seen = new Set(); + const found: Program[] = []; + const visit = (tsconfigPath: string): void => { + const key = comparablePath(tsconfigPath); + if (seen.has(key) || !existsSync(tsconfigPath)) return; + seen.add(key); + const resolved = program(tsconfigPath); + if (resolved === undefined) return; + found.push(resolved); + resolved.references.forEach(visit); }; + visit(rootTsconfigPath); + return found; }; +/** + * Whether one of the project's own files in the program imports an entry the + * generated declaration augments. A file the program reached through a package + * import — a workspace-linked package's `dist`, which resolves to a real path + * the host could not decline — is a dependency, not the project's consumer, + * even when it imports the runtime itself. The scanner's import pre-processing + * reads static and dynamic import specifiers only — a specifier in a comment + * or a string literal is not an import — and a user's own `.d.ts` counts like + * any other file, since `import type` from a consumer entry reads the + * registration too. + */ +const consumesRegistration = (program: ts.Program): boolean => + program.getSourceFiles().some((sourceFile) => + !program.isSourceFileFromExternalLibrary(sourceFile) + && ts.preProcessFile(sourceFile.text, true, false).importedFiles + .some((imported) => consumerEntries.has(imported.fileName))); + /** * AB4834: the generated `.agent-bundle/routes.d.ts` registers the project's - * route contracts and provider keys on `@agent-bundle/runtime`, but only a - * TypeScript program that compiles the file observes them — a program that - * leaves it out type-checks `renderRoute` ids as `string` and `input` / - * `result` as `unknown`, silently. Reported once the declaration has been - * published (a route-free, provider-free project has no file to include) for a - * project whose root `tsconfig.json` program — its own root files or, for a - * solution-style root, one of its referenced projects — does not compile it. - * A project without a root `tsconfig.json` has no program to check. + * route contracts and provider keys on `@agent-bundle/runtime`, + * `agent-bundle/app`, and the harness modules, but only a TypeScript program + * that compiles the file observes them. Reported once the declaration has + * been published (a route-free, provider-free project has no file to include) + * for every program of the root `tsconfig.json` — its own or, for a + * solution-style root, any project it references, transitively — that + * imports one of those modules and does not compile the file. A program that + * imports none of them is not a consumer and is left alone, and a project + * without a root `tsconfig.json` has no program to check. */ export const routeTypesProgramDiagnostics = (projectRoot: string): readonly Diagnostic[] => { const routeTypesPath = join(projectRoot, routeTypesRelativePath); - const tsconfigPath = join(projectRoot, projectTsconfigFilename); - if (!existsSync(routeTypesPath) || !existsSync(tsconfigPath)) return []; - const root = programRootFiles(tsconfigPath); - if (root === undefined) return []; - const expected = comparablePath(routeTypesPath); - const compiles = (fileNames: readonly string[]): boolean => - fileNames.some((fileName) => comparablePath(fileName) === expected); - if (compiles(root.fileNames)) return []; - for (const reference of root.references) { - const referenced = existsSync(reference) ? programRootFiles(reference) : undefined; - if (referenced !== undefined && compiles(referenced.fileNames)) return []; - } - return [{ - code: 'AB4834', - message: `${projectTsconfigFilename} does not include the generated ${routeTypesRelativePath}, so renderRoute and renderRouteEvents type-check route ids as string and input/result as unknown.`, - recovery: `Add ${JSON.stringify(routeTypesRelativePath)} to the "include" array of ${projectTsconfigFilename}; agent-bundle build, dev, and validate keep the file current, and it stays gitignored.`, - severity: 'warning', - sourcePath: tsconfigPath, - }]; + const rootTsconfigPath = join(projectRoot, projectTsconfigFilename); + if (!existsSync(routeTypesPath) || !existsSync(rootTsconfigPath)) return []; + return programs(rootTsconfigPath) + .filter((candidate) => + candidate.program.getSourceFile(routeTypesPath) === undefined + && consumesRegistration(candidate.program)) + .map((candidate) => { + const tsconfig = relative(projectRoot, candidate.tsconfigPath).replaceAll('\\', '/'); + const include = JSON.stringify(relative(dirname(candidate.tsconfigPath), routeTypesPath).replaceAll('\\', '/')); + // An `include` array replaces the default (`**/*`) or the inherited + // patterns, so a config without its own must keep them when it adds one. + // The path is spelled for this config; the one it extends may sit in + // another directory, so the recovery never points there. + const where = candidate.ownInclude + ? `Add ${include} to the "include" array of ${tsconfig}` + : `Declare an "include" array in ${tsconfig} that lists ${include} beside the patterns it compiles today (an "include" array replaces the inherited or default "**/*" patterns)`; + return { + code: 'AB4834', + message: `${tsconfig} imports agent-bundle/app, agent-bundle/test, agent-bundle/eval, or @agent-bundle/runtime but does not include the generated ${routeTypesRelativePath}, so that program type-checks route ids as string and input/result/provider values as unknown.`, + recovery: `${where}; agent-bundle validate, build, and dev keep the file current, and it stays gitignored.`, + severity: 'warning', + sourcePath: candidate.tsconfigPath, + }; + }); }; diff --git a/packages/agent-bundle/src/routes/typegen.ts b/packages/agent-bundle/src/routes/typegen.ts index a99635f89..e6c99d797 100644 --- a/packages/agent-bundle/src/routes/typegen.ts +++ b/packages/agent-bundle/src/routes/typegen.ts @@ -75,8 +75,9 @@ const hasToolRoutes = (routes: readonly CompiledAgentRoute[]): boolean => * is the MCP tool subset of `AgentBundleRoutes` — the `tool:/` * ids filtered at the type level, so every route id is spelled once and the * contracts reuse the same type-only route module imports — one - * `{ input, result }` per tool from the module's own `inputSchema` and - * `resultSchema` output. The filter is inlined in the mapped type rather than + * `{ input, result }` per tool from the module's own `inputSchema` input + * (what a caller sends, before the server parses it) and `resultSchema` + * output. The filter is inlined in the mapped type rather than * named first so `keyof` the map (what an App client's route-id parameter * resolves to) prints as the tool ids in a rejection, not as an alias name. * Omitted for graphs without a tool route. @@ -99,7 +100,7 @@ const appDeclarations = (routes: readonly CompiledAgentRoute[]): readonly string * the tool contract map (the same `Register` pattern as the runtime * augmentation below), so `createAppClient().call(id, input)` narrows its * route id to the project's tools, `input` to that tool's `inputSchema` - * output, and its resolved value to the `resultSchema` output. Type-only: + * input, and its resolved value to the `resultSchema` output. Type-only: * the App bundle never loads a route module, Zod, or Node through it. * Omitted with the contract map for graphs without a tool route, so the * augmentation never references a module the project has no reason to @@ -120,10 +121,10 @@ const appAugmentation = (routes: readonly CompiledAgentRoute[]): readonly string /** * The single `@agent-bundle/runtime` augmentation. Its `Register.routes` - * member registers the thin `{ input, result }` contract map (TanStack + * member registers the thin `{ input, parsedInput, result }` contract map (TanStack * Router's `Register` pattern), so `agent-bundle/test`'s `renderRoute` narrows * its route-id parameter, `input`, and `result` from the project's own route - * modules — a schema route's `inputSchema`/`resultSchema` output, an event + * modules — a schema route's `inputSchema` input and `resultSchema` output, an event * route's `{ canonical, native }` payload with no result; its * `AgentProviderValues` members make * `(await agent()).providers.` observe each factory's resolved type. @@ -166,8 +167,14 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => { ...providers.map(providerImport), '', 'type SchemaOutput = Schema extends { readonly _output: infer Output } ? Output : never;', + '// A schema that declares `_input` (Zod) types a caller by it; a structural schema that declares only', + '// `_output` parses without defaults or transforms, so its output is also what a caller sends.', + 'type SchemaInput = Schema extends { readonly _input: infer Input } ? Input : SchemaOutput;', + '// `input` is what a caller sends — the schema\'s own input type, so a defaulted or transformed field is', + '// spelled the way the wire carries it; the route component receives the parsed output instead.', 'export type RouteContract = Readonly<{', - ' input: SchemaOutput;', + ' input: SchemaInput;', + ' parsedInput: SchemaOutput;', ' result: SchemaOutput;', '}>;', 'export type EventRouteContract = Readonly<{', @@ -191,6 +198,10 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => { ' Contract extends { readonly input: infer Input } ? Input', " : Contract extends { readonly component: infer Component } ? Omit, 'signal'>", ' : never;', + 'type HarnessParsedInput =', + ' Contract extends { readonly parsedInput: infer Parsed } ? Parsed', + " : Contract extends { readonly component: infer Component } ? Omit, 'signal'>", + ' : never;', 'type HarnessResult =', ' Contract extends { readonly result: infer Result } ? Result', ' : Contract extends { readonly component: unknown } ? undefined', @@ -206,9 +217,13 @@ export const generateRouteTypes = (graph: CompiledRouteGraph): string => { 'export type RouteId = keyof AgentBundleRoutes;', 'export type RouteInput = ContractInput;', 'export type RouteResult = ContractResult;', - '/** The registered harness contract map: one `{ input, result }` per route id, for `@agent-bundle/runtime`\'s `Register`. */', + '/** The registered harness contract map: one `{ input, parsedInput, result }` per route id, for `@agent-bundle/runtime`\'s `Register`. */', 'export type AgentBundleRouteContracts = {', - ' readonly [Id in RouteId]: Readonly<{ input: HarnessInput; result: HarnessResult }>;', + ' readonly [Id in RouteId]: Readonly<{', + ' input: HarnessInput;', + ' parsedInput: HarnessParsedInput;', + ' result: HarnessResult;', + ' }>;', '};', '', ...appDeclarations(routes), diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index ab13a8e35..7d1e6884d 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -80,6 +80,7 @@ export type { RenderedRouteEvents, RouteTargetInput, RouteTargetConstraint, + RouteTargetParsedInput, RouteTargetResult, } from './render.ts'; export { expectDocument } from './matchers.ts'; diff --git a/packages/agent-bundle/src/test/mcp.ts b/packages/agent-bundle/src/test/mcp.ts index cbc230c3a..90bfb2535 100644 --- a/packages/agent-bundle/src/test/mcp.ts +++ b/packages/agent-bundle/src/test/mcp.ts @@ -149,9 +149,10 @@ export type McpServerConstraint = string extends Server ? string : Regis 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 + * Options of one wire invocation. `Input` is the payload a caller sends over + * the wire — the route's `inputSchema` input, before the server parses it into + * what the route receives: `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. diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 487e3938a..4ffb54d0f 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -22,6 +22,7 @@ import type { AgentRequestInit, RegisteredRouteId, RegisteredRouteInput, + RegisteredRouteParsedInput, RegisteredRouteResult, } from '@agent-bundle/runtime'; import type * as React from 'react'; @@ -114,6 +115,9 @@ export type RouteTargetConstraint = Target extends AgentRouteModule /** The registered input type of a route target; `unknown` for a module target, a dynamic string, or an unregistered project. */ export type RouteTargetInput = Target extends RegisteredRouteId ? RegisteredRouteInput : unknown; +/** The registered parsed input of a route target — what its component receives after defaults and transforms; `unknown` when unregistered. */ +export type RouteTargetParsedInput = Target extends RegisteredRouteId ? RegisteredRouteParsedInput : unknown; + /** The registered result type of a route target; `unknown` for a module target, a dynamic string, or an unregistered project. */ export type RouteTargetResult = Target extends RegisteredRouteId ? RegisteredRouteResult : unknown; @@ -386,12 +390,13 @@ const componentProps = ( kind: RenderableRouteKind, options: RenderRouteOptions, signal: AbortSignal, + parsedInput: unknown, ): Readonly> => { switch (kind) { case 'prompt': case 'resource': case 'tool': - return { input: (invocation.props as { readonly input?: unknown }).input, signal }; + return { input: parsedInput, signal }; case 'event-route': { // The public event-route contract is `{ canonical, native, signal }`, // and the generated Flight worker unwraps the payload into exactly that. @@ -406,7 +411,7 @@ const componentProps = ( }; } case 'cli': - return { input: options.input ?? {}, signal }; + return { input: parsedInput, signal }; case 'script': return { argv: (invocation.props as { readonly input?: unknown }).input ?? [], signal }; default: { @@ -573,7 +578,8 @@ export interface LoadedRouteModule { readonly [exportName: string]: unknown; readonly config?: unknown; readonly default?: (props: never) => unknown; - readonly inputSchema?: RouteModuleSchema>; + /** `parse` returns the component's input — the caller's input after defaults and transforms. */ + readonly inputSchema?: RouteModuleSchema>; readonly resultSchema?: RouteModuleSchema>; } @@ -625,6 +631,35 @@ export const loadRouteModule = async ( return loaded.module as LoadedRouteModule; }; +/** + * The component's `input` prop: the caller's input parsed by the route's own + * `inputSchema`, exactly where the generated Flight worker parses it (defaults + * filled, transforms applied) — so the registration types what the caller + * sends and the component still sees the schema's output. Rejected before the + * request scope opens, so no provider or component runs on invalid input. + * A module without an `inputSchema` (a script) receives the input as given. + */ +const parsedInput = ( + schema: { readonly parse: (value: unknown) => unknown } | undefined, + input: unknown, + provenance: RenderedRouteProvenance, +): unknown => { + if (schema === undefined) return input; + try { + return schema.parse(input); + } catch (error) { + throw new AgentTestError('invalid-input', "The route's own inputSchema rejected the input.", { + cause: error, + details: [ + `cause: ${error instanceof Error ? error.message : String(error)}`, + `received: ${captured(input)}`, + ], + provenance, + recovery: 'Pass the input the route\'s inputSchema accepts; defaults and transforms are applied by the parse, not by the caller.', + }); + } +}; + /** * The structured result a generated server would return: the document value * validated by the route's own `resultSchema`. A document that renders but @@ -1434,6 +1469,9 @@ const prepareRender = async ( const renderer = await loadRenderer(); const surface = executableSurface(resolved.kind, resolved.provenance.routeId, resolved.manifest); const invocation = invocationFor(resolved.kind, resolved.provenance.routeId, surface, options, resolved.provenance); + const input = resolved.kind === 'cli' || resolved.kind === 'prompt' || resolved.kind === 'resource' || resolved.kind === 'tool' + ? parsedInput(resolved.module.inputSchema, options.input ?? {}, resolved.provenance) + : undefined; const collected: AgentProgressUpdate[] = []; const context = options.context ?? {}; const signal = options.signal ?? new AbortController().signal; @@ -1445,7 +1483,7 @@ const prepareRender = async ( const dispatcher = createFlightDispatcher({ collected, component: resolved.component, - componentProps: (request) => componentProps(request.invocation, resolved.kind, options, request.signal), + componentProps: (request) => componentProps(request.invocation, resolved.kind, options, request.signal, input), contextProgress: context.progress, layoutRoute: { id: resolved.provenance.routeId, diff --git a/packages/agent-bundle/tests/route-caller-input-types.test.ts b/packages/agent-bundle/tests/route-caller-input-types.test.ts new file mode 100644 index 000000000..784f0e486 --- /dev/null +++ b/packages/agent-bundle/tests/route-caller-input-types.test.ts @@ -0,0 +1,253 @@ +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, expect, it } from '@rstest/core'; +import ts from 'typescript-5'; + +import { build, validate } from '../src/api.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const writeProjectFile = async (root: string, path: string, contents: string): Promise => { + const output = join(root, path); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, contents); +}; + +const equality = [ + 'type Equal =', + ' (() => Value extends Left ? 1 : 2) extends', + ' (() => Value extends Right ? 1 : 2) ? true : false;', + 'type Assert = Value;', +]; + +/** The tool records every invocation it actually ran, so a rejected input provably ran nothing. */ +const tool = (name: string, schema: string, result: string, body: string): string => [ + "import { Agent } from '@agent-bundle/runtime';", + "import type { ToolRouteProps } from 'agent-bundle';", + "import { appendFile } from 'node:fs/promises';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + `export const config = { description: '${name}' };`, + `export const inputSchema = ${schema};`, + `export const resultSchema = ${result};`, + `export default async function Route({ input }: ToolRouteProps) {`, + ` await appendFile(process.env['INVOCATIONS']!, JSON.stringify({ input, tool: '${name}' }) + '\\n');`, + ` const value = ${body};`, + " return createElement(Agent.Result, { value }, createElement(Agent.Text, null, JSON.stringify(value)));", + '}', + '', +].join('\n'); + +const tsconfig = (include: readonly string[], lib: readonly string[]): string => `${JSON.stringify({ + compilerOptions: { + composite: true, + exactOptionalPropertyTypes: true, + lib, + module: 'NodeNext', + moduleResolution: 'NodeNext', + noEmit: true, + skipLibCheck: true, + strict: true, + target: 'ES2022', + types: [], + }, + include, +}, null, 2)}\n`; + +/** + * Type-checks one of the project's real tsconfig programs — the file set + * `tsc -p ` compiles, resolved from the config on disk — optionally + * with one extra entry added the way another `include` line would add it. + */ +const typecheckProgram = (root: string, tsconfigPath: string, extraEntry?: string): readonly string[] => { + const read = ts.readConfigFile(join(root, tsconfigPath), ts.sys.readFile); + if (read.error !== undefined) throw new Error(ts.flattenDiagnosticMessageText(read.error.messageText, '\n')); + const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root, undefined, join(root, tsconfigPath)); + const program = ts.createProgram( + [...parsed.fileNames, ...(extraEntry === undefined ? [] : [join(root, extraEntry)])], + { ...parsed.options, composite: false, noEmit: true }, + ); + return ts.getPreEmitDiagnostics(program) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')); +}; + +const callTool = async (client: Client, name: string, input: Record): Promise => { + try { + return await client.callTool({ arguments: input, name }, { signal: AbortSignal.timeout(10_000) }); + } catch (error) { + return error; + } +}; + +/** + * #752: the generated declarations type a caller by each schema's input and + * the component by its output, so a defaulted field is optional to the App + * and a transformed field is spelled as the wire carries it — proved in the + * project's own browser and server tsconfig programs, then at run time + * through the generated MCP server. #748: those programs are the ones + * `validate` judges for AB4834. + */ +it('types callers by schema input and components by schema output in a clean generated project', { timeout: 120_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-caller-input-types-')); + roots.push(root); + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); + const invocations = join(root, 'invocations.ndjson'); + await Promise.all([ + writeProjectFile(root, 'package.json', JSON.stringify({ + dependencies: { '@agent-bundle/runtime': 'workspace:*', 'agent-bundle': 'workspace:*', react: '19.2.8', zod: '4.4.3' }, + name: 'caller-input-types-fixture', + type: 'module', + version: '1.0.0', + })), + writeProjectFile(root, 'agent-bundle.config.ts', [ + "import { defineConfig } from 'agent-bundle/config';", + "export default defineConfig({ plugin: { name: 'caller-input-types-fixture', version: '1.0.0' }, targets: ['portable'] });", + '', + ].join('\n')), + // A defaulted field: optional to the caller, present for the component. + writeProjectFile(root, 'src/mcp/curator/tools/page.ts', tool( + 'page', + 'z.object({ limit: z.number().default(10) }).strict()', + 'z.object({ limit: z.number() }).strict()', + '{ limit: input.limit }', + )), + // A transformed field: the caller sends the string the wire carries, the component receives the number. + writeProjectFile(root, 'src/mcp/curator/tools/measure.ts', tool( + 'measure', + 'z.object({ text: z.string().transform((value) => value.trim().length) }).strict()', + 'z.object({ length: z.number() }).strict()', + '{ length: input.text }', + )), + writeProjectFile(root, 'src/mcp/curator/apps/dashboard.html', '
\n'),
+    // The browser program: the App itself, compiled against the generated `AppRegister` augmentation.
+    writeProjectFile(root, 'src/mcp/curator/apps/dashboard.ts', [
+      "import { createAppClient, type AppRegister, type AppRouteInput, type AppRouteResult } from 'agent-bundle/app';",
+      "export const config = { resourceUri: 'ui://caller-input-types-fixture/dashboard.html', template: './dashboard.html' };",
+      ...equality,
+      "export type PageInput = Assert, { limit?: number | undefined }>>;",
+      "export type PageResult = Assert, { limit: number }>>;",
+      "export type MeasureInput = Assert, { text: string }>>;",
+      "export type Registered = Assert>>;",
+      "const client = createAppClient({ appInfo: { name: 'dashboard', version: '1.0.0' } });",
+      '// The opening input is the host\'s payload, before the server parses it: the default may be absent.',
+      "client.onToolInput('tool:curator/page', (input) => { const limit: number | undefined = input.limit; void limit; });",
+      'await client.connect();',
+      "export const defaulted = await client.call('tool:curator/page', {});",
+      "export const explicit = await client.call('tool:curator/page', { limit: 5 });",
+      "export const measured = await client.call('tool:curator/measure', { text: '  hi  ' });",
+      "document.querySelector('#state')!.textContent = String(defaulted.limit + explicit.limit + measured.length);",
+      '',
+    ].join('\n')),
+    // The server program: the component sees the parsed output.
+    writeProjectFile(root, 'src/handler-types.ts', [
+      "import type { ToolRouteProps } from 'agent-bundle';",
+      "import type { RegisteredRouteInput, RegisteredRouteParsedInput } from '@agent-bundle/runtime';",
+      "import type { inputSchema as measureSchema } from './mcp/curator/tools/measure.js';",
+      "import type { inputSchema as pageSchema } from './mcp/curator/tools/page.js';",
+      ...equality,
+      "export type PageProps = Assert['input'], { limit: number }>>;",
+      "export type MeasureProps = Assert['input'], { text: number }>>;",
+      '// The registration carries both sides: what a caller sends and what the component receives.',
+      "export type PageCaller = Assert, { limit?: number | undefined }>>;",
+      "export type PageParsed = Assert, { limit: number }>>;",
+      "export type MeasureParsed = Assert, { text: number }>>;",
+      '',
+    ].join('\n')),
+    // Negative cases, each its own entry so one program reports exactly one rejection.
+    writeProjectFile(root, 'negative/wrong-id.ts', [
+      "import { createAppClient } from 'agent-bundle/app';",
+      "void createAppClient().call('tool:curator/missing', {});",
+      '',
+    ].join('\n')),
+    writeProjectFile(root, 'negative/missing-required.ts', [
+      "import { createAppClient } from 'agent-bundle/app';",
+      "void createAppClient().call('tool:curator/measure', {});",
+      '',
+    ].join('\n')),
+    writeProjectFile(root, 'negative/wrong-primitive.ts', [
+      "import { createAppClient } from 'agent-bundle/app';",
+      "void createAppClient().call('tool:curator/page', { limit: 'ten' });",
+      '',
+    ].join('\n')),
+    // The reverse mismatch: the component's parsed number is not what the caller sends.
+    writeProjectFile(root, 'negative/parsed-as-caller.ts', [
+      "import { createAppClient } from 'agent-bundle/app';",
+      "void createAppClient().call('tool:curator/measure', { text: 4 });",
+      '',
+    ].join('\n')),
+    // A solution-style root: one browser program, one server program, each judged on its own.
+    writeProjectFile(root, 'tsconfig.json', `${JSON.stringify({ files: [], references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }] }, null, 2)}\n`),
+    writeProjectFile(root, 'tsconfig.app.json', tsconfig(['src/mcp/**/apps/*.ts', '.agent-bundle/routes.d.ts'], ['DOM', 'ES2022'])),
+    writeProjectFile(root, 'tsconfig.node.json', tsconfig(['src/**/tools/*.ts', 'src/handler-types.ts', '.agent-bundle/routes.d.ts'], ['ES2022'])),
+  ]);
+
+  // The documented entry: `validate` publishes the declaration for a clean checkout, and judges both programs.
+  const validated = await validate({ root });
+  expect(validated.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]);
+  expect(validated.diagnostics.map((diagnostic) => diagnostic.code)).not.toContain('AB4834');
+  const generated = await readFile(join(root, '.agent-bundle', 'routes.d.ts'), 'utf8');
+  expect(generated).toContain('input: SchemaInput;');
+  expect(generated.split('\n').filter((line) => line.startsWith('import')).every((line) => line.startsWith('import type * as '))).toBe(true);
+
+  expect(typecheckProgram(root, 'tsconfig.app.json')).toEqual([]);
+  expect(typecheckProgram(root, 'tsconfig.node.json')).toEqual([]);
+  const wrongId = typecheckProgram(root, 'tsconfig.app.json', 'negative/wrong-id.ts');
+  expect(wrongId).toHaveLength(1);
+  expect(wrongId[0]).toMatch(/tool:curator\/missing/u);
+  const missingRequired = typecheckProgram(root, 'tsconfig.app.json', 'negative/missing-required.ts');
+  expect(missingRequired).toHaveLength(1);
+  expect(missingRequired[0]).toMatch(/Property 'text' is missing/u);
+  const wrongPrimitive = typecheckProgram(root, 'tsconfig.app.json', 'negative/wrong-primitive.ts');
+  expect(wrongPrimitive).toHaveLength(1);
+  expect(wrongPrimitive[0]).toMatch(/Type 'string' is not assignable to type 'number'/u);
+  const parsedAsCaller = typecheckProgram(root, 'tsconfig.app.json', 'negative/parsed-as-caller.ts');
+  expect(parsedAsCaller).toHaveLength(1);
+  expect(parsedAsCaller[0]).toMatch(/Type 'number' is not assignable to type 'string'/u);
+
+  // Dropping the declaration from the browser program alone is reported on that program alone.
+  await writeProjectFile(root, 'tsconfig.app.json', tsconfig(['src/mcp/**/apps/*.ts'], ['DOM', 'ES2022']));
+  const omitted = (await validate({ root })).diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
+  expect(omitted).toHaveLength(1);
+  expect(omitted[0]).toMatchObject({ sourcePath: join(root, 'tsconfig.app.json') });
+  expect(typecheckProgram(root, 'tsconfig.app.json')).not.toEqual([]);
+  await writeProjectFile(root, 'tsconfig.app.json', tsconfig(['src/mcp/**/apps/*.ts', '.agent-bundle/routes.d.ts'], ['DOM', 'ES2022']));
+
+  // The same schema at run time, through the generated server: the default is applied once, the transform once,
+  // and an input the schema rejects never reaches the component.
+  const output = join(root, 'artifact');
+  const compiled = await build({ output, root, targets: ['portable'] });
+  const server = compiled.model.mcpServers[0];
+  if (server?.args?.[0] === undefined) throw new Error('expected a generated MCP entry');
+  const client = new Client({ name: 'caller-input-types', version: '0.0.0' });
+  const transport = new StdioClientTransport({
+    args: [join(output, server.args[0])],
+    command: process.execPath,
+    env: { ...process.env, INVOCATIONS: invocations },
+    stderr: 'pipe',
+  });
+  await client.connect(transport);
+  try {
+    expect(await callTool(client, 'page', {})).toMatchObject({ structuredContent: { limit: 10 } });
+    expect(await callTool(client, 'page', { limit: 5 })).toMatchObject({ structuredContent: { limit: 5 } });
+    expect(await callTool(client, 'measure', { text: '  hi  ' })).toMatchObject({ structuredContent: { length: 2 } });
+    const rejected = await callTool(client, 'page', { limit: 'ten' });
+    expect(rejected instanceof Error ? rejected.message : JSON.stringify(rejected)).toMatch(/limit|invalid/iu);
+    const missing = await callTool(client, 'measure', {});
+    expect(missing instanceof Error ? missing.message : JSON.stringify(missing)).toMatch(/text|invalid/iu);
+  } finally {
+    await client.close();
+  }
+  expect((await readFile(invocations, 'utf8')).trim().split('\n').map((line) => JSON.parse(line) as unknown)).toEqual([
+    { input: { limit: 10 }, tool: 'page' },
+    { input: { limit: 5 }, tool: 'page' },
+    { input: { text: 2 }, tool: 'measure' },
+  ]);
+});
diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts
index 0cdeecbe5..c2eee8db2 100644
--- a/packages/agent-bundle/tests/route-graph.test.ts
+++ b/packages/agent-bundle/tests/route-graph.test.ts
@@ -1465,7 +1465,7 @@ it('generates deterministic route-specific types from the compiled graph', () =>
   // The registered map is the harness contract: an event route registers its `{ canonical, native }` payload and no result.
   expect(first).toContain("type HarnessInput =\n  Contract extends { readonly input: infer Input } ? Input\n    : Contract extends { readonly component: infer Component } ? Omit, 'signal'>\n      : never;");
   expect(first).toContain('type HarnessResult =\n  Contract extends { readonly result: infer Result } ? Result\n    : Contract extends { readonly component: unknown } ? undefined\n      : never;');
-  expect(first).toContain('export type AgentBundleRouteContracts = {\n  readonly [Id in RouteId]: Readonly<{ input: HarnessInput; result: HarnessResult }>;\n};');
+  expect(first).toContain('export type AgentBundleRouteContracts = {\n  readonly [Id in RouteId]: Readonly<{\n    input: HarnessInput;\n    parsedInput: HarnessParsedInput;\n    result: HarnessResult;\n  }>;\n};');
   // A provider-free graph declares no provider surface; the runtime augmentation carries only the route registration.
   expect(first).not.toContain('AgentBundleProviders');
   expect(first).not.toContain('AgentProviderValues');
@@ -1648,9 +1648,11 @@ it('resolves generated helper types for schema and event route contracts and the
       '',
     ].join('\n'),
     'src/mcp/curator/tools/inspect.ts': [
-      'export interface InspectInput { readonly source: string; }',
+      '// What a caller sends (`_input`: the limit is optional) versus what the component receives (`_output`).',
+      'export interface InspectInput { readonly source: string; readonly limit?: number; }',
+      'export interface InspectParsedInput { readonly source: string; readonly limit: number; }',
       'export interface InspectResult { readonly accepted: boolean; }',
-      'export const inputSchema = {} as { readonly _output: InspectInput };',
+      'export const inputSchema = {} as { readonly _input: InspectInput; readonly _output: InspectParsedInput };',
       'export const resultSchema = {} as { readonly _output: InspectResult };',
       'export default async function Inspect() { return undefined; }',
       '',
@@ -1684,12 +1686,14 @@ it('resolves generated helper types for schema and event route contracts and the
       "import type { AgentBundleAppRouteContracts, AppToolRouteId, RouteId, RouteInput, RouteResult } from './.agent-bundle/routes.js';",
       "import type { WorkspaceOpenInput, WorkspaceOpenResult } from './src/events/workspace/open.js';",
       "import type { BriefInput, BriefResult } from './src/mcp/curator/prompts/brief.js';",
-      "import type { InspectInput, InspectResult } from './src/mcp/curator/tools/inspect.js';",
+      "import type { InspectInput, InspectParsedInput, InspectResult } from './src/mcp/curator/tools/inspect.js';",
       '',
       ...equalityHelpers,
       '',
       "export type Ids = Assert>;",
+      '// A schema with `_input` registers the caller\'s input; one with only `_output` (the prompt) registers its output.',
       "export type SchemaInput = Assert, InspectInput>>;",
+      "export type StructuralInput = Assert, BriefInput>>;",
       "export type SchemaResult = Assert, InspectResult>>;",
       "export type EventInput = Assert, WorkspaceOpenInput>>;",
       "export type EventResult = Assert, WorkspaceOpenResult>>;",
@@ -1697,9 +1701,10 @@ it('resolves generated helper types for schema and event route contracts and the
       'export type AllResults = Assert, BriefResult | InspectResult | WorkspaceOpenResult>>;',
       '// The augmentation registers the same contracts on the runtime, keyed by route id.',
       "export type RegisteredIds = Assert>;",
-      "export type RegisteredInspect = Assert>>;",
-      '// An event route registers the harness payload (props without the signal the harness injects) and no result.',
-      "export type RegisteredEvent = Assert; result: undefined }>>>;",
+      '// Both sides of the schema boundary are registered: the caller\'s input and the component\'s parsed input.',
+      "export type RegisteredInspect = Assert>>;",
+      '// An event route registers the harness payload (props without the signal the harness injects) on both sides and no result.',
+      "export type RegisteredEvent = Assert; parsedInput: Omit; result: undefined }>>>;",
       "export type RegisteredEventInput = Assert>;",
       '// The App registration is the MCP tool subset of the same contracts: the prompt and event routes are not',
       '// `tools/call` targets, so an App client cannot name them; the tool keeps its own schema types.',
diff --git a/packages/agent-bundle/tests/route-register-typegen.test.ts b/packages/agent-bundle/tests/route-register-typegen.test.ts
index 50469e5b5..7a3f8db1f 100644
--- a/packages/agent-bundle/tests/route-register-typegen.test.ts
+++ b/packages/agent-bundle/tests/route-register-typegen.test.ts
@@ -252,7 +252,8 @@ it('types every route-aware public surface from the generated route registration
       "  expectNoMcpCall({ server: 'curator' });",
       "  expectNoMcpCall({ server: 'github', tool: 'search_issues' });",
       "  expectMcpCall({ server: dynamic, tool: dynamic });",
-      '  // loadRouteModule checks its id the same way and types the schemas\' parsed values from the registration.',
+      '  // loadRouteModule checks its id the same way and types the schemas\' parsed values from the registration:',
+      '  // inputSchema.parse returns the component\'s input (the registered parsedInput), not what a caller sends.',
       "  const found_module = await loadRouteModule('tool:curator/find');",
       "  const parsedQuery: string | undefined = found_module.inputSchema?.parse({ query: 'dune' }).query;",
       '  const parsedHits: number | undefined = found_module.resultSchema?.parse({ hits: 1 }).hits;',
diff --git a/packages/agent-bundle/tests/route-types-program.test.ts b/packages/agent-bundle/tests/route-types-program.test.ts
index 6b712a766..e8d644d1b 100644
--- a/packages/agent-bundle/tests/route-types-program.test.ts
+++ b/packages/agent-bundle/tests/route-types-program.test.ts
@@ -1,9 +1,10 @@
-import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
+import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises';
 import { existsSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import { dirname, join } from 'node:path';
 
 import { afterEach, describe, expect, it } from '@rstest/core';
+import ts from 'typescript-5';
 
 import { inspect, validate } from '../src/api.ts';
 import { routeTypesProgramDiagnostics } from '../src/routes/typegen-program.ts';
@@ -15,14 +16,27 @@ afterEach(async () => {
   await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true })));
 });
 
+// A route module consumes the registration through `@agent-bundle/runtime` (provider values).
 const routeModule = [
+  "import { agent } from '@agent-bundle/runtime';",
   "import { z } from 'zod';",
   'export const inputSchema = z.object({ service: z.string() });',
   'export const resultSchema = z.object({ status: z.string() });',
-  'export default async () => undefined;',
+  'export default async () => { await agent(); return undefined; };',
   '',
 ].join('\n');
 
+// An App view consumes it through `agent-bundle/app`; it lives in the browser program.
+const appModule = [
+  "import { createAppClient } from 'agent-bundle/app';",
+  "export const config = { resourceUri: 'ui://routes-fixture/panel.html', template: './panel.html' };",
+  "void createAppClient().call('tool:status/report', { service: 'compiler' });",
+  '',
+].join('\n');
+
+// A build-only script imports none of the augmented modules: not a consumer.
+const scriptModule = "export const main = async (): Promise => { console.log('build'); };\n";
+
 const tsconfig = (include: readonly string[], extra: Readonly> = {}): string =>
   `${JSON.stringify({ compilerOptions: { module: 'NodeNext', strict: true }, include, ...extra }, null, 2)}\n`;
 
@@ -63,7 +77,7 @@ describe('AB4834 generated route declarations outside the TypeScript program', (
     const warnings = result.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
     expect(warnings).toHaveLength(1);
     expect(warnings[0]).toMatchObject({
-      message: expect.stringContaining('tsconfig.json does not include the generated .agent-bundle/routes.d.ts'),
+      message: expect.stringContaining('tsconfig.json imports agent-bundle/app, agent-bundle/test, agent-bundle/eval, or @agent-bundle/runtime but does not include the generated .agent-bundle/routes.d.ts'),
       recovery: expect.stringContaining('Add ".agent-bundle/routes.d.ts" to the "include" array of tsconfig.json'),
       severity: 'warning',
       sourcePath: join(root, 'tsconfig.json'),
@@ -111,6 +125,174 @@ describe('AB4834 generated route declarations outside the TypeScript program', (
     expect(codesOf((await validate({ root: wildcardOnly })).diagnostics)).toContain('AB4834');
   });
 
+  it('judges every consuming program of a solution, so the server project cannot hide the browser project', async () => {
+    const solution = {
+      'src/mcp/status/apps/panel.html': '\n',
+      'src/mcp/status/apps/panel.ts': appModule,
+      'src/mcp/status/tools/report.ts': routeModule,
+      'src/scripts/build.ts': scriptModule,
+      // Nested: the root references a solution file that references the three projects.
+      'tsconfig.json': `${JSON.stringify({ files: [], references: [{ path: './tsconfig.solution.json' }] }, null, 2)}\n`,
+      'tsconfig.solution.json': `${JSON.stringify({ files: [], references: [{ path: './tsconfig.node.json' }, { path: './config/tsconfig.app.json' }, { path: './tsconfig.scripts.json' }] }, null, 2)}\n`,
+      'tsconfig.node.json': tsconfig(['.agent-bundle/routes.d.ts', 'src/mcp/**/tools/*.ts'], { compilerOptions: { composite: true, module: 'NodeNext' } }),
+      'tsconfig.scripts.json': tsconfig(['src/scripts/*.ts'], { compilerOptions: { composite: true, module: 'NodeNext' } }),
+    };
+    const browserOmits = await createProject({
+      ...solution,
+      // Excluding the declaration is as good as omitting it.
+      'config/tsconfig.app.json': tsconfig(['../src/mcp/**/apps/*.ts', '../.agent-bundle/**/*'], { compilerOptions: { composite: true, lib: ['DOM', 'ES2022'], module: 'NodeNext' }, exclude: ['../.agent-bundle/**/*'] }),
+    });
+    const warnings = (await validate({ root: browserOmits })).diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
+    // One warning, on the browser project; the node project includes the file and the scripts project imports nothing that consumes it.
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]).toMatchObject({
+      message: expect.stringContaining('config/tsconfig.app.json imports agent-bundle/app'),
+      recovery: expect.stringContaining('Add "../.agent-bundle/routes.d.ts" to the "include" array of config/tsconfig.app.json'),
+      sourcePath: join(browserOmits, 'config', 'tsconfig.app.json'),
+    });
+
+    const browserIncludes = await createProject({
+      ...solution,
+      'config/tsconfig.app.json': tsconfig(['../src/mcp/**/apps/*.ts', '../.agent-bundle/routes.d.ts'], { compilerOptions: { composite: true, lib: ['DOM', 'ES2022'], module: 'NodeNext' } }),
+    });
+    expect(codesOf((await validate({ root: browserIncludes })).diagnostics)).not.toContain('AB4834');
+
+    // A program that imports none of the augmented modules is never asked to include the file.
+    const buildOnly = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      'src/scripts/build.ts': scriptModule,
+      'tsconfig.json': tsconfig(['src/scripts/*.ts']),
+    });
+    expect(codesOf((await validate({ root: buildOnly })).diagnostics)).not.toContain('AB4834');
+  });
+
+  it('reads imports with the scanner, not by text: comments and strings are not consumers, import type in a .d.ts is', async () => {
+    const commentOnly = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      // The program's own files mention the entries only in prose and a string literal.
+      'src/scripts/build.ts': [
+        "// Consumers import '@agent-bundle/runtime' or 'agent-bundle/test'; this script does neither.",
+        "export const hint = \"import { agent } from '@agent-bundle/runtime'\";",
+        '',
+      ].join('\n'),
+      'tsconfig.json': tsconfig(['src/scripts/*.ts']),
+    });
+    expect(codesOf((await validate({ root: commentOnly })).diagnostics)).not.toContain('AB4834');
+
+    const subpath = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      'src/scripts/build.ts': "import type { AgentRouteModule } from 'agent-bundle/routes';\nexport type Module = AgentRouteModule;\n",
+      'tsconfig.json': tsconfig(['src/scripts/*.ts']),
+    });
+    expect(codesOf((await validate({ root: subpath })).diagnostics)).not.toContain('AB4834');
+
+    const declaration = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      'src/scripts/types.d.ts': "import type { RegisteredRouteId } from '@agent-bundle/runtime';\nexport type Id = RegisteredRouteId;\n",
+      'tsconfig.json': tsconfig(['src/scripts/*.ts']),
+    });
+    expect(codesOf((await validate({ root: declaration })).diagnostics)).toContain('AB4834');
+  });
+
+  it('judges the project by its own files: a workspace-linked package that imports the runtime is not the consumer', async () => {
+    // A `workspace:*` link resolves outside the project root, where the host
+    // cannot decline it by path; its declaration imports the runtime, as the
+    // framework's own `agent-bundle/routes` declaration may.
+    const linked = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      'src/scripts/build.ts': "import type { AgentRouteModule } from 'agent-bundle/routes';\nexport type Module = AgentRouteModule;\n",
+      'tsconfig.json': tsconfig(['src/scripts/*.ts']),
+    });
+    const sibling = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-route-types-linked-')));
+    roots.push(sibling);
+    await mkdir(join(sibling, 'agent-bundle'), { recursive: true });
+    await writeFile(join(sibling, 'agent-bundle/package.json'), '{"name":"agent-bundle","type":"module","exports":{"./routes":{"types":"./routes.d.ts"}}}\n');
+    await writeFile(join(sibling, 'agent-bundle/routes.d.ts'), "import type { RegisteredRouteId } from '@agent-bundle/runtime';\nexport interface AgentRouteModule { readonly id: RegisteredRouteId }\n");
+    await mkdir(join(linked, 'node_modules'), { recursive: true });
+    await symlink(join(sibling, 'agent-bundle'), join(linked, 'node_modules/agent-bundle'), 'dir');
+    expect(codesOf((await validate({ root: linked })).diagnostics)).not.toContain('AB4834');
+
+    // A monorepo root links its own package from inside the project root; the
+    // program still reached it through node_modules, so it is a dependency.
+    const monorepo = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      'src/scripts/build.ts': "import type { AgentRouteModule } from 'agent-bundle/routes';\nexport type Module = AgentRouteModule;\n",
+      'tsconfig.json': tsconfig(['src/scripts/*.ts']),
+      'packages/agent-bundle/package.json': '{"name":"agent-bundle","type":"module","exports":{"./routes":{"types":"./routes.d.ts"}}}\n',
+      'packages/agent-bundle/routes.d.ts': "import type { RegisteredRouteId } from '@agent-bundle/runtime';\nexport interface AgentRouteModule { readonly id: RegisteredRouteId }\n",
+    });
+    await mkdir(join(monorepo, 'node_modules'), { recursive: true });
+    await symlink(join(monorepo, 'packages/agent-bundle'), join(monorepo, 'node_modules/agent-bundle'), 'dir');
+    expect(codesOf((await validate({ root: monorepo })).diagnostics)).not.toContain('AB4834');
+  });
+
+  it('reads a referenced project through its emitted declaration, as tsc does, so its source-only imports do not make the parent a consumer', async () => {
+    const solution = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      // The child consumes the registration in source; its emitted declaration does not.
+      'child/src/status.ts': "import { invokeMcpTool } from 'agent-bundle/test';\nexport const status = (): Promise => invokeMcpTool('report', { input: {} });\n",
+      'child/dist/status.d.ts': 'export declare const status: () => Promise;\n',
+      'child/tsconfig.json': tsconfig(['src/**/*.ts'], { compilerOptions: { composite: true, declaration: true, module: 'NodeNext', outDir: 'dist', rootDir: 'src' } }),
+      'app/index.ts': "export { status } from '../child/src/status.js';\n",
+      'tsconfig.json': tsconfig([], { files: ['app/index.ts'], references: [{ path: './child' }] }),
+    });
+    const diagnostics = (await validate({ root: solution })).diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
+    expect(diagnostics.map((diagnostic) => diagnostic.sourcePath)).toEqual([join(solution, 'child/tsconfig.json')]);
+  });
+
+  it('follows imports from a narrow root, as tsc does: the consumer an entry point imports is in the program', async () => {
+    const narrow = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      // The root file imports nothing augmented itself; the module it imports does.
+      'src/index.ts': "export { status } from './status.js';\n",
+      'src/status.ts': "import { invokeMcpTool } from 'agent-bundle/test';\nexport const status = () => invokeMcpTool('report', { input: {} });\n",
+      'tsconfig.json': tsconfig([], { files: ['src/index.ts'] }),
+    });
+    const diagnostics = (await validate({ root: narrow })).diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
+    expect(diagnostics.map((diagnostic) => diagnostic.sourcePath)).toEqual([join(narrow, 'tsconfig.json')]);
+
+    // The declaration reached through an import counts as compiled, like any other module.
+    const referenced = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      'src/index.ts': "/// \nimport { invokeMcpTool } from 'agent-bundle/test';\nexport const status = () => invokeMcpTool('report', { input: {} });\n",
+      'tsconfig.json': tsconfig([], { files: ['src/index.ts'] }),
+    });
+    expect(codesOf((await validate({ root: referenced })).diagnostics)).not.toContain('AB4834');
+  });
+
+  it('tailors the recovery to a config without its own include array, whose patterns an include would replace', async () => {
+    const defaults = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      // No `include`: the program is the `**/*` default, which never descends into dot-directories.
+      'tsconfig.json': `${JSON.stringify({ compilerOptions: { module: 'NodeNext', strict: true } }, null, 2)}\n`,
+    });
+    const [warning] = (await validate({ root: defaults })).diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
+    expect(warning?.recovery).toContain('Declare an "include" array in tsconfig.json that lists ".agent-bundle/routes.d.ts" beside the patterns it compiles today (an "include" array replaces the inherited or default "**/*" patterns)');
+
+    // The inherited patterns live in another directory: the path is spelled
+    // for the config being edited, never for the one it extends.
+    const inherited = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      'config/tsconfig.base.json': tsconfig(['../agent-bundle.config.ts', '../src/**/*.ts']),
+      'tsconfig.json': tsconfig([], { compilerOptions: { composite: true, module: 'NodeNext' }, files: [], references: [{ path: './config/tsconfig.app.json' }] }),
+      'config/tsconfig.app.json': '{ "extends": "./tsconfig.base.json", "compilerOptions": { "composite": true } }\n',
+    });
+    const [inheritedWarning] = (await validate({ root: inherited })).diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834');
+    expect(inheritedWarning?.sourcePath).toBe(join(inherited, 'config/tsconfig.app.json'));
+    expect(inheritedWarning?.recovery).toContain('Declare an "include" array in config/tsconfig.app.json that lists "../.agent-bundle/routes.d.ts" beside the patterns it compiles today');
+  });
+
+  it('matches the declaration path the way the host file system does', async () => {
+    const root = await createProject({
+      'src/mcp/status/tools/report.ts': routeModule,
+      // Case-mismatched: names the file on a case-insensitive host, nothing on a case-sensitive one.
+      'tsconfig.json': tsconfig(['.agent-bundle/Routes.d.ts', 'src/**/*.ts']),
+    });
+    const codes = codesOf((await validate({ root })).diagnostics);
+    if (ts.sys.useCaseSensitiveFileNames) expect(codes).toContain('AB4834');
+    else expect(codes).not.toContain('AB4834');
+  });
+
   it('has nothing to report without a tsconfig, without routes, or with an unparsable tsconfig', async () => {
     const noTsconfig = await createProject({ 'src/mcp/status/tools/report.ts': routeModule });
     expect(codesOf((await validate({ root: noTsconfig })).diagnostics)).not.toContain('AB4834');
diff --git a/packages/agent-bundle/tests/route-unit/render-route.test.ts b/packages/agent-bundle/tests/route-unit/render-route.test.ts
index 6499ae838..113b0f003 100644
--- a/packages/agent-bundle/tests/route-unit/render-route.test.ts
+++ b/packages/agent-bundle/tests/route-unit/render-route.test.ts
@@ -569,6 +569,32 @@ describe('layout composition at the route-unit level', () => {
     });
   });
 
+  it('parses the input through the route\'s own inputSchema before the component runs, as the generated worker does', async () => {
+    // The caller omits the defaulted field; the component receives the default (#752).
+    const defaulted = await renderRoute('tool:harness/layout-probe', { input: {} });
+    expect(defaulted.result).toEqual({ label: 'probe' });
+    expectDocument(defaulted).toContainText('probe: probe');
+
+    // Invalid input is rejected before any provider or component runs: no document, one harness diagnostic.
+    const error = await rejection(renderRoute('tool:harness/layout-probe', { input: { label: 1 } as never }));
+    expect(error).toBeInstanceOf(AgentTestError);
+    expect(error.code).toBe('invalid-input');
+    expect(error.message).toContain("The route's own inputSchema rejected the input.");
+    expect(error.message).toContain('received:     {"label":1}');
+    expect(error.message).toContain('route:        tool:harness/layout-probe (tool)');
+  });
+
+  it('parses a rendered CLI route\'s input the same way, so CliRouteProps sees the schema output', async () => {
+    // The strict schema rejects an empty topic and an unknown mode before the component runs; without the
+    // parse both would render, since the component never reads what it does not use.
+    for (const input of [{ topic: '' }, { mode: 'bogus', topic: 'layouts' }]) {
+      const error = await rejection(renderRoute('cli:report', { input: input as never }));
+      expect(error).toBeInstanceOf(AgentTestError);
+      expect(error.code).toBe('invalid-input');
+      expect(error.message).toContain('route:        cli:report (cli)');
+    }
+  });
+
   it('applies only the root layout to a rendered CLI command', async () => {
     const rendered = await renderRoute('cli:report', { input: { topic: 'layouts' } });
 
diff --git a/packages/create-agent-bundle/templates/cli-tool/README.md b/packages/create-agent-bundle/templates/cli-tool/README.md
index 7934c866c..003de0f2e 100644
--- a/packages/create-agent-bundle/templates/cli-tool/README.md
+++ b/packages/create-agent-bundle/templates/cli-tool/README.md
@@ -15,7 +15,8 @@ schemas. `src/index.ts` is the library export with declarations, and
 ```sh
 npm run dev              # local workbench with live rebuilds
 npm run build            # dist/ package build + host artifacts in artifact/
-npm run check            # validate + build + typecheck + both test pools
+npm run check            # build + typecheck + both test pools
+npm run typecheck        # validate (writes .agent-bundle/routes.d.ts) + tsc
 npm run test             # plain module tests
 npm run test:projection  # cli-dispatch + script-dispatch pool
 
diff --git a/packages/create-agent-bundle/templates/cli-tool/package_json b/packages/create-agent-bundle/templates/cli-tool/package_json
index cf917cae6..5b39f3d92 100644
--- a/packages/create-agent-bundle/templates/cli-tool/package_json
+++ b/packages/create-agent-bundle/templates/cli-tool/package_json
@@ -14,12 +14,12 @@
   },
   "scripts": {
     "build": "agent-bundle build --json --output artifact",
-    "check": "npm run validate && npm run build && npm run typecheck && npm run test && npm run test:projection",
+    "check": "npm run build && npm run typecheck && npm run test && npm run test:projection",
     "dev": "agent-bundle dev",
     "pack:check": "agent-bundle prepack --json --output artifact",
     "test": "rstest tests --exclude \"tests/projection/**\"",
     "test:projection": "rstest --config rstest.projection.config.ts",
-    "typecheck": "tsc -p tsconfig.json --noEmit",
+    "typecheck": "npm run validate && tsc -p tsconfig.json --noEmit",
     "validate": "agent-bundle validate --json"
   },
   "devDependencies": {
diff --git a/packages/create-agent-bundle/templates/mcp-server/README.md b/packages/create-agent-bundle/templates/mcp-server/README.md
index d13fcf494..0c446e18c 100644
--- a/packages/create-agent-bundle/templates/mcp-server/README.md
+++ b/packages/create-agent-bundle/templates/mcp-server/README.md
@@ -10,7 +10,8 @@ the `status` server; no handwritten server factory or server config is needed.
 ```sh
 npm run dev
 npm run build
-npm run check            # validate + build + typecheck + all three test pools
+npm run check            # build + typecheck + all three test pools
+npm run typecheck        # validate (writes .agent-bundle/routes.d.ts) + tsc
 npm run test             # plain module tests
 npm run test:routes      # route-unit pool
 npm run test:projection  # in-memory MCP projection pool
diff --git a/packages/create-agent-bundle/templates/mcp-server/package_json b/packages/create-agent-bundle/templates/mcp-server/package_json
index dc12ec721..33172c2f7 100644
--- a/packages/create-agent-bundle/templates/mcp-server/package_json
+++ b/packages/create-agent-bundle/templates/mcp-server/package_json
@@ -15,13 +15,13 @@
   },
   "scripts": {
     "build": "agent-bundle build --json --output artifact",
-    "check": "npm run validate && npm run build && npm run typecheck && npm run test && npm run test:routes && npm run test:projection",
+    "check": "npm run build && npm run typecheck && npm run test && npm run test:routes && npm run test:projection",
     "dev": "agent-bundle dev",
     "pack:check": "agent-bundle prepack --json --output artifact",
     "test": "rstest tests --exclude \"tests/{route-unit,projection}/**\"",
     "test:projection": "rstest --config rstest.projection.config.ts",
     "test:routes": "rstest --config rstest.route-unit.config.ts",
-    "typecheck": "tsc -p tsconfig.json --noEmit",
+    "typecheck": "npm run validate && tsc -p tsconfig.json --noEmit",
     "validate": "agent-bundle validate --json"
   },
   "devDependencies": {
diff --git a/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts b/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts
index b507f2e09..4982a8e05 100644
--- a/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts
+++ b/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts
@@ -1,4 +1,4 @@
-import { readFile } from 'node:fs/promises';
+import { access, readFile, rm, writeFile } from 'node:fs/promises';
 import { join } from 'node:path';
 
 import { afterAll, expect, it } from '@rstest/core';
@@ -52,6 +52,26 @@ it('scaffolds with independently versioned release tarballs and runs after sourc
     ?.endsWith(`agent-bundle-runtime-${pairing.runtime}.tgz`)).toBe(true);
 
   await installScaffoldedProject(projectRoot);
+
+  // `typecheck` on a clean checkout (#748): nothing has published the
+  // generated declaration yet, the script's `validate` step publishes it, and
+  // the program then consumes the registration — a wrong tool id is a compile
+  // error, not a `string` that type-checks.
+  const routeTypes = join(projectRoot, '.agent-bundle', 'routes.d.ts');
+  await expect(access(routeTypes)).rejects.toMatchObject({ code: 'ENOENT' });
+  await npmRun(projectRoot, 'typecheck');
+  await expect(readFile(routeTypes, 'utf8')).resolves.toContain('"tool:status/report-status"');
+  const wrongId = join(projectRoot, 'tests', 'route-unit', 'wrong-id.test.ts');
+  await writeFile(wrongId, [
+    "import { renderRoute } from 'agent-bundle/test';",
+    "void renderRoute('tool:status/does-not-exist', { input: {} });",
+    '',
+  ].join('\n'));
+  await expect(npmRun(projectRoot, 'typecheck')).rejects.toMatchObject({
+    stdout: expect.stringContaining("'\"tool:status/does-not-exist\"' is not assignable"),
+  });
+  await rm(wrongId);
+
   await npmRun(projectRoot, 'build');
 
   const artifact = join(projectRoot, 'artifact');
diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts
index 6b246d469..7c38f0cb9 100644
--- a/packages/rsc-runtime/src/agent-request.ts
+++ b/packages/rsc-runtime/src/agent-request.ts
@@ -304,7 +304,7 @@ export interface AgentProviderValues {
 /**
  * The project-registration seam, after TanStack Router's `Register`. It is
  * empty here; the compiler's generated `.agent-bundle/routes.d.ts` augments it
- * with `routes: AgentBundleRouteContracts` — a thin `{ input, result }` map
+ * with `routes: AgentBundleRouteContracts` — a thin `{ input, parsedInput, result }` map
  * keyed by route id: what the `agent-bundle/test` harness accepts and returns
  * for that route, inferred from each schema route's own `inputSchema` and
  * `resultSchema`, and for an event route its `{ canonical, native }` payload
@@ -329,9 +329,15 @@ export interface AgentProviderValues {
 // rslint-disable-next-line @typescript-eslint/no-empty-object-type -- declaration-merge extension point
 export interface Register {}
 
-/** One registered route's harness contract: the `input` a render accepts and the `result` it returns (`undefined` for routes without a `resultSchema`). */
+/**
+ * One registered route's harness contract: the `input` a caller sends (the
+ * schema's own input type), the `parsedInput` the component receives after
+ * defaults and transforms (the schema's output type), and the `result` a
+ * render returns (`undefined` for routes without a `resultSchema`).
+ */
 export interface RegisteredRouteContract {
   readonly input: unknown;
+  readonly parsedInput: unknown;
   readonly result: unknown;
 }
 
@@ -348,6 +354,11 @@ export type RegisteredRouteInput = Id extends keyof Registere
   ? RegisteredRoutes[Id] extends RegisteredRouteContract ? RegisteredRoutes[Id]['input'] : unknown
   : unknown;
 
+/** The registered parsed-input type for one route id — what its component receives; `unknown` for an unregistered id. */
+export type RegisteredRouteParsedInput = Id extends keyof RegisteredRoutes
+  ? RegisteredRoutes[Id] extends RegisteredRouteContract ? RegisteredRoutes[Id]['parsedInput'] : unknown
+  : unknown;
+
 /** The registered result type for one route id; `unknown` for an unregistered id. */
 export type RegisteredRouteResult = Id extends keyof RegisteredRoutes
   ? RegisteredRoutes[Id] extends RegisteredRouteContract ? RegisteredRoutes[Id]['result'] : unknown
diff --git a/packages/rsc-runtime/src/plugin.ts b/packages/rsc-runtime/src/plugin.ts
index dc9688a84..b5f93b2f0 100644
--- a/packages/rsc-runtime/src/plugin.ts
+++ b/packages/rsc-runtime/src/plugin.ts
@@ -43,6 +43,7 @@ export type {
   RegisteredRouteContract,
   RegisteredRouteId,
   RegisteredRouteInput,
+  RegisteredRouteParsedInput,
   RegisteredRouteResult,
   RegisteredRoutes,
   AgentRequestCapabilities,
diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts
index 937435136..73eb0ffff 100644
--- a/rstest.integration-tests.ts
+++ b/rstest.integration-tests.ts
@@ -80,6 +80,7 @@ export const integrationTestFiles: readonly string[] = [
   'packages/agent-bundle/tests/publint-gate.test.ts',
   'packages/agent-bundle/tests/route-contract-imports.test.ts',
   'packages/agent-bundle/tests/route-invocation-dev-server.test.ts',
+  'packages/agent-bundle/tests/route-caller-input-types.test.ts',
   'packages/agent-bundle/tests/route-register-typegen.test.ts',
   'packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts',
   'packages/agent-bundle/tests/rstest-meta-consumer.test.ts',
diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx
index 7afc52c94..15c5f59b2 100644
--- a/website/docs/en/guide/authoring/mcp.mdx
+++ b/website/docs/en/guide/authoring/mcp.mdx
@@ -849,11 +849,15 @@ declare module 'agent-bundle/app' {
 ```
 
 whose members are the project's generated MCP **tool** routes (`AppToolRouteId` in the same
-file) — `input` inferred from each route's `inputSchema`, `result` from its `resultSchema`. Once
-that file is in the project's TypeScript program (the templates list `".agent-bundle/routes.d.ts"`
-in `tsconfig.json` `include`; `AB4834` when a routed project leaves it out), a literal route id is
-checked against the compiled tools, `input` is typed from that route, and the result is that
-route's structured object. The declaration imports the route modules type-only, so no route
+file) — `input` inferred from each route's `inputSchema` **input** type (what a caller sends: a
+`.default()`ed field is optional, a `.transform()`ed field is spelled as the wire carries it; the
+route component receives the parsed output), `result` from its `resultSchema` output. Once
+that file is in the App's TypeScript program (the templates list `".agent-bundle/routes.d.ts"`
+in `tsconfig.json` `include`; `AB4834` names each program that imports `agent-bundle/app` and
+leaves it out), a literal route id is checked against the compiled tools, `input` is typed from
+that route, and the result is that route's structured object. `onToolInput` delivers the host's
+opening payload with the same input type: it is the message before the server parses it, so a
+default the schema would fill is not promised to be present. The declaration imports the route modules type-only, so no route
 module, schema, or server code enters the App document. Without the augmentation — a handwritten
 server, or a program that omits the file — `call()` still works with `unknown` input and result,
 and a view may declare its own contract map through the same `AppRegister` seam. The
diff --git a/website/docs/en/guide/development/index.mdx b/website/docs/en/guide/development/index.mdx
index 86d448494..f7b2033d0 100644
--- a/website/docs/en/guide/development/index.mdx
+++ b/website/docs/en/guide/development/index.mdx
@@ -38,9 +38,15 @@ as one `AB7103` **warning** on the succeeded build attempt and retries on the ne
 Development also publishes generated route declarations at `.agent-bundle/routes.d.ts` from the
 same compiled graph. Each write goes to a sibling temporary file and is renamed over the prior
 complete declaration atomically, so invalid source keeps the last-good file, and a successful
-route-free preparation removes it. The declaration only types `renderRoute` when the project's
-TypeScript program compiles it: keep `".agent-bundle/routes.d.ts"` in `tsconfig.json` `include`
-(the templates ship it that way; `agent-bundle validate` warns with `AB4834` when it is missing).
+route-free preparation removes it. The declaration only types `renderRoute`, the App client, and
+provider values in a TypeScript program that compiles it: keep `".agent-bundle/routes.d.ts"` in
+the `include` of every tsconfig that imports `agent-bundle/test`, `agent-bundle/app`,
+`agent-bundle/eval`, or `@agent-bundle/runtime` — a solution-style root is judged one referenced
+project at a time, so a server project that includes the file cannot hide a browser project that
+omits it (`agent-bundle validate` warns with `AB4834` per program). `validate` is also the
+lightweight way to publish current declarations before an isolated type check — no dev server or
+production bundle needed — which is why the routed templates' `typecheck` script runs
+`agent-bundle validate && tsc`.
 
 ## The three surfaces
 
diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx
index ec1f71452..a13a6b3c8 100644
--- a/website/docs/en/guide/development/testing.mdx
+++ b/website/docs/en/guide/development/testing.mdx
@@ -62,8 +62,9 @@ The compiler's generated `.agent-bundle/routes.d.ts` registers the project's rou
 `@agent-bundle/runtime`'s `Register` interface, the way it already declares provider keys. Once
 that file is part of the project's TypeScript program — `create-agent-bundle` templates list
 `".agent-bundle/routes.d.ts"` in `tsconfig.json` `include` by default, the file itself stays
-gitignored, and `agent-bundle validate` warns with `AB4834` when a project that compiles routes or
-providers leaves it out — a `renderRoute` call written with a string literal is checked against
+gitignored, and `agent-bundle validate` warns with `AB4834` for each TypeScript program that
+imports `agent-bundle/test`, `agent-bundle/app`, `agent-bundle/eval`, or `@agent-bundle/runtime`
+and leaves it out — a `renderRoute` call written with a string literal is checked against
 the compiled route ids — the editor completes them, and a typo is rejected naming the registered
 alternatives — while `input` and `result` come from that route's own `inputSchema` and
 `resultSchema`:
@@ -75,6 +76,16 @@ const { result } = await renderRoute('tool:library/summarize', {
 const chapters: number | undefined = result?.chapters; // no cast
 ```
 
+`input` is what a caller sends — the schema's **input** type, so a field with a `.default()` is
+optional and a `.transform()`ed field is spelled the way the wire carries it — while the route
+component receives the schema's **output**, exactly as the generated server hands it over.
+`renderRoute` parses the input through the route's own `inputSchema` at that same boundary: the
+component sees defaults filled and transforms applied, and an input the schema rejects fails with
+an `invalid-input` harness error before any provider or component runs. `result` is the
+`resultSchema` output. Both sides are registered: `RegisteredRouteInput` (from
+`@agent-bundle/runtime`) is the caller's side and `RegisteredRouteParsedInput` the component's,
+which is what `loadRouteModule(id).inputSchema.parse(...)` returns.
+
 An event route registers what the harness actually takes and gives back: `input` is the
 `{ canonical, native, preflight? }` payload (the harness supplies `signal` itself), and `result` is
 `undefined`, since event modules export no `resultSchema`. Build that input from a host envelope
diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx
index 450a8f473..bfef983f2 100644
--- a/website/docs/zh/guide/authoring/mcp.mdx
+++ b/website/docs/zh/guide/authoring/mcp.mdx
@@ -754,10 +754,13 @@ declare module 'agent-bundle/app' {
 }
 ```
 
-成员就是项目生成的 MCP **工具**路由:`input` 来自各路由的 `inputSchema`,`result` 来自
-`resultSchema`。该声明以 type-only 方式导入路由模块,所以不会把路由模块、schema 或服务器代码带入 App
-文档。模板已经在 `tsconfig.json` 的 `include` 中列出 `".agent-bundle/routes.d.ts"`;路由项目漏掉它时
-会收到 `AB4834`。没有 augmentation 时——例如手写服务器——`call()` 仍能以 `unknown` 输入与结果工作,视图也可以
+成员就是项目生成的 MCP **工具**路由:`input` 来自各路由 `inputSchema` 的**输入**类型(调用方发送的
+内容:带 `.default()` 的字段是可选的,经 `.transform()` 的字段按 wire 上的写法拼写;路由组件收到的则是
+解析后的输出),`result` 来自 `resultSchema` 的输出。该声明以 type-only 方式导入路由模块,所以不会把路由
+模块、schema 或服务器代码带入 App 文档。模板已经在 `tsconfig.json` 的 `include` 中列出
+`".agent-bundle/routes.d.ts"`;每一个导入了 `agent-bundle/app` 却漏掉它的程序都会收到 `AB4834`。
+`onToolInput` 以同一输入类型交付宿主的 opening 载荷:那是服务器解析之前的消息,因此 schema 会填入的默认值
+并不保证已经存在。没有 augmentation 时——例如手写服务器——`call()` 仍能以 `unknown` 输入与结果工作,视图也可以
 通过同一个结构化 `AppRegister` seam 声明自己的本地契约 map。[MCP App 示例](../../examples/mcp-app.mdx)
 依赖的就是生成文件:它的 App 以路由 schema 声明的输入与结果调用 `tool:status/show-status`。`AppRouteId`、
 `AppRouteInput` 与 `AppRouteResult` 为 wrapper 命名同一套类型表面。
diff --git a/website/docs/zh/guide/development/index.mdx b/website/docs/zh/guide/development/index.mdx
index b30bfa7aa..bee1b420a 100644
--- a/website/docs/zh/guide/development/index.mdx
+++ b/website/docs/zh/guide/development/index.mdx
@@ -32,9 +32,12 @@ npx agent-bundle dev --root .
 
 开发期还会从同一份编译后的路由图,把生成的路由声明发布到 `.agent-bundle/routes.d.ts`。每次写入都先
 写入一个同级临时文件,再原子地重命名覆盖先前那份完整声明,因此无效源码会保留上一份可用文件,而一次
-成功的、不含路由的准备过程会移除它。只有当项目的 TypeScript 程序编译了这份声明,它才会为 `renderRoute`
-提供类型:请把 `".agent-bundle/routes.d.ts"` 保留在 `tsconfig.json` 的 `include` 中(模板默认如此;
-缺失时 `agent-bundle validate` 会以 `AB4834` 警告)。
+成功的、不含路由的准备过程会移除它。只有编译了这份声明的 TypeScript 程序,才会为 `renderRoute`、App
+客户端与 provider 值提供类型:请把 `".agent-bundle/routes.d.ts"` 保留在每一个导入了 `agent-bundle/test`、
+`agent-bundle/app`、`agent-bundle/eval` 或 `@agent-bundle/runtime` 的 tsconfig 的 `include` 中——solution
+式根会按被引用项目逐一判断,因此包含了该文件的服务器项目无法掩盖漏掉它的浏览器项目(`agent-bundle validate`
+会按程序逐一以 `AB4834` 警告)。`validate` 也是在独立类型检查之前发布最新声明的轻量方式——无需 dev
+服务器或生产构建——因此带路由的模板把 `typecheck` 脚本写成 `agent-bundle validate && tsc`。
 
 ## 三个表面
 
diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx
index 462b1c87d..9f5d2e55f 100644
--- a/website/docs/zh/guide/development/testing.mdx
+++ b/website/docs/zh/guide/development/testing.mdx
@@ -54,8 +54,9 @@ export const summarizes = async (): Promise => {
 编译器生成的 `.agent-bundle/routes.d.ts` 会把项目的路由契约注册到 `@agent-bundle/runtime` 的
 `Register` 接口上——与它已经声明 provider 键的方式相同。一旦该文件成为项目 TypeScript 程序的一部分
 (`create-agent-bundle` 模板默认就把 `".agent-bundle/routes.d.ts"` 列在 `tsconfig.json` 的 `include`
-中,文件本身仍被 gitignore;当一个编译了路由或 provider 的项目漏掉它时,`agent-bundle validate` 会以
-`AB4834` 警告),用字符串字面量写出的
+中,文件本身仍被 gitignore;每一个导入了 `agent-bundle/test`、`agent-bundle/app`、`agent-bundle/eval`
+或 `@agent-bundle/runtime` 却漏掉该文件的 TypeScript 程序,`agent-bundle validate` 都会以 `AB4834`
+逐一警告),用字符串字面量写出的
 `renderRoute` 调用就会针对编译后的 route id 做检查——编辑器会补全它们,写错时会被拒绝并列出已注册的
 备选项——而 `input` 与 `result` 来自该路由自己的 `inputSchema` 与 `resultSchema`:
 
@@ -66,6 +67,14 @@ const { result } = await renderRoute('tool:library/summarize', {
 const chapters: number | undefined = result?.chapters; // 无需强制类型转换
 ```
 
+`input` 是调用方发送的内容——schema 的**输入**类型:带 `.default()` 的字段是可选的,经
+`.transform()` 的字段按 wire 上的写法拼写——而路由组件收到的是 schema 的**输出**,与生成的服务器交给它的
+完全一致。`renderRoute` 在同一边界用路由自己的 `inputSchema` 解析输入:组件看到的是已填默认值、已应用
+transform 的值;schema 拒绝的输入会在任何 provider 或组件运行之前,以 `invalid-input` 测试错误失败。
+`result` 是 `resultSchema` 的输出。两侧都会被注册:`RegisteredRouteInput`(来自
+`@agent-bundle/runtime`)是调用方一侧,`RegisteredRouteParsedInput` 是组件一侧,也就是
+`loadRouteModule(id).inputSchema.parse(...)` 的返回类型。
+
 事件路由注册的是测试工具实际接受与返回的内容:`input` 是 `{ canonical, native, preflight? }` 载荷(`signal` 由测试
 工具自行提供),而 `result` 为 `undefined`,因为事件模块不导出 `resultSchema`。请用
 `createEventRouteInput('tool/after', envelope, { host: 'claude' })` 从宿主信封构造这个输入,而不要手写:它按