From be037064b48d4dabaf4940695ad210231d699eb5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 22:03:50 +0000 Subject: [PATCH 1/2] feat(workbench): add schema-driven route input editors Project bounded route schemas into the manifest so Routes can validate inputs, hand tool calls to existing MCP sessions, and render CLI argv without executing route modules. --- .changeset/schema-driven-route-inputs.md | 18 + .../mcp/curator/tools/inventory_sources.tsx | 6 +- packages/agent-bundle/src/contracts/routes.ts | 11 + .../src/dev/routes/route-manifest.ts | 4 + packages/agent-bundle/src/routes/cli-argv.ts | 462 ++---------------- packages/agent-bundle/src/routes/graph.ts | 25 +- .../agent-bundle/src/routes/input-schema.ts | 462 ++++++++++++++++++ packages/agent-bundle/src/routes/types.ts | 53 ++ .../agent-bundle/tests/input-schema.test.ts | 63 +++ .../agent-bundle/tests/route-graph.test.ts | 55 +++ .../tests/route-manifest-routes.test.ts | 29 ++ packages/workbench/src/main.tsx | 25 +- packages/workbench/src/mcp/mcp-page.css | 17 +- packages/workbench/src/mcp/mcp-page.tsx | 17 +- .../src/routes/route-manifest-client.ts | 49 ++ packages/workbench/src/routes/routes-model.ts | 268 ++++++++++ packages/workbench/src/routes/routes-page.css | 19 +- packages/workbench/src/routes/routes-page.tsx | 263 +++++++--- .../workbench/tests/examples-real.e2e.test.ts | 28 ++ packages/workbench/tests/mcp-page.test.ts | 22 + .../tests/route-manifest-client.test.ts | 51 ++ packages/workbench/tests/routes-model.test.ts | 116 +++++ packages/workbench/tests/routes-page.test.ts | 33 ++ 23 files changed, 1598 insertions(+), 498 deletions(-) create mode 100644 .changeset/schema-driven-route-inputs.md create mode 100644 packages/agent-bundle/src/routes/input-schema.ts create mode 100644 packages/agent-bundle/tests/input-schema.test.ts diff --git a/.changeset/schema-driven-route-inputs.md b/.changeset/schema-driven-route-inputs.md new file mode 100644 index 000000000..33ef775d5 --- /dev/null +++ b/.changeset/schema-driven-route-inputs.md @@ -0,0 +1,18 @@ +--- +"agent-bundle": minor +--- + +Project conventional route `inputSchema` exports into a deterministic, +deep-frozen JSON Schema subset without executing route modules (#105 stage 2). +Supported zod object, scalar, enum, array, optional, default, description, and +validation-only chains now travel through `CompiledRouteGraph` and the route +manifest as an optional `inputSchema` field. Rich schemas remain valid and +simply omit the projection; CLI routes retain their existing `AB4814` +diagnostics and argv behavior. + +The Workbench Routes page renders generated scalar, enum, boolean, and +repeatable-array editors with defaults, descriptions, required markers, and +client-side validation. Unprojectable schemas receive an explicit raw-JSON +fallback. Valid tool input can be handed to the existing MCP playground as a +prefilled server, tool, and arguments selection without auto-execution, while +valid CLI input produces a copyable argv invocation. diff --git a/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx b/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx index a0c88515c..02d09b2ca 100644 --- a/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx +++ b/examples/audiobook-curator/src/mcp/curator/tools/inventory_sources.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type { ToolRouteProps } from 'agent-bundle'; +import { z } from 'zod'; import { CuratorResult, type CuratorReceipt } from '../../../result.js'; import { defaultDiscoveryOperations, discoveryOperations } from '../../../operations/discovery.js'; @@ -7,7 +8,10 @@ import { defaultDiscoveryOperations, discoveryOperations } from '../../../operat const operation = discoveryOperations(defaultDiscoveryOperations).inventory; export const config = {"annotations":{"readOnlyHint":false},"description":"Inventory source audio with retained per-file probe evidence."}; -export const inputSchema = operation.inputSchema; +export const inputSchema = z.object({ + inventory: z.string().min(1).max(4096).describe('Source audio path to inventory.'), + report: z.string().min(1).max(4096).optional().describe('Optional report destination.'), +}).strict(); export const resultSchema = operation.resultSchema; export default async function Route({ input, signal }: ToolRouteProps) { diff --git a/packages/agent-bundle/src/contracts/routes.ts b/packages/agent-bundle/src/contracts/routes.ts index 08767e894..780fe5436 100644 --- a/packages/agent-bundle/src/contracts/routes.ts +++ b/packages/agent-bundle/src/contracts/routes.ts @@ -18,3 +18,14 @@ export type { RouteManifestServer, RouteManifestServerMode, } from '../dev/routes/route-manifest.ts'; +export type { + RouteInputArrayItemSchema, + RouteInputArraySchema, + RouteInputBooleanSchema, + RouteInputNumberSchema, + RouteInputPropertySchema, + RouteInputScalarSchema, + RouteInputSchema, + RouteInputSchemaLiteral, + RouteInputStringSchema, +} from '../routes/types.ts'; diff --git a/packages/agent-bundle/src/dev/routes/route-manifest.ts b/packages/agent-bundle/src/dev/routes/route-manifest.ts index df2b3a380..d9d8477ca 100644 --- a/packages/agent-bundle/src/dev/routes/route-manifest.ts +++ b/packages/agent-bundle/src/dev/routes/route-manifest.ts @@ -11,6 +11,7 @@ import type { CompiledRouteKind, CompiledServerMode, CompiledServerSurface, + RouteInputSchema, } from '../../routes/types.ts'; /** Mirrors {@link CompiledRouteKind}: the catalog groups by the compiler's own kinds. */ @@ -51,6 +52,8 @@ export interface RouteManifestRoute { /** Canonical event identity; `event-route` routes only. */ readonly event?: string; readonly id: string; + /** Bounded JSON Schema projection; absent when the route schema is richer than the static grammar. */ + readonly inputSchema?: RouteInputSchema; readonly kind: RouteManifestKind; readonly provenance: RouteManifestProvenance; /** The owning MCP server id (`mcp:`); MCP route kinds only. */ @@ -175,6 +178,7 @@ const manifestRoute = (route: CompiledAgentRoute): RouteManifestRoute => { ...(summary === undefined ? {} : { description: summary }), ...(route.event === undefined ? {} : { event: route.event }), id: route.id, + ...(route.inputSchema === undefined ? {} : { inputSchema: route.inputSchema }), kind: route.kind, provenance: { kind: route.provenance.kind }, ...(route.serverId === undefined ? {} : { serverId: route.serverId }), diff --git a/packages/agent-bundle/src/routes/cli-argv.ts b/packages/agent-bundle/src/routes/cli-argv.ts index 57091378a..227bc1337 100644 --- a/packages/agent-bundle/src/routes/cli-argv.ts +++ b/packages/agent-bundle/src/routes/cli-argv.ts @@ -1,45 +1,14 @@ -// Aliased: the workspace toolchain is typescript@7 (native compiler, no -// single-file parse API), and a plain `typescript` dependency here would -// shadow it for rslib's declaration generation. The alias ships the 5.x -// compiler API for parsing only. -import ts from 'typescript-5'; - import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; +import { parseInputSchema, type StaticInputSchemaProperty } from './input-schema.ts'; import type { CompiledCliOption } from './types.ts'; /** * The bounded zod-to-argv grammar (#102 stage 2). A routed CLI command's * `inputSchema` is projected onto argv statically — the module is parsed, - * never executed — so the initializer must be built from these forms only: - * - * - the top level is `z.object({ ... })` or `z.strictObject({ ... })`, - * optionally followed by `.strict()`; - * - each property is a zod chain rooted at `z.string()`, `z.number()`, - * `z.boolean()`, `z.url()` (a string-valued option validated as a URL at - * run time), `z.enum([...string literals])`, or `z.array()` - * where the element chain roots at `z.string()`, `z.number()`, or - * `z.enum([...])`; - * - chains may append `.optional()`, `.default()`, and - * `.describe('')` — these shape the argv contract — plus a - * bounded set of validation-only refinements the projection accepts - * without interpreting (strings: `min`, `max`, `length`, `regex`, - * `startsWith`, `endsWith`, `includes`; numbers: `int`, `min`, `max`, - * `gt`, `gte`, `lt`, `lte`, `positive`, `nonnegative`, `negative`, - * `nonpositive`, `finite`, `safe`, `multipleOf`, `step`; arrays: `min`, - * `max`, `length`, `nonempty`), because the module's real zod schema still - * validates every input at run time; - * - `as`/`satisfies` casts, non-null assertions, and parentheses unwrap. - * - * Everything else — identifier references (including shared schema - * constants), unions, tuples, nested objects, records, literals, - * transforms, refinements, coercions, template substitutions — is outside - * the projection and raises AB4814 naming the offending construct. Argv - * policy: property keys project to kebab-case `--options` (`maxFiles` -> - * `--max-files`); `--help`, `--json`, `--ndjson`, and `--version` are - * reserved; booleans are flags and must carry `.optional()` or - * `.default(...)`; `config.positionals` names the keys consumed as bare - * arguments in order. + * never executed. Parsing and property-chain interpretation are shared with + * the route JSON-Schema projection; argv-specific naming and flag policy stay + * here. */ export const cliArgvGrammar = 'z.object of z.string/z.number/z.boolean/z.enum/z.array chains with optional/default/describe and bounded validation-only refinements'; @@ -74,404 +43,79 @@ const argvError = (message: string, sourcePath: string): Diagnostic => ({ sourcePath, }); -/** Casts, assertions, and parentheses carry no runtime value; unwrap them. */ -const unwrapExpression = (expression: ts.Expression): ts.Expression => { - let current = expression; - while ( - ts.isParenthesizedExpression(current) || - ts.isAsExpression(current) || - ts.isSatisfiesExpression(current) || - ts.isNonNullExpression(current) || - ts.isTypeAssertionExpression(current) - ) { - current = current.expression; - } - return current; -}; - -const positionOf = (sourceFile: ts.SourceFile, node: ts.Node): string => { - const { character, line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); - return `${line + 1}:${character + 1}`; -}; - -interface ChainCall { - readonly args: readonly ts.Expression[]; - readonly method: string; - readonly node: ts.Node; -} - -interface ZodChain { - /** The `z.(...)` call name and arguments. */ - readonly base: ChainCall; - /** Chained method calls after the base, innermost first. */ - readonly calls: readonly ChainCall[]; -} - -/** Flattens `z.base(...).m1(...).m2(...)` into base + ordered calls; undefined when the shape is not a `z.` chain. */ -const flattenZodChain = (expression: ts.Expression): ZodChain | undefined => { - const calls: ChainCall[] = []; - let current = unwrapExpression(expression); - while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) { - const target = unwrapExpression(current.expression.expression); - calls.unshift({ args: current.arguments, method: current.expression.name.text, node: current }); - if (ts.isIdentifier(target) && target.text === 'z') { - const base = calls.shift()!; - return { base, calls }; - } - current = target; - } - return undefined; -}; - -type StaticLiteral = - | { readonly kind: 'value'; readonly value: unknown } - | { readonly kind: 'dynamic'; readonly node: ts.Node }; - -/** Static literal grammar for `.default(...)` arguments: scalars and arrays of scalars. */ -const staticLiteral = (expression: ts.Expression): StaticLiteral => { - const node = unwrapExpression(expression); - if (node.kind === ts.SyntaxKind.TrueKeyword) return { kind: 'value', value: true }; - if (node.kind === ts.SyntaxKind.FalseKeyword) return { kind: 'value', value: false }; - if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { - return { kind: 'value', value: node.text }; - } - if (ts.isNumericLiteral(node)) return { kind: 'value', value: Number(node.text) }; - if (ts.isPrefixUnaryExpression(node)) { - const operand = unwrapExpression(node.operand); - if ( - ts.isNumericLiteral(operand) && - (node.operator === ts.SyntaxKind.MinusToken || node.operator === ts.SyntaxKind.PlusToken) - ) { - const magnitude = Number(operand.text); - return { kind: 'value', value: node.operator === ts.SyntaxKind.MinusToken ? -magnitude : magnitude }; - } - return { kind: 'dynamic', node }; - } - if (ts.isArrayLiteralExpression(node)) { - const values: unknown[] = []; - for (const element of node.elements) { - if (ts.isSpreadElement(element) || ts.isOmittedExpression(element)) return { kind: 'dynamic', node: element }; - const extracted = staticLiteral(element); - if (extracted.kind === 'dynamic') return extracted; - values.push(extracted.value); - } - return { kind: 'value', value: values }; - } - return { kind: 'dynamic', node }; -}; - -type ScalarBaseKind = 'boolean' | 'enum' | 'number' | 'string'; - -const validationOnlyMethods: Readonly>> = Object.freeze({ - array: new Set(['length', 'max', 'min', 'nonempty']), - boolean: new Set(), - enum: new Set(), - number: new Set([ - 'finite', - 'gt', - 'gte', - 'int', - 'lt', - 'lte', - 'max', - 'min', - 'multipleOf', - 'negative', - 'nonnegative', - 'nonpositive', - 'positive', - 'safe', - 'step', - ]), - string: new Set(['endsWith', 'includes', 'length', 'max', 'min', 'regex', 'startsWith']), -}); - -interface ScalarBase { - readonly choices?: readonly string[]; - readonly kind: ScalarBaseKind; -} - -type ScalarBaseResult = - | { readonly base: ScalarBase; readonly ok: true } - | { readonly message: string; readonly ok: false }; - -/** Interprets one `z.(...)` call as a scalar argv projection base. */ -const scalarBaseOf = ( - chain: ZodChain, - sourceFile: ts.SourceFile, - relativePath: string, - key: string, -): ScalarBaseResult => { - const { args, method, node } = chain.base; - const reject = (detail: string): ScalarBaseResult => ({ - message: `CLI route ${relativePath} property ${JSON.stringify(key)}: ${detail} at ${positionOf(sourceFile, node)} is outside the bounded argv grammar.`, - ok: false, - }); - switch (method) { - case 'string': - case 'number': - case 'boolean': { - if (args.length > 0) return reject(`z.${method} with arguments`); - return { base: { kind: method }, ok: true }; - } - case 'url': { - // A string-valued option; the module's real zod schema enforces the - // URL format at run time. - if (args.length > 0) return reject('z.url with arguments'); - return { base: { kind: 'string' }, ok: true }; - } - case 'enum': { - const argument = args.length === 1 ? unwrapExpression(args[0]!) : undefined; - if (argument === undefined || !ts.isArrayLiteralExpression(argument)) { - return reject('z.enum without one array-literal argument'); - } - const choices: string[] = []; - for (const element of argument.elements) { - const literal = unwrapExpression(element as ts.Expression); - if (!ts.isStringLiteral(literal) && !ts.isNoSubstitutionTemplateLiteral(literal)) { - return reject('a non-string-literal z.enum member'); - } - choices.push(literal.text); - } - if (choices.length === 0) return reject('an empty z.enum'); - return { base: { choices, kind: 'enum' }, ok: true }; - } - default: - return reject(`the zod base z.${method}`); - } -}; - -/** True when every chained call is validation-only for the given base kind. */ -const validationOnlyChain = ( - calls: readonly ChainCall[], - kind: ScalarBaseKind | 'array', -): ChainCall | undefined => calls.find((call) => !validationOnlyMethods[kind].has(call.method)); +const optionNameOf = (key: string): string => key + .replace(/([a-z0-9])([A-Z])/gu, '$1-$2') + .replace(/([A-Z]+)([A-Z][a-z])/gu, '$1-$2') + .toLowerCase(); -interface PropertyProjection { - readonly diagnostics: readonly Diagnostic[]; +interface CliPropertyProjection { + readonly diagnostic?: Diagnostic; readonly option?: CompiledCliOption; } -const projectProperty = ( - key: string, - initializer: ts.Expression, - sourceFile: ts.SourceFile, +const cliOptionFor = ( + property: StaticInputSchemaProperty, relativePath: string, sourcePath: string, -): PropertyProjection => { - const reject = (detail: string, node: ts.Node): PropertyProjection => ({ - diagnostics: [argvError( - `CLI route ${relativePath} property ${JSON.stringify(key)}: ${detail} at ${positionOf(sourceFile, node)} is outside the bounded argv grammar.`, - sourcePath, - )], - }); - const chain = flattenZodChain(initializer); - if (chain === undefined) { - const node = unwrapExpression(initializer); - const description = ts.isIdentifier(node) - ? `a reference to the identifier ${JSON.stringify(node.text)}` - : 'an expression outside the z.(...) chain form'; - return reject(description, node); - } - - let elementBase: ScalarBase | undefined; - let repeated = false; - if (chain.base.method === 'array') { - repeated = true; - const argument = chain.base.args.length === 1 ? chain.base.args[0]! : undefined; - const element = argument === undefined ? undefined : flattenZodChain(argument); - if (element === undefined) { - return reject('z.array without one z.(...) chain argument', chain.base.node); - } - const scalar = scalarBaseOf(element, sourceFile, relativePath, key); - if (!scalar.ok) return { diagnostics: [argvError(scalar.message, sourcePath)] }; - if (scalar.base.kind === 'boolean') { - return reject('z.array of z.boolean cannot be projected onto argv;', chain.base.node); - } - const invalidElementCall = validationOnlyChain(element.calls, scalar.base.kind); - if (invalidElementCall !== undefined) { - return reject(`the array-element method .${invalidElementCall.method}()`, invalidElementCall.node); - } - elementBase = scalar.base; - } else { - const scalar = scalarBaseOf(chain, sourceFile, relativePath, key); - if (!scalar.ok) return { diagnostics: [argvError(scalar.message, sourcePath)] }; - elementBase = scalar.base; - } - - let defaultValue: unknown; - let hasDefault = false; - let description: string | undefined; - let optional = false; - const validationKind = repeated ? 'array' : elementBase.kind; - for (const call of chain.calls) { - if (call.method === 'optional') { - if (call.args.length > 0) return reject('.optional() with arguments', call.node); - optional = true; - continue; - } - if (call.method === 'default') { - const argument = call.args.length === 1 ? staticLiteral(call.args[0]!) : undefined; - if (argument === undefined || argument.kind === 'dynamic') { - return reject('.default() without one static literal argument', call.node); - } - defaultValue = argument.value; - hasDefault = true; - continue; - } - if (call.method === 'describe') { - const argument = call.args.length === 1 ? unwrapExpression(call.args[0]!) : undefined; - if (argument === undefined || (!ts.isStringLiteral(argument) && !ts.isNoSubstitutionTemplateLiteral(argument))) { - return reject('.describe() without one string-literal argument', call.node); - } - description = argument.text; - continue; - } - if (validationOnlyMethods[validationKind].has(call.method)) continue; - return reject(`the method .${call.method}()`, call.node); - } - - const required = !optional && !hasDefault; - if (elementBase.kind === 'boolean' && required) { +): CliPropertyProjection => { + const required = !property.optional && !property.hasDefault; + if (property.base.kind === 'boolean' && required) { return { - diagnostics: [argvError( - `CLI route ${relativePath} property ${JSON.stringify(key)}: a required boolean cannot be expressed as a flag; add .optional() or .default(false).`, + diagnostic: argvError( + `CLI route ${relativePath} property ${JSON.stringify(property.key)}: a required boolean cannot be expressed as a flag; add .optional() or .default(false).`, sourcePath, - )], + ), }; } - const option = key - .replace(/([a-z0-9])([A-Z])/gu, '$1-$2') - .replace(/([A-Z]+)([A-Z][a-z])/gu, '$1-$2') - .toLowerCase(); + const option = optionNameOf(property.key); if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(option)) { return { - diagnostics: [argvError( - `CLI route ${relativePath} property ${JSON.stringify(key)} does not project onto a kebab-case option name.`, + diagnostic: argvError( + `CLI route ${relativePath} property ${JSON.stringify(property.key)} does not project onto a kebab-case option name.`, sourcePath, - )], + ), }; } if (reservedCliOptionNames.has(option)) { return { - diagnostics: [argvError( - `CLI route ${relativePath} property ${JSON.stringify(key)} projects onto the reserved option --${option}.`, + diagnostic: argvError( + `CLI route ${relativePath} property ${JSON.stringify(property.key)} projects onto the reserved option --${option}.`, sourcePath, - )], + ), }; } return { - diagnostics: [], option: { - ...(elementBase.choices === undefined ? {} : { choices: elementBase.choices }), - ...(hasDefault ? { defaultValue } : {}), - ...(description === undefined ? {} : { description }), - key, - kind: elementBase.kind, + ...(property.base.choices === undefined ? {} : { choices: property.base.choices }), + ...(property.hasDefault ? { defaultValue: property.defaultValue } : {}), + ...(property.description === undefined ? {} : { description: property.description }), + key: property.key, + kind: property.base.kind, option, - repeated, + repeated: property.repeated, required, }, }; }; -interface InputSchemaExportSite { - /** The accepted-form initializer; absent for every rejected declaration shape. */ - readonly initializer?: ts.Expression; - readonly rejection?: string; -} - -const bindsInputSchemaName = (name: ts.BindingName): boolean => { - if (ts.isIdentifier(name)) return name.text === 'inputSchema'; - return name.elements.some((element) => - !ts.isOmittedExpression(element) && bindsInputSchemaName(element.name)); -}; - -const hasExportModifier = (statement: ts.Statement): boolean => - ts.canHaveModifiers(statement) && - (ts.getModifiers(statement) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); - -/** Finds the first top-level statement that exports an `inputSchema` binding. */ -const findInputSchemaExport = (sourceFile: ts.SourceFile): InputSchemaExportSite | undefined => { - for (const statement of sourceFile.statements) { - if (ts.isVariableStatement(statement) && hasExportModifier(statement)) { - const declaration = statement.declarationList.declarations - .find((candidate) => bindsInputSchemaName(candidate.name)); - if (declaration === undefined) continue; - if (!ts.isIdentifier(declaration.name)) return { rejection: 'a destructuring declaration' }; - if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) { - return { rejection: 'a mutable `let`/`var` declaration' }; - } - if (declaration.initializer === undefined) return { rejection: 'a declaration without an initializer' }; - return { initializer: declaration.initializer }; - } - if (ts.isExportDeclaration(statement) && statement.exportClause !== undefined && - ts.isNamedExports(statement.exportClause)) { - const named = statement.exportClause.elements - .find((element) => element.name.text === 'inputSchema'); - if (named !== undefined) return { rejection: 'an indirect `export { inputSchema }` clause' }; - } - } - return undefined; -}; - /** * Statically projects one CLI route module's `export const inputSchema` * declaration onto the argv contract. The module is parsed with the - * TypeScript compiler and never executed; the module's real zod schema still - * validates parsed input at run time, so validation-only refinements pass - * through uninterpreted. + * TypeScript compiler and never executed; validation-only refinements pass + * through uninterpreted because the real zod schema validates at run time. */ export const extractCliArgv = ( moduleText: string, relativePath: string, sourcePath: string, ): ExtractedCliArgv => { - const sourceFile = ts.createSourceFile(relativePath, moduleText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - const site = findInputSchemaExport(sourceFile); - if (site === undefined) return deepFreeze({ diagnostics: [], found: false }); - if (site.initializer === undefined) { + const parsed = parseInputSchema(moduleText, relativePath); + if (!parsed.found) return deepFreeze({ diagnostics: [], found: false }); + if (parsed.entries === undefined) { return deepFreeze({ - diagnostics: [argvError( - `CLI route ${relativePath} exports inputSchema through ${site.rejection!}; only a single top-level \`export const inputSchema = z.object({ ... })\` declaration is projected onto argv.`, - sourcePath, - )], - found: true, - }); - } - - const chain = flattenZodChain(site.initializer); - const objectBase = chain !== undefined && (chain.base.method === 'object' || chain.base.method === 'strictObject') - ? chain - : undefined; - if (objectBase === undefined) { - return deepFreeze({ - diagnostics: [argvError( - `CLI route ${relativePath} has an inputSchema outside the argv grammar: the top level must be z.object({ ... }) or z.strictObject({ ... }).`, - sourcePath, - )], - found: true, - }); - } - const invalidTopLevelCall = objectBase.calls.find((call) => call.method !== 'strict'); - if (invalidTopLevelCall !== undefined) { - return deepFreeze({ - diagnostics: [argvError( - `CLI route ${relativePath} has an inputSchema outside the argv grammar: the top-level method .${invalidTopLevelCall.method}() at ${positionOf(sourceFile, invalidTopLevelCall.node)} is not supported.`, - sourcePath, - )], - found: true, - }); - } - const shape = objectBase.base.args.length === 1 ? unwrapExpression(objectBase.base.args[0]!) : undefined; - if (shape === undefined || !ts.isObjectLiteralExpression(shape)) { - return deepFreeze({ - diagnostics: [argvError( - `CLI route ${relativePath} has an inputSchema outside the argv grammar: z.${objectBase.base.method} requires one object-literal argument.`, - sourcePath, - )], + diagnostics: parsed.issues.map((issue) => argvError(issue, sourcePath)), found: true, }); } @@ -479,37 +123,27 @@ export const extractCliArgv = ( const diagnostics: Diagnostic[] = []; const options: CompiledCliOption[] = []; const seenOptions = new Map(); - for (const property of shape.properties) { - if (!ts.isPropertyAssignment(property)) { - diagnostics.push(argvError( - `CLI route ${relativePath} has an inputSchema property outside the argv grammar at ${positionOf(sourceFile, property)}; use plain \`key: z...\` property assignments.`, - sourcePath, - )); + for (const entry of parsed.entries) { + if ('issue' in entry) { + diagnostics.push(argvError(entry.issue, sourcePath)); continue; } - const name = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) - ? property.name.text - : undefined; - if (name === undefined) { - diagnostics.push(argvError( - `CLI route ${relativePath} has a computed inputSchema property name at ${positionOf(sourceFile, property.name)}; property names must be identifiers or string literals.`, - sourcePath, - )); + const projected = cliOptionFor(entry.property, relativePath, sourcePath); + if (projected.diagnostic !== undefined) { + diagnostics.push(projected.diagnostic); continue; } - const projected = projectProperty(name, property.initializer, sourceFile, relativePath, sourcePath); - diagnostics.push(...projected.diagnostics); - if (projected.option === undefined) continue; - const claimed = seenOptions.get(projected.option.option); + const option = projected.option!; + const claimed = seenOptions.get(option.option); if (claimed !== undefined) { diagnostics.push(argvError( - `CLI route ${relativePath} properties ${JSON.stringify(claimed)} and ${JSON.stringify(name)} both project onto --${projected.option.option}.`, + `CLI route ${relativePath} properties ${JSON.stringify(claimed)} and ${JSON.stringify(option.key)} both project onto --${option.option}.`, sourcePath, )); continue; } - seenOptions.set(projected.option.option, name); - options.push(projected.option); + seenOptions.set(option.option, option.key); + options.push(option); } if (diagnostics.length > 0) return deepFreeze({ diagnostics, found: true }); diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index 69cac93d0..9414d32fa 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -8,6 +8,7 @@ import { isProjectPathIgnored, readProjectIgnoreRules, toPosixPath } from '../co import { compileCliCommands } from './cli-commands.ts'; import { extractRouteConfig } from './config-extract.ts'; import { validateEventRouteModuleContract, validateRouteModuleContract } from './contract.ts'; +import { extractInputSchema } from './input-schema.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { digest } from '../core/digest.ts'; import { deepFreeze } from '../core/freeze.ts'; @@ -24,6 +25,7 @@ import { type CompiledRouteKind, type CompiledServerMode, type CompiledServerSurface, + type RouteInputSchema, } from './types.ts'; type ProjectIgnoreRules = Awaited>; @@ -305,10 +307,12 @@ const declaredMcpServer = ( const compiledRoute = ( module: DiscoveredRouteModule, config: Readonly>, + inputSchema?: RouteInputSchema, ): CompiledAgentRoute => ({ config, ...(module.event === undefined ? {} : { event: module.event }), id: module.id, + ...(inputSchema === undefined ? {} : { inputSchema }), kind: module.kind, provenance: { kind: 'conventional', relativePath: module.relativePath }, ...(module.serverName === undefined ? {} : { serverId: `mcp:${module.serverName}` }), @@ -320,25 +324,35 @@ const compiledRoute = ( * a racing deletion removed simply has no config; extraction diagnostics * (AB4805/AB4806) accumulate beside the discovery diagnostics. */ -const extractedModuleConfig = async ( +interface ExtractedModuleMetadata { + readonly config: Readonly>; + readonly inputSchema?: RouteInputSchema; +} + +const extractedModuleMetadata = async ( module: DiscoveredRouteModule, diagnostics: Diagnostic[], -): Promise>> => { +): Promise => { let moduleText: string; try { moduleText = await readFile(module.source, 'utf8'); } catch { - return emptyRouteConfig; + return { config: emptyRouteConfig }; } const extracted = extractRouteConfig(moduleText, module.relativePath, module.source); diagnostics.push(...extracted.diagnostics); - return extracted.config; + const inputSchema = extractInputSchema(moduleText, module.relativePath); + return { + config: extracted.config, + ...(inputSchema === undefined ? {} : { inputSchema }), + }; }; const routeIdentity = (route: CompiledAgentRoute): Readonly> => ({ config: route.config, ...(route.event === undefined ? {} : { event: route.event }), id: route.id, + ...(route.inputSchema === undefined ? {} : { inputSchema: route.inputSchema }), kind: route.kind, relativePath: route.provenance.relativePath, ...(route.serverId === undefined ? {} : { serverId: route.serverId }), @@ -451,7 +465,8 @@ export const compileRouteGraph = async ( }); continue; } - const route = compiledRoute(module, await extractedModuleConfig(module, diagnostics)); + const metadata = await extractedModuleMetadata(module, diagnostics); + const route = compiledRoute(module, metadata.config, metadata.inputSchema); if (route.kind === 'event-route') { try { diagnostics.push(...validateEventRouteModuleContract( diff --git a/packages/agent-bundle/src/routes/input-schema.ts b/packages/agent-bundle/src/routes/input-schema.ts new file mode 100644 index 000000000..f6b16b6fe --- /dev/null +++ b/packages/agent-bundle/src/routes/input-schema.ts @@ -0,0 +1,462 @@ +// Aliased for the same reason as cli-argv.ts: this is a parser-only use of the +// TypeScript 5.x compiler API and route modules are never executed. +import ts from 'typescript-5'; + +import { deepFreeze } from '../core/freeze.ts'; +import type { + RouteInputArrayItemSchema, + RouteInputPropertySchema, + RouteInputSchema, + RouteInputSchemaLiteral, +} from './types.ts'; + +export interface ChainCall { + readonly args: readonly ts.Expression[]; + readonly method: string; + readonly node: ts.Node; +} + +export interface ZodChain { + readonly base: ChainCall; + readonly calls: readonly ChainCall[]; +} + +export type ScalarBaseKind = 'boolean' | 'enum' | 'number' | 'string'; + +export interface ScalarBase { + readonly choices?: readonly string[]; + readonly kind: ScalarBaseKind; +} + +export interface StaticInputSchemaProperty { + readonly base: ScalarBase; + readonly defaultValue?: unknown; + readonly description?: string; + readonly hasDefault: boolean; + readonly key: string; + readonly optional: boolean; + readonly repeated: boolean; +} + +export interface ParsedInputSchema { + readonly entries?: readonly ParsedInputSchemaEntry[]; + readonly found: boolean; + readonly issues: readonly string[]; + readonly properties?: readonly StaticInputSchemaProperty[]; +} + +export type ParsedInputSchemaEntry = + | Readonly<{ readonly issue: string }> + | Readonly<{ readonly property: StaticInputSchemaProperty }>; + +/** Casts, assertions, and parentheses carry no runtime value; unwrap them. */ +export const unwrapExpression = (expression: ts.Expression): ts.Expression => { + let current = expression; + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isSatisfiesExpression(current) || + ts.isNonNullExpression(current) || + ts.isTypeAssertionExpression(current) + ) { + current = current.expression; + } + return current; +}; + +const positionOf = (sourceFile: ts.SourceFile, node: ts.Node): string => { + const { character, line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + return `${line + 1}:${character + 1}`; +}; + +/** Flattens `z.base(...).m1(...).m2(...)` into base + ordered calls. */ +export const flattenZodChain = (expression: ts.Expression): ZodChain | undefined => { + const calls: ChainCall[] = []; + let current = unwrapExpression(expression); + while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) { + const target = unwrapExpression(current.expression.expression); + calls.unshift({ args: current.arguments, method: current.expression.name.text, node: current }); + if (ts.isIdentifier(target) && target.text === 'z') { + const base = calls.shift()!; + return { base, calls }; + } + current = target; + } + return undefined; +}; + +type StaticLiteral = + | { readonly kind: 'value'; readonly value: unknown } + | { readonly kind: 'dynamic'; readonly node: ts.Node }; + +/** Static literal grammar used by `.default(...)`; callers decide which values they can expose. */ +export const staticLiteral = (expression: ts.Expression): StaticLiteral => { + const node = unwrapExpression(expression); + if (node.kind === ts.SyntaxKind.TrueKeyword) return { kind: 'value', value: true }; + if (node.kind === ts.SyntaxKind.FalseKeyword) return { kind: 'value', value: false }; + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return { kind: 'value', value: node.text }; + } + if (ts.isNumericLiteral(node)) return { kind: 'value', value: Number(node.text) }; + if (ts.isPrefixUnaryExpression(node)) { + const operand = unwrapExpression(node.operand); + if ( + ts.isNumericLiteral(operand) && + (node.operator === ts.SyntaxKind.MinusToken || node.operator === ts.SyntaxKind.PlusToken) + ) { + const magnitude = Number(operand.text); + return { kind: 'value', value: node.operator === ts.SyntaxKind.MinusToken ? -magnitude : magnitude }; + } + return { kind: 'dynamic', node }; + } + if (ts.isArrayLiteralExpression(node)) { + const values: unknown[] = []; + for (const element of node.elements) { + if (ts.isSpreadElement(element) || ts.isOmittedExpression(element)) return { kind: 'dynamic', node: element }; + const extracted = staticLiteral(element); + if (extracted.kind === 'dynamic') return extracted; + values.push(extracted.value); + } + return { kind: 'value', value: values }; + } + return { kind: 'dynamic', node }; +}; + +export const validationOnlyMethods: Readonly>> = Object.freeze({ + array: new Set(['length', 'max', 'min', 'nonempty']), + boolean: new Set(), + enum: new Set(), + number: new Set([ + 'finite', + 'gt', + 'gte', + 'int', + 'lt', + 'lte', + 'max', + 'min', + 'multipleOf', + 'negative', + 'nonnegative', + 'nonpositive', + 'positive', + 'safe', + 'step', + ]), + string: new Set(['endsWith', 'includes', 'length', 'max', 'min', 'regex', 'startsWith']), +}); + +type ScalarBaseResult = + | { readonly base: ScalarBase; readonly ok: true } + | { readonly message: string; readonly ok: false }; + +/** Interprets one `z.(...)` call as a bounded scalar projection base. */ +export const scalarBaseOf = ( + chain: ZodChain, + sourceFile: ts.SourceFile, + relativePath: string, + key: string, +): ScalarBaseResult => { + const { args, method, node } = chain.base; + const reject = (detail: string): ScalarBaseResult => ({ + message: `CLI route ${relativePath} property ${JSON.stringify(key)}: ${detail} at ${positionOf(sourceFile, node)} is outside the bounded argv grammar.`, + ok: false, + }); + switch (method) { + case 'string': + case 'number': + case 'boolean': { + if (args.length > 0) return reject(`z.${method} with arguments`); + return { base: { kind: method }, ok: true }; + } + case 'url': { + if (args.length > 0) return reject('z.url with arguments'); + return { base: { kind: 'string' }, ok: true }; + } + case 'enum': { + const argument = args.length === 1 ? unwrapExpression(args[0]!) : undefined; + if (argument === undefined || !ts.isArrayLiteralExpression(argument)) { + return reject('z.enum without one array-literal argument'); + } + const choices: string[] = []; + for (const element of argument.elements) { + const literal = unwrapExpression(element as ts.Expression); + if (!ts.isStringLiteral(literal) && !ts.isNoSubstitutionTemplateLiteral(literal)) { + return reject('a non-string-literal z.enum member'); + } + choices.push(literal.text); + } + if (choices.length === 0) return reject('an empty z.enum'); + return { base: { choices, kind: 'enum' }, ok: true }; + } + default: + return reject(`the zod base z.${method}`); + } +}; + +const validationOnlyChain = ( + calls: readonly ChainCall[], + kind: ScalarBaseKind | 'array', +): ChainCall | undefined => calls.find((call) => !validationOnlyMethods[kind].has(call.method)); + +type PropertyProjection = + | { readonly issue: string } + | { readonly property: StaticInputSchemaProperty }; + +const projectProperty = ( + key: string, + initializer: ts.Expression, + sourceFile: ts.SourceFile, + relativePath: string, +): PropertyProjection => { + const reject = (detail: string, node: ts.Node): PropertyProjection => ({ + issue: `CLI route ${relativePath} property ${JSON.stringify(key)}: ${detail} at ${positionOf(sourceFile, node)} is outside the bounded argv grammar.`, + }); + const chain = flattenZodChain(initializer); + if (chain === undefined) { + const node = unwrapExpression(initializer); + const description = ts.isIdentifier(node) + ? `a reference to the identifier ${JSON.stringify(node.text)}` + : 'an expression outside the z.(...) chain form'; + return reject(description, node); + } + + let base: ScalarBase; + let repeated = false; + if (chain.base.method === 'array') { + repeated = true; + const argument = chain.base.args.length === 1 ? chain.base.args[0]! : undefined; + const element = argument === undefined ? undefined : flattenZodChain(argument); + if (element === undefined) return reject('z.array without one z.(...) chain argument', chain.base.node); + const scalar = scalarBaseOf(element, sourceFile, relativePath, key); + if (!scalar.ok) return { issue: scalar.message }; + if (scalar.base.kind === 'boolean') { + return reject('z.array of z.boolean cannot be projected onto argv;', chain.base.node); + } + const invalidElementCall = validationOnlyChain(element.calls, scalar.base.kind); + if (invalidElementCall !== undefined) { + return reject(`the array-element method .${invalidElementCall.method}()`, invalidElementCall.node); + } + base = scalar.base; + } else { + const scalar = scalarBaseOf(chain, sourceFile, relativePath, key); + if (!scalar.ok) return { issue: scalar.message }; + base = scalar.base; + } + + let defaultValue: unknown; + let hasDefault = false; + let description: string | undefined; + let optional = false; + const validationKind = repeated ? 'array' : base.kind; + for (const call of chain.calls) { + if (call.method === 'optional') { + if (call.args.length > 0) return reject('.optional() with arguments', call.node); + optional = true; + continue; + } + if (call.method === 'default') { + const argument = call.args.length === 1 ? staticLiteral(call.args[0]!) : undefined; + if (argument === undefined || argument.kind === 'dynamic') { + return reject('.default() without one static literal argument', call.node); + } + defaultValue = argument.value; + hasDefault = true; + continue; + } + if (call.method === 'describe') { + const argument = call.args.length === 1 ? unwrapExpression(call.args[0]!) : undefined; + if (argument === undefined || (!ts.isStringLiteral(argument) && !ts.isNoSubstitutionTemplateLiteral(argument))) { + return reject('.describe() without one string-literal argument', call.node); + } + description = argument.text; + continue; + } + if (validationOnlyMethods[validationKind].has(call.method)) continue; + return reject(`the method .${call.method}()`, call.node); + } + + return { + property: { + base, + ...(hasDefault ? { defaultValue } : {}), + ...(description === undefined ? {} : { description }), + hasDefault, + key, + optional, + repeated, + }, + }; +}; + +interface InputSchemaExportSite { + readonly initializer?: ts.Expression; + readonly rejection?: string; +} + +const bindsInputSchemaName = (name: ts.BindingName): boolean => { + if (ts.isIdentifier(name)) return name.text === 'inputSchema'; + return name.elements.some((element) => + !ts.isOmittedExpression(element) && bindsInputSchemaName(element.name)); +}; + +const hasExportModifier = (statement: ts.Statement): boolean => + ts.canHaveModifiers(statement) && + (ts.getModifiers(statement) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + +const findInputSchemaExport = (sourceFile: ts.SourceFile): InputSchemaExportSite | undefined => { + for (const statement of sourceFile.statements) { + if (ts.isVariableStatement(statement) && hasExportModifier(statement)) { + const declaration = statement.declarationList.declarations + .find((candidate) => bindsInputSchemaName(candidate.name)); + if (declaration === undefined) continue; + if (!ts.isIdentifier(declaration.name)) return { rejection: 'a destructuring declaration' }; + if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) { + return { rejection: 'a mutable `let`/`var` declaration' }; + } + if (declaration.initializer === undefined) return { rejection: 'a declaration without an initializer' }; + return { initializer: declaration.initializer }; + } + if (ts.isExportDeclaration(statement) && statement.exportClause !== undefined && + ts.isNamedExports(statement.exportClause)) { + const named = statement.exportClause.elements + .find((element) => element.name.text === 'inputSchema'); + if (named !== undefined) return { rejection: 'an indirect `export { inputSchema }` clause' }; + } + } + return undefined; +}; + +/** + * Parses the shared bounded input-schema grammar. Issues intentionally retain + * the existing CLI diagnostic wording; non-CLI projection simply ignores them. + */ +export const parseInputSchema = (moduleText: string, relativePath: string): ParsedInputSchema => { + const sourceFile = ts.createSourceFile(relativePath, moduleText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const site = findInputSchemaExport(sourceFile); + if (site === undefined) return { found: false, issues: [] }; + if (site.initializer === undefined) { + return { + found: true, + issues: [ + `CLI route ${relativePath} exports inputSchema through ${site.rejection!}; only a single top-level \`export const inputSchema = z.object({ ... })\` declaration is projected onto argv.`, + ], + }; + } + + const chain = flattenZodChain(site.initializer); + const objectBase = chain !== undefined && (chain.base.method === 'object' || chain.base.method === 'strictObject') + ? chain + : undefined; + if (objectBase === undefined) { + return { + found: true, + issues: [ + `CLI route ${relativePath} has an inputSchema outside the argv grammar: the top level must be z.object({ ... }) or z.strictObject({ ... }).`, + ], + }; + } + const invalidTopLevelCall = objectBase.calls.find((call) => call.method !== 'strict'); + if (invalidTopLevelCall !== undefined) { + return { + found: true, + issues: [ + `CLI route ${relativePath} has an inputSchema outside the argv grammar: the top-level method .${invalidTopLevelCall.method}() at ${positionOf(sourceFile, invalidTopLevelCall.node)} is not supported.`, + ], + }; + } + const shape = objectBase.base.args.length === 1 ? unwrapExpression(objectBase.base.args[0]!) : undefined; + if (shape === undefined || !ts.isObjectLiteralExpression(shape)) { + return { + found: true, + issues: [ + `CLI route ${relativePath} has an inputSchema outside the argv grammar: z.${objectBase.base.method} requires one object-literal argument.`, + ], + }; + } + + const issues: string[] = []; + const entries: ParsedInputSchemaEntry[] = []; + const properties: StaticInputSchemaProperty[] = []; + for (const property of shape.properties) { + if (!ts.isPropertyAssignment(property)) { + const issue = `CLI route ${relativePath} has an inputSchema property outside the argv grammar at ${positionOf(sourceFile, property)}; use plain \`key: z...\` property assignments.`; + issues.push(issue); + entries.push({ issue }); + continue; + } + const name = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) + ? property.name.text + : undefined; + if (name === undefined) { + const issue = `CLI route ${relativePath} has a computed inputSchema property name at ${positionOf(sourceFile, property.name)}; property names must be identifiers or string literals.`; + issues.push(issue); + entries.push({ issue }); + continue; + } + const projected = projectProperty(name, property.initializer, sourceFile, relativePath); + if ('issue' in projected) { + issues.push(projected.issue); + entries.push({ issue: projected.issue }); + } else { + properties.push(projected.property); + entries.push({ property: projected.property }); + } + } + return { entries, found: true, issues, properties }; +}; + +const inputSchemaLiteral = (value: unknown): value is RouteInputSchemaLiteral => { + if (typeof value === 'boolean' || typeof value === 'string') return true; + if (typeof value === 'number') return Number.isFinite(value); + return Array.isArray(value) && value.every((entry) => + typeof entry === 'boolean' || typeof entry === 'string' || + (typeof entry === 'number' && Number.isFinite(entry))); +}; + +const scalarSchema = (base: ScalarBase): RouteInputArrayItemSchema => { + switch (base.kind) { + case 'boolean': + return { type: 'boolean' }; + case 'number': + return { type: 'number' }; + case 'enum': + return { enum: [...base.choices!], type: 'string' }; + case 'string': + return { type: 'string' }; + default: { + const unreachable: never = base.kind; + throw new TypeError(`Unhandled input schema base ${String(unreachable)}.`); + } + } +}; + +/** Statically projects a route module without ever importing or executing it. */ +export const extractInputSchema = ( + moduleText: string, + relativePath: string, +): RouteInputSchema | undefined => { + const parsed = parseInputSchema(moduleText, relativePath); + if (!parsed.found || parsed.issues.length > 0 || parsed.properties === undefined) return undefined; + if (parsed.properties.some((property) => + property.hasDefault && !inputSchemaLiteral(property.defaultValue))) return undefined; + + const properties: Record = {}; + const required: string[] = []; + for (const property of [...parsed.properties].sort((left, right) => left.key.localeCompare(right.key))) { + const metadata = { + ...(property.hasDefault ? { default: property.defaultValue as RouteInputSchemaLiteral } : {}), + ...(property.description === undefined ? {} : { description: property.description }), + }; + properties[property.key] = property.repeated + ? { ...metadata, items: scalarSchema(property.base), type: 'array' } + : { ...scalarSchema(property.base), ...metadata }; + if (!property.optional && !property.hasDefault) required.push(property.key); + } + return deepFreeze({ + additionalProperties: false, + properties, + ...(required.length === 0 ? {} : { required }), + type: 'object', + }); +}; diff --git a/packages/agent-bundle/src/routes/types.ts b/packages/agent-bundle/src/routes/types.ts index 2f444c42b..bc1205bcb 100644 --- a/packages/agent-bundle/src/routes/types.ts +++ b/packages/agent-bundle/src/routes/types.ts @@ -36,6 +36,57 @@ export type { CapabilityEvidence, CapabilityState } from '../core/capabilities.t */ export const emptyRouteConfig: Readonly> = Object.freeze({}); +export type RouteInputSchemaLiteral = + | boolean + | number + | string + | readonly (boolean | number | string)[]; + +interface RouteInputScalarSchemaBase { + readonly default?: RouteInputSchemaLiteral; + readonly description?: string; +} + +export interface RouteInputStringSchema extends RouteInputScalarSchemaBase { + readonly enum?: readonly string[]; + readonly type: 'string'; +} + +export interface RouteInputNumberSchema extends RouteInputScalarSchemaBase { + readonly type: 'number'; +} + +export interface RouteInputBooleanSchema extends RouteInputScalarSchemaBase { + readonly type: 'boolean'; +} + +export type RouteInputScalarSchema = + | RouteInputBooleanSchema + | RouteInputNumberSchema + | RouteInputStringSchema; + +export type RouteInputArrayItemSchema = + | Readonly<{ readonly type: 'boolean' }> + | Readonly<{ readonly type: 'number' }> + | Readonly<{ readonly enum?: readonly string[]; readonly type: 'string' }>; + +export interface RouteInputArraySchema { + readonly default?: RouteInputSchemaLiteral; + readonly description?: string; + readonly items: RouteInputArrayItemSchema; + readonly type: 'array'; +} + +export type RouteInputPropertySchema = RouteInputArraySchema | RouteInputScalarSchema; + +/** Deep-frozen JSON Schema draft-2020-12 subset projected from a route module without executing it. */ +export interface RouteInputSchema { + readonly additionalProperties: false; + readonly properties: Readonly>; + readonly required?: readonly string[]; + readonly type: 'object'; +} + /** One conventional route module compiled into the immutable route graph. */ export interface CompiledAgentRoute { /** Statically extracted from the module's `export const config` declaration; {@link emptyRouteConfig} when absent or rejected. */ @@ -43,6 +94,8 @@ export interface CompiledAgentRoute { /** Canonical event identity; present only when {@link kind} is `event-route`. */ readonly event?: CanonicalAgentEvent; readonly id: string; + /** Statically projected bounded JSON Schema subset; absent for missing or richer input schemas. */ + readonly inputSchema?: RouteInputSchema; readonly kind: CompiledRouteKind; readonly provenance: RouteProvenance; /** The owning MCP server id (`mcp:`); MCP route kinds only. */ diff --git a/packages/agent-bundle/tests/input-schema.test.ts b/packages/agent-bundle/tests/input-schema.test.ts new file mode 100644 index 000000000..36d370221 --- /dev/null +++ b/packages/agent-bundle/tests/input-schema.test.ts @@ -0,0 +1,63 @@ +import { expect, it } from '@rstest/core'; + +import { extractInputSchema } from '../src/routes/input-schema.ts'; + +const extract = (schema: string) => extractInputSchema( + `export const inputSchema = ${schema};\n`, + 'src/mcp/library/tools/inspect.ts', +); + +it('projects the bounded zod grammar into a sorted frozen JSON Schema subset', () => { + const schema = extract([ + 'z.object({', + " tags: z.array(z.enum(['fiction', 'history'])).default(['history']).describe('Catalog tags.'),", + " count: z.number().int().min(1).optional().describe('Maximum matches.'),", + ' enabled: z.boolean().default(true),', + " format: z.enum(['json', 'table']).default('table'),", + ' root: z.url(),', + '}).strict()', + ].join('\n')); + + expect(schema).toEqual({ + additionalProperties: false, + properties: { + count: { description: 'Maximum matches.', type: 'number' }, + enabled: { default: true, type: 'boolean' }, + format: { default: 'table', enum: ['json', 'table'], type: 'string' }, + root: { type: 'string' }, + tags: { + default: ['history'], + description: 'Catalog tags.', + items: { enum: ['fiction', 'history'], type: 'string' }, + type: 'array', + }, + }, + required: ['root'], + type: 'object', + }); + expect(Object.isFrozen(schema)).toBe(true); + expect(Object.isFrozen(schema?.properties)).toBe(true); + expect(Object.isFrozen(schema?.properties.tags)).toBe(true); + expect(Object.isFrozen(schema?.properties.tags?.type === 'array' ? schema.properties.tags.items : undefined)).toBe(true); +}); + +it('accepts strictObject and omits an empty required array', () => { + expect(extract("z.strictObject({ name: z.string().default('library') })")).toEqual({ + additionalProperties: false, + properties: { + name: { default: 'library', type: 'string' }, + }, + type: 'object', + }); +}); + +it('returns no projection for absent and out-of-grammar schemas without diagnostics', () => { + expect(extractInputSchema( + 'export const other = 1;\n', + 'src/mcp/library/tools/inspect.ts', + )).toBeUndefined(); + expect(extract('z.object({ nested: z.object({ value: z.string() }) })')).toBeUndefined(); + expect(extract('z.object({ root: sharedPathSchema })')).toBeUndefined(); + expect(extract('z.object({ flags: z.array(z.boolean()) })')).toBeUndefined(); + expect(extract('z.object({ value: z.string().transform(String) })')).toBeUndefined(); +}); diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 645d64aff..51f664ed9 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -136,6 +136,61 @@ it('compiles the conventional tree into one frozen graph with a machine-independ expect(isEmptyRouteGraph(graph)).toBe(false); }); +it('populates bounded input schemas for every route kind and includes them in the digest', async () => { + const root = await createRoot(); + const bounded = (config = '') => [ + config, + "export const inputSchema = z.object({ count: z.number().optional(), root: z.string().describe('Project root.') }).strict();", + 'export const resultSchema = {};', + 'export default async () => undefined;', + '', + ].join('\n'); + const tree = { + 'src/cli/audit.ts': bounded(), + 'src/events/stop.ts': bounded(), + 'src/mcp/curator/apps/dashboard.tsx': bounded("export const config = { resourceUri: 'ui://curator/dashboard.html' };"), + 'src/mcp/curator/prompts/curate.ts': bounded(), + 'src/mcp/curator/resources/catalog.ts': bounded(), + 'src/mcp/curator/tools/inspect.ts': bounded(), + 'src/mcp/curator/tools/rich.ts': [ + 'const sharedSchema = z.object({ nested: z.object({ value: z.string() }) });', + 'export const inputSchema = sharedSchema;', + 'export const resultSchema = {};', + 'export default async () => undefined;', + '', + ].join('\n'), + 'src/scripts/rebuild.ts': bounded(), + }; + await writeTree(root, tree); + + const graph = await compileRouteGraph(root, fixtureConfig()); + const routes = [ + ...graph.servers.flatMap((server) => server.routes), + ...graph.events, + ...graph.cli!.routes, + ...graph.scripts, + ]; + expect(graph.diagnostics).toEqual([]); + expect(routes.filter((route) => route.id !== 'tool:curator/rich').every((route) => route.inputSchema !== undefined)).toBe(true); + expect(routes.find((route) => route.id === 'tool:curator/rich')?.inputSchema).toBeUndefined(); + expect(routes.find((route) => route.id === 'tool:curator/inspect')?.inputSchema).toEqual({ + additionalProperties: false, + properties: { + count: { type: 'number' }, + root: { description: 'Project root.', type: 'string' }, + }, + required: ['root'], + type: 'object', + }); + + const changedRoot = await createRoot(); + await writeTree(changedRoot, tree); + const inspectPath = join(changedRoot, 'src/mcp/curator/tools/inspect.ts'); + await writeFile(inspectPath, (await readFile(inspectPath, 'utf8')).replace('Project root.', 'Workspace root.')); + const changed = await compileRouteGraph(changedRoot, fixtureConfig()); + expect(changed.digest).not.toBe(graph.digest); +}); + it('skips ignored paths, private segments, and declaration files', async () => { const root = await createRoot(); await writeTree(root, { diff --git a/packages/agent-bundle/tests/route-manifest-routes.test.ts b/packages/agent-bundle/tests/route-manifest-routes.test.ts index c741555e7..4cb53722e 100644 --- a/packages/agent-bundle/tests/route-manifest-routes.test.ts +++ b/packages/agent-bundle/tests/route-manifest-routes.test.ts @@ -5,6 +5,7 @@ import { expect, it } from '@rstest/core'; import { compileRouteGraph, emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import { routeManifestFor, type RouteManifest } from '../src/dev/routes/route-manifest.ts'; import { RouteManifestRoutes, type RouteManifestRouteService } from '../src/dev/routes/route-manifest-routes.ts'; +import type { CompiledRouteGraph } from '../src/routes/types.ts'; import { authorize, originHeaders as headers, @@ -171,6 +172,34 @@ it('projects a compiled graph into the browser manifest with project-relative so expect(Object.isFrozen(manifest)).toBe(true); }); +it('passes the bounded input schema through as the optional manifest wire field', () => { + const inputSchema = Object.freeze({ + additionalProperties: false as const, + properties: Object.freeze({ + root: Object.freeze({ description: 'Project root.', type: 'string' as const }), + }), + required: Object.freeze(['root']), + type: 'object' as const, + }); + const graph: CompiledRouteGraph = { + ...emptyCompiledRouteGraph, + digest: 's'.repeat(64), + scripts: [{ + config: {}, + id: 'script:inspect', + inputSchema, + kind: 'script', + provenance: { kind: 'conventional', relativePath: 'src/scripts/inspect.ts' }, + source: '/project/src/scripts/inspect.ts', + }], + }; + + const manifest = routeManifestFor(graph, revision); + + expect(manifest.scripts[0]?.inputSchema).toEqual(inputSchema); + expect(Object.isFrozen(manifest.scripts[0]?.inputSchema)).toBe(true); +}); + it('summarizes an extracted route config without leaking non-scalar shapes', async () => { const graph = await compileRouteGraph(resolve(import.meta.dirname, '../fixtures/route-harness'), { targets: ['claude'] } as never); const manifest = routeManifestFor(graph, revision); diff --git a/packages/workbench/src/main.tsx b/packages/workbench/src/main.tsx index 8846b2483..22bb9ffc5 100644 --- a/packages/workbench/src/main.tsx +++ b/packages/workbench/src/main.tsx @@ -49,7 +49,12 @@ import { playgroundScriptsForEpoch, } from './playground/playground-page.tsx'; import { RouteManifestClient } from './routes/route-manifest-client.ts'; -import type { RouteCatalog } from './routes/routes-model.ts'; +import { + mcpToolPrefillFromNavigationState, + mcpToolPrefillNavigationState, + type McpToolPrefill, + type RouteCatalog, +} from './routes/routes-model.ts'; import { RoutesPage } from './routes/routes-page.tsx'; import { overviewFor } from './overview-model.ts'; import { downloadBlob } from './client-helpers.ts'; @@ -629,14 +634,15 @@ const PlaygroundScreen = ({ connectionError, inspection, onNavigate, onRunChange ; }; -const RoutesScreen = ({ catalog, connectionError, onNavigate, pages, runtimeDiagnostic }: { +const RoutesScreen = ({ catalog, connectionError, onNavigate, onOpenMcp, pages, runtimeDiagnostic }: { readonly catalog: RouteCatalog; readonly connectionError?: string; readonly onNavigate: (page: WorkbenchPage) => void; + readonly onOpenMcp: (prefill: McpToolPrefill) => void; readonly pages: ReadonlySet; readonly runtimeDiagnostic: string | undefined; }) => - + ; const LogsScreen = ({ connectionError, logClient, onNavigate, pages, runtimeDiagnostic }: { @@ -660,11 +666,12 @@ const HooksScreen = ({ connectionError, hookClient, onNavigate, pages, runtimeDi ; -const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controller, mcpDepartureDiagnostic, model, onNavigate, onResetSession, onRuntimeInitialPreviewConsumed, pages, registerPreviewClose, runtimeDiagnostic, runtimeHandoff, runtimePreviewDependencies, status }: { +const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controller, initialToolPrefill, mcpDepartureDiagnostic, model, onNavigate, onResetSession, onRuntimeInitialPreviewConsumed, pages, registerPreviewClose, runtimeDiagnostic, runtimeHandoff, runtimePreviewDependencies, status }: { readonly appPreviewClient: McpAppClient; readonly artifactClient: ArtifactClient; readonly connectionError?: string; readonly controller: ReturnType; + readonly initialToolPrefill?: McpToolPrefill; readonly mcpDepartureDiagnostic?: string; readonly model: ReturnType['model']; readonly onNavigate: (page: WorkbenchPage) => void; @@ -717,7 +724,11 @@ const McpScreen = ({ appPreviewClient, artifactClient, connectionError, controll appPreviewClient={appPreviewClient} controller={controller} epochOptions={activeEpoch === undefined ? [] : [activeEpoch.id]} - initialBinding={activeEpoch === undefined ? undefined : { epochId: activeEpoch.id }} + initialBinding={activeEpoch === undefined ? undefined : { + epochId: activeEpoch.id, + ...(initialToolPrefill === undefined ? {} : { serverName: initialToolPrefill.serverName }), + }} + initialToolPrefill={initialToolPrefill} onDownloadConfig={downloadMcpFile} onDownloadTrace={downloadMcpFile} onResetSession={onResetSession} @@ -1336,6 +1347,7 @@ const Workbench = () => { artifactClient={artifactClient.current} connectionError={connectionError} controller={mcpController} + initialToolPrefill={mcpToolPrefillFromNavigationState(window.history.state)} mcpDepartureDiagnostic={mcpDepartureError} model={mcpModel} onNavigate={navigate} @@ -1383,6 +1395,9 @@ const Workbench = () => { catalog={capabilities!.routes} connectionError={connectionError} onNavigate={navigate} + onOpenMcp={(prefill) => navigate('mcp', () => { + window.history.replaceState(mcpToolPrefillNavigationState(prefill), '', '#mcp'); + })} pages={pages} runtimeDiagnostic={runtimeError} />); diff --git a/packages/workbench/src/mcp/mcp-page.css b/packages/workbench/src/mcp/mcp-page.css index 702457d0d..81370cafc 100644 --- a/packages/workbench/src/mcp/mcp-page.css +++ b/packages/workbench/src/mcp/mcp-page.css @@ -24,7 +24,8 @@ .mcp-page-recovery, .mcp-page-diagnostics, .mcp-page-app-controls, -.mcp-page-app-preview { +.mcp-page-app-preview, +.mcp-page-prefill { background: #16202e; border: 1px solid #34465d; border-radius: 0.75rem; @@ -84,6 +85,20 @@ gap: 1rem; } +.mcp-page-prefill { + background: #142b47; + border-left: 3px solid #72aef7; +} + +.mcp-page-prefill p { + margin-top: 0.45rem; +} + +.mcp-page-prefill pre { + max-height: 11rem; + overflow: auto; +} + .mcp-page-trace details { display: grid; gap: 0.45rem; diff --git a/packages/workbench/src/mcp/mcp-page.tsx b/packages/workbench/src/mcp/mcp-page.tsx index 987c667f0..1dda422dd 100644 --- a/packages/workbench/src/mcp/mcp-page.tsx +++ b/packages/workbench/src/mcp/mcp-page.tsx @@ -28,6 +28,7 @@ import type { import type { McpSessionControllerBinding, McpSessionControllerReplay, McpSessionControllerRequest } from './mcp-session-controller.ts'; import type { RuntimeAppPreviewProps } from '../runtime-stage.tsx'; import type { RuntimeAppPreviewLifecycle } from '../runtime-playground.tsx'; +import type { McpToolPrefill } from '../routes/routes-model.ts'; import { mcpProtocolTraceDownload, type McpDownload, @@ -52,6 +53,8 @@ export interface McpPageController { interface McpPageCommonProps { readonly controller: McpPageController; readonly initialBinding?: Partial; + /** A validated Routes-page handoff; it selects form state but never executes a call. */ + readonly initialToolPrefill?: McpToolPrefill; readonly onDownloadConfig?: (download: McpConfigDownload) => void; readonly onDownloadTrace?: (download: McpDownload) => void; /** Replaces the terminal controller with a fresh idle controller in the parent. */ @@ -1054,7 +1057,7 @@ const McpPageAppPreview = ({ artifactClient, host, onLifecycleChange, previewPro }; export const McpPage = (props: McpPageProps) => { - const { controller, initialBinding, initialPreview, onDownloadConfig, onDownloadTrace, onResetSession, registerPreviewClose } = props; + const { controller, initialBinding, initialPreview, initialToolPrefill, onDownloadConfig, onDownloadTrace, onResetSession, registerPreviewClose } = props; const runtimeProps: McpPageRuntimeProps | undefined = 'runtimePreviewDependencies' in props ? props : undefined; const artifactProps: McpPageArtifactProps | undefined = 'runtimePreviewDependencies' in props ? undefined : props; const [runtimeAdmission] = useState(() => runtimeProps === undefined @@ -1075,7 +1078,7 @@ export const McpPage = (props: McpPageProps) => { const [binding, setBinding] = useState(() => { const initialTarget = mcpPageTargetFor(initialBinding?.target ?? '', targetOptions); const initialTargetServerOptions = mcpPageServerOptionsFor(serverOptions, initialTarget); - const initialServerName = initialBinding?.serverName ?? ''; + const initialServerName = initialBinding?.serverName ?? initialToolPrefill?.serverName ?? ''; const serverNameOrigin: McpPageServerNameOrigin = initialServerName.length > 0 && !initialTargetServerOptions.some((option) => option.name === initialServerName) ? 'manual' @@ -1091,8 +1094,8 @@ export const McpPage = (props: McpPageProps) => { const [timeoutMs, setTimeoutMs] = useState(''); const [timeoutError, setTimeoutError] = useState(); const [activeTimeoutMs, setActiveTimeoutMs] = useState(controller.session?.timeoutMs); - const [toolName, setToolName] = useState(''); - const [toolArguments, setToolArguments] = useState({}); + const [toolName, setToolName] = useState(initialToolPrefill?.toolName ?? ''); + const [toolArguments, setToolArguments] = useState(initialToolPrefill?.arguments ?? {}); const [promptName, setPromptName] = useState(''); const [promptArguments, setPromptArguments] = useState({}); const [actionError, setActionError] = useState(); @@ -1421,6 +1424,12 @@ export const McpPage = (props: McpPageProps) => {

Catalog

+ {initialToolPrefill === undefined ? undefined : }

Tools

diff --git a/packages/workbench/src/routes/route-manifest-client.ts b/packages/workbench/src/routes/route-manifest-client.ts index 9df29da9c..a19f12868 100644 --- a/packages/workbench/src/routes/route-manifest-client.ts +++ b/packages/workbench/src/routes/route-manifest-client.ts @@ -10,6 +10,10 @@ import type { RouteManifestProvider, RouteManifestRoute, RouteManifestServer, + RouteInputArrayItemSchema, + RouteInputPropertySchema, + RouteInputSchema, + RouteInputSchemaLiteral, } from '../../../agent-bundle/src/contracts/routes.ts'; import type { ForegroundRequestAuthority } from '../mcp/mcp-route-client.ts'; @@ -46,11 +50,56 @@ const configEntrySchema: z.ZodType = z.strictObject({ value: z.string(), }); +const inputSchemaScalarLiteral = z.union([z.boolean(), z.number().finite(), z.string()]); +const inputSchemaLiteral: z.ZodType = z.union([ + inputSchemaScalarLiteral, + z.array(inputSchemaScalarLiteral), +]); + +const inputSchemaArrayItem: z.ZodType = z.union([ + z.strictObject({ type: z.literal('boolean') }), + z.strictObject({ type: z.literal('number') }), + z.strictObject({ enum: z.array(z.string()).optional(), type: z.literal('string') }), +]); + +const inputSchemaProperty: z.ZodType = z.union([ + z.strictObject({ + default: inputSchemaLiteral.optional(), + description: z.string().optional(), + enum: z.array(z.string()).optional(), + type: z.literal('string'), + }), + z.strictObject({ + default: inputSchemaLiteral.optional(), + description: z.string().optional(), + type: z.literal('number'), + }), + z.strictObject({ + default: inputSchemaLiteral.optional(), + description: z.string().optional(), + type: z.literal('boolean'), + }), + z.strictObject({ + default: inputSchemaLiteral.optional(), + description: z.string().optional(), + items: inputSchemaArrayItem, + type: z.literal('array'), + }), +]); + +const inputSchema: z.ZodType = z.strictObject({ + additionalProperties: z.literal(false), + properties: z.record(z.string(), inputSchemaProperty), + required: z.array(z.string()).optional(), + type: z.literal('object'), +}); + const routeSchema: z.ZodType = z.strictObject({ config: z.array(configEntrySchema), description: z.string().optional(), event: z.string().optional(), id: z.string(), + inputSchema: inputSchema.optional(), kind: z.enum(['app', 'cli', 'event-route', 'prompt', 'resource', 'script', 'tool']), provenance: z.strictObject({ kind: z.literal('conventional') }), serverId: z.string().optional(), diff --git a/packages/workbench/src/routes/routes-model.ts b/packages/workbench/src/routes/routes-model.ts index 1330e6212..2a989e604 100644 --- a/packages/workbench/src/routes/routes-model.ts +++ b/packages/workbench/src/routes/routes-model.ts @@ -1,4 +1,5 @@ import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; +import type { JsonObject, JsonValue } from '../../../agent-bundle/src/contracts/runtime.ts'; import type { RouteManifest, RouteManifestCliCommand, @@ -6,6 +7,9 @@ import type { RouteManifestKind, RouteManifestRoute, RouteManifestServerMode, + RouteInputArrayItemSchema, + RouteInputPropertySchema, + RouteInputSchema, } from '../../../agent-bundle/src/contracts/routes.ts'; /** @@ -32,6 +36,7 @@ export interface RouteCatalogEntry { readonly description?: string; readonly event?: string; readonly id: string; + readonly inputSchema?: RouteInputSchema; readonly kind: RouteManifestKind; readonly provenance: 'conventional'; readonly source: string; @@ -77,6 +82,30 @@ export interface RouteCatalog { readonly state: RouteCatalogState; } +export type RouteInputDraftValue = boolean | string | readonly (boolean | string)[]; +export type RouteInputDraft = Readonly>; +export type RouteInputArguments = JsonObject; + +export interface RouteInputValidation { + readonly arguments?: RouteInputArguments; + readonly errors: Readonly>; +} + +export interface RawRouteInputValidation { + readonly arguments?: RouteInputArguments; + readonly error?: string; +} + +export interface McpToolPrefill { + readonly arguments: RouteInputArguments; + readonly serverName: string; + readonly toolName: string; +} + +export interface McpToolPrefillNavigationState { + readonly mcpToolPrefill: McpToolPrefill; +} + const kindLabels: Readonly> = Object.freeze({ app: 'MCP Apps', cli: 'CLI commands', @@ -97,6 +126,7 @@ const entryFor = (route: RouteManifestRoute, command?: RouteManifestCliCommand): ...(route.description === undefined ? {} : { description: route.description }), ...(route.event === undefined ? {} : { event: route.event }), id: route.id, + ...(route.inputSchema === undefined ? {} : { inputSchema: route.inputSchema }), kind: route.kind, provenance: route.provenance.kind, source: route.source, @@ -182,3 +212,241 @@ export const routeCatalogHasKind = (catalog: RouteCatalog, kind: RouteManifestKi export const routeCatalogServerCount = (catalog: RouteCatalog): number => catalog.servers.length; + +const defaultDraftValue = (schema: RouteInputPropertySchema): RouteInputDraftValue => { + if (schema.default !== undefined) { + if (Array.isArray(schema.default)) { + return Object.freeze(schema.default.map((value) => typeof value === 'boolean' ? value : String(value))); + } + return typeof schema.default === 'boolean' ? schema.default : String(schema.default); + } + if (schema.type === 'boolean') return false; + if (schema.type === 'array') return Object.freeze([]); + return ''; +}; + +export const createRouteInputDraft = (schema: RouteInputSchema): RouteInputDraft => Object.freeze( + Object.fromEntries(Object.keys(schema.properties).sort().map((key) => [ + key, + defaultDraftValue(schema.properties[key]!), + ])), +); + +export const routeInputLabel = (key: string): string => { + const words = key + .replace(/([a-z0-9])([A-Z])/gu, '$1 $2') + .replace(/[-_]+/gu, ' ') + .trim(); + return words.length === 0 ? key : `${words[0]!.toUpperCase()}${words.slice(1)}`; +}; + +const scalarArgument = ( + schema: RouteInputArrayItemSchema | Exclude, + value: RouteInputDraftValue | undefined, +): boolean | number | string | undefined => { + switch (schema.type) { + case 'boolean': + return typeof value === 'boolean' ? value : undefined; + case 'number': + return typeof value === 'string' && value.trim().length > 0 ? Number(value) : undefined; + case 'string': + return typeof value === 'string' && value.length > 0 ? value : undefined; + default: { + const unreachable: never = schema; + throw new TypeError(`Unhandled route input scalar ${String(unreachable)}.`); + } + } +}; + +const scalarError = ( + schema: RouteInputArrayItemSchema | Exclude, + value: RouteInputDraftValue | undefined, + label: string, +): string | undefined => { + switch (schema.type) { + case 'boolean': + return typeof value === 'boolean' ? undefined : `${label} must be true or false.`; + case 'number': + return typeof value !== 'string' || value.trim().length === 0 || !Number.isFinite(Number(value)) + ? `${label} must be a number.` + : undefined; + case 'string': { + if (typeof value !== 'string' || value.length === 0) return `${label} is required.`; + return schema.enum !== undefined && !schema.enum.includes(value) + ? `${label} must be one of: ${schema.enum.join(', ')}.` + : undefined; + } + default: { + const unreachable: never = schema; + throw new TypeError(`Unhandled route input scalar ${String(unreachable)}.`); + } + } +}; + +export const validateRouteInput = ( + schema: RouteInputSchema, + draft: RouteInputDraft, +): RouteInputValidation => { + const argumentsValue: Record = {}; + const errors: Record = {}; + const required = new Set(schema.required ?? []); + for (const key of Object.keys(schema.properties).sort()) { + const property = schema.properties[key]!; + const value = draft[key]; + const label = routeInputLabel(key); + if (property.type === 'array') { + if (!Array.isArray(value)) { + if (required.has(key)) errors[key] = `${label} is required.`; + continue; + } + if (value.length === 0) { + if (required.has(key)) errors[key] = `${label} is required.`; + continue; + } + const parsed: (boolean | number | string)[] = []; + for (const [index, item] of value.entries()) { + const error = scalarError(property.items, item, `${label} item ${String(index + 1)}`); + if (error !== undefined) { + errors[key] = error; + break; + } + parsed.push(scalarArgument(property.items, item)!); + } + if (errors[key] === undefined) argumentsValue[key] = parsed; + continue; + } + const absent = property.type === 'boolean' + ? typeof value !== 'boolean' + : typeof value !== 'string' || value.length === 0; + if (absent && !required.has(key)) continue; + const error = scalarError(property, value, label); + if (error !== undefined) { + errors[key] = error; + continue; + } + argumentsValue[key] = scalarArgument(property, value)!; + } + return Object.keys(errors).length > 0 + ? { errors } + : { arguments: Object.freeze(argumentsValue), errors }; +}; + +export const validateRawRouteInput = (text: string): RawRouteInputValidation => { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return { error: 'Enter a valid JSON object.' }; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return { error: 'Arguments must be a JSON object.' }; + } + return { arguments: Object.freeze(value as JsonObject) }; +}; + +const cliOperand = (option: RouteManifestCliCommand['options'][number]): string => { + const kind = option.kind === 'enum' ? option.choices?.join('|') ?? 'string' : option.kind; + return `<${kind}${option.repeated ? '...' : ''}>`; +}; + +export const cliCommandUsage = (command: RouteManifestCliCommand): string => { + const positionals = command.options.filter((option) => option.positional !== undefined) + .toSorted((left, right) => left.positional! - right.positional!) + .map((option) => option.required + ? `<${option.key}${option.repeated ? '...' : ''}>` + : `[${option.key}${option.repeated ? '...' : ''}]`); + const flags = command.options.filter((option) => option.positional === undefined) + .map((option) => { + const value = option.kind === 'boolean' ? `--${option.option}` : `--${option.option} ${cliOperand(option)}`; + return option.required ? value : `[${value}]`; + }); + return [...command.path, ...positionals, ...flags].join(' '); +}; + +const shellToken = (value: unknown): string => { + const text = String(value); + return /^[A-Za-z0-9_./:@%+=,-]+$/u.test(text) ? text : `'${text.replaceAll("'", "'\\''")}'`; +}; + +export const cliCommandInvocation = ( + command: RouteManifestCliCommand, + argumentsValue: RouteInputArguments, +): string | undefined => { + const argv = [...command.path]; + const appendValues = (option: RouteManifestCliCommand['options'][number], positional: boolean): boolean => { + const value = argumentsValue[option.key]; + if (option.kind === 'boolean') { + if (value === true && !positional) argv.push(`--${option.option}`); + return value !== undefined || !option.required; + } + const values = Array.isArray(value) ? value : value === undefined ? [] : [value]; + if (values.length === 0) return !option.required; + for (const item of values) { + if (!positional) argv.push(`--${option.option}`); + argv.push(shellToken(item)); + } + return true; + }; + const positionals = command.options.filter((option) => option.positional !== undefined) + .toSorted((left, right) => left.positional! - right.positional!); + if (positionals.some((option) => !appendValues(option, true))) return undefined; + for (const option of command.options.filter((candidate) => candidate.positional === undefined)) { + if (!appendValues(option, false)) return undefined; + } + return argv.join(' '); +}; + +export const mcpToolPrefillFor = ( + group: RouteCatalogGroup, + entry: RouteCatalogEntry, + argumentsValue: RouteInputArguments, +): McpToolPrefill | undefined => { + if (entry.kind !== 'tool' || group.serverId?.startsWith('mcp:') !== true) return undefined; + const slash = entry.id.lastIndexOf('/'); + if (slash < 0 || slash === entry.id.length - 1) return undefined; + return Object.freeze({ + arguments: argumentsValue, + serverName: group.serverId.slice('mcp:'.length), + toolName: entry.id.slice(slash + 1), + }); +}; + +const navigationJsonValue = (value: unknown, ancestors = new WeakSet()): value is JsonValue => { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return true; + if (typeof value === 'number') return Number.isFinite(value); + if (typeof value !== 'object' || ancestors.has(value)) return false; + ancestors.add(value); + try { + if (Array.isArray(value)) return value.every((entry) => navigationJsonValue(entry, ancestors)); + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false; + return Object.values(value).every((entry) => navigationJsonValue(entry, ancestors)); + } finally { + ancestors.delete(value); + } +}; + +const navigationJsonObject = (value: unknown): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value) && navigationJsonValue(value); + +export const mcpToolPrefillNavigationState = ( + prefill: McpToolPrefill, +): McpToolPrefillNavigationState => Object.freeze({ mcpToolPrefill: prefill }); + +export const mcpToolPrefillFromNavigationState = (value: unknown): McpToolPrefill | undefined => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const prefill = Reflect.get(value, 'mcpToolPrefill') as unknown; + if (typeof prefill !== 'object' || prefill === null || Array.isArray(prefill)) return undefined; + const argumentsValue = Reflect.get(prefill, 'arguments') as unknown; + const serverName = Reflect.get(prefill, 'serverName') as unknown; + const toolName = Reflect.get(prefill, 'toolName') as unknown; + if ( + typeof serverName !== 'string' || serverName.length === 0 || + typeof toolName !== 'string' || toolName.length === 0 || + !navigationJsonObject(argumentsValue) + ) return undefined; + return Object.freeze({ + arguments: argumentsValue, + serverName, + toolName, + }); +}; diff --git a/packages/workbench/src/routes/routes-page.css b/packages/workbench/src/routes/routes-page.css index 3e468019a..cf4aeb1d6 100644 --- a/packages/workbench/src/routes/routes-page.css +++ b/packages/workbench/src/routes/routes-page.css @@ -23,10 +23,25 @@ .route-table { border-collapse: collapse; margin-top: 14px; table-layout: fixed; width: 100%; } .route-table th, .route-table td { border-bottom: 1px solid #e4e8ef; padding: 11px 12px 11px 0; text-align: left; vertical-align: top; } .route-table thead th { color: #596372; font-size: 12px; font-weight: 750; letter-spacing: .03em; text-transform: uppercase; } -.route-table tbody th { font-weight: 600; width: 34%; } +.route-table tbody th { font-weight: 600; width: 27%; } .route-id { display: block; font: 13px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-weight: 700; overflow-wrap: anywhere; } .route-event, .route-command { color: #345080; display: block; font: 12px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; margin-top: 4px; overflow-wrap: anywhere; } .route-description { color: #596372; display: block; font-size: 13px; font-weight: 400; margin-top: 4px; } -.route-source { font: 12px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; overflow-wrap: anywhere; width: 30%; } +.route-source { font: 12px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; overflow-wrap: anywhere; width: 25%; } .route-provenance { color: #7a8492; display: block; font-family: inherit; font-size: 11px; margin-top: 4px; text-transform: uppercase; } .route-config { color: #4f5866; font-size: 13px; overflow-wrap: anywhere; } +.route-config-summary { margin: 0 0 12px; } +.route-input-editor { border-left: 2px solid #d9dee7; padding-left: 12px; } +.route-input-editor h3 { color: #243449; font-size: 13px; margin: 0 0 10px; } +.route-input-editor label, .route-input-field legend { color: #35445a; display: grid; font-size: 12px; font-weight: 700; gap: 5px; } +.route-input-editor input:not([type="checkbox"]), .route-input-editor select, .route-input-editor textarea { background: #fff; border: 1px solid #b8c1ce; border-radius: 3px; box-sizing: border-box; color: #18212d; font: 12px/1.45 "SFMono-Regular", Consolas, "Liberation Mono", monospace; min-width: 0; padding: 6px 7px; width: 100%; } +.route-input-editor input[type="checkbox"] { height: 16px; margin: 1px 0; width: 16px; } +.route-input-field { border: 0; margin: 0 0 10px; min-width: 0; padding: 0; } +.route-input-field p, .route-input-note, .route-input-honesty { color: #697383; font-size: 11px; line-height: 1.45; margin: 4px 0 0; } +.route-input-note { border-top: 1px solid #e4e8ef; margin-top: 10px; padding-top: 8px; } +.route-input-array-row { align-items: center; display: grid; gap: 7px; grid-template-columns: minmax(0, 1fr) auto; margin-bottom: 6px; } +.route-input-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; } +.route-input-editor button { background: #f5f7fa; border: 1px solid #aeb8c6; border-radius: 3px; color: #26364b; cursor: pointer; font-size: 11px; font-weight: 650; padding: 5px 8px; } +.route-input-editor button:disabled { cursor: not-allowed; opacity: .5; } +.route-input-error { color: #aa1f2a; display: block; font-size: 11px; margin-top: 4px; } +.route-cli-invocation { align-items: end; display: grid; gap: 7px; grid-template-columns: minmax(0, 1fr) auto; margin-top: 10px; } diff --git a/packages/workbench/src/routes/routes-page.tsx b/packages/workbench/src/routes/routes-page.tsx index 761fc0c26..148cb8ed6 100644 --- a/packages/workbench/src/routes/routes-page.tsx +++ b/packages/workbench/src/routes/routes-page.tsx @@ -1,15 +1,28 @@ -import React from 'react'; +import React, { useState } from 'react'; -import type { - RouteCatalog, - RouteCatalogEntry, - RouteCatalogGroup, - RouteCatalogServer, +import type { RouteInputPropertySchema } from '../../../agent-bundle/src/contracts/routes.ts'; +import { + cliCommandInvocation, + cliCommandUsage, + createRouteInputDraft, + mcpToolPrefillFor, + routeInputLabel, + validateRawRouteInput, + validateRouteInput, + type McpToolPrefill, + type RouteCatalog, + type RouteCatalogEntry, + type RouteCatalogGroup, + type RouteCatalogServer, + type RouteInputArguments, + type RouteInputDraft, + type RouteInputDraftValue, } from './routes-model.ts'; import './routes-page.css'; export interface RoutesPageProps { readonly catalog: RouteCatalog; + readonly onOpenMcp?: (prefill: McpToolPrefill) => void; } const stateSummaries: Readonly> = Object.freeze({ @@ -21,10 +34,6 @@ const stateSummaries: Readonly> = Object.f const counted = (count: number, singular: string, plural = `${singular}s`): string => `${String(count)} ${count === 1 ? singular : plural}`; -/** - * `description` is projected as the route's own label, so repeating it here - * would print the same sentence twice on every row. - */ const configSummary = (entry: RouteCatalogEntry): string => { if (entry.config.length === 0) return 'No static config export'; const fields = entry.config.filter((field) => !(field.key === 'description' && entry.description !== undefined)); @@ -33,30 +42,6 @@ const configSummary = (entry: RouteCatalogEntry): string => { : fields.map((field) => `${field.key}: ${field.value}`).join(' · '); }; -/** - * A usage line, so positionals lead in their argv order and flags follow — - * the compiler orders options by key, which is not the order they are typed. - */ -const commandSummary = (entry: RouteCatalogEntry): string | undefined => { - const command = entry.command; - if (command === undefined) return undefined; - const positionals = command.options.filter((option) => option.positional !== undefined) - .toSorted((left, right) => left.positional! - right.positional!) - .map((option) => { - const name = option.repeated ? `${option.option}...` : option.option; - return option.required ? `<${name}>` : `[${name}]`; - }); - const flags = command.options.filter((option) => option.positional === undefined) - .map((option) => { - const placeholder = option.kind === 'boolean' - ? '' - : ` <${option.choices === undefined ? option.kind : option.choices.join('|')}>`; - const flag = `--${option.option}${placeholder}`; - return option.required ? flag : `[${flag}]`; - }); - return [...command.path, ...positionals, ...flags].join(' '); -}; - const emptyServerSummary = (server: RouteCatalogServer): string => { switch (server.mode) { case 'command': @@ -85,25 +70,179 @@ const EmptyServerSurface = ({ server }: { readonly server: RouteCatalogServer })

{emptyServerSummary(server)}

; -const RouteGroup = ({ group }: { readonly group: RouteCatalogGroup }) =>
+ `route-input-${routeId}-${key}`.replace(/[^a-zA-Z0-9_-]/gu, '-'); + +const scalarControl = ( + routeId: string, + key: string, + schema: Exclude, + value: RouteInputDraftValue | undefined, + setValue: (value: RouteInputDraftValue) => void, +): React.ReactNode => { + const id = editorId(routeId, key); + switch (schema.type) { + case 'boolean': + return setValue(event.currentTarget.checked)} type="checkbox" />; + case 'number': + return setValue(event.currentTarget.value)} type="number" value={typeof value === 'string' ? value : ''} />; + case 'string': + return schema.enum === undefined + ? setValue(event.currentTarget.value)} type="text" value={typeof value === 'string' ? value : ''} /> + : ; + default: { + const unreachable: never = schema; + throw new TypeError(`Unhandled route input control ${String(unreachable)}.`); + } + } +}; + +const RouteInputEditor = ({ entry, group, onOpenMcp }: { + readonly entry: RouteCatalogEntry; + readonly group: RouteCatalogGroup; + readonly onOpenMcp?: (prefill: McpToolPrefill) => void; +}) => { + const schema = entry.inputSchema; + const [draft, setDraft] = useState(() => schema === undefined ? Object.freeze({}) : createRouteInputDraft(schema)); + const [raw, setRaw] = useState('{}'); + const [errors, setErrors] = useState>>({}); + const [rawError, setRawError] = useState(); + const [attempted, setAttempted] = useState(false); + const [argumentsValue, setArgumentsValue] = useState(); + const [argv, setArgv] = useState(); + + const commitValidation = (next: RouteInputDraft): void => { + if (schema === undefined || !attempted) return; + const validated = validateRouteInput(schema, next); + setErrors(validated.errors); + setArgumentsValue(validated.arguments); + setArgv(validated.arguments === undefined || entry.command === undefined + ? undefined + : cliCommandInvocation(entry.command, validated.arguments)); + }; + const setValue = (key: string, value: RouteInputDraftValue): void => { + const next = Object.freeze({ ...draft, [key]: value }); + setDraft(next); + commitValidation(next); + }; + const validate = (): void => { + setAttempted(true); + if (schema === undefined) { + const validated = validateRawRouteInput(raw); + setRawError(validated.error); + setArgumentsValue(validated.arguments); + setArgv(validated.arguments === undefined || entry.command === undefined + ? undefined + : cliCommandInvocation(entry.command, validated.arguments)); + return; + } + const validated = validateRouteInput(schema, draft); + setErrors(validated.errors); + setArgumentsValue(validated.arguments); + setArgv(validated.arguments === undefined || entry.command === undefined + ? undefined + : cliCommandInvocation(entry.command, validated.arguments)); + }; + const openMcp = (): void => { + if (argumentsValue === undefined || onOpenMcp === undefined) return; + const prefill = mcpToolPrefillFor(group, entry, argumentsValue); + if (prefill !== undefined) onOpenMcp(prefill); + }; + + return
+

{schema === undefined ? 'Raw JSON input' : 'Generated input editor'}

+ {schema === undefined + ?