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..31d8dafd1 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,11 @@ 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({ + source: 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: z.boolean().optional().describe('Fail when any source cannot be probed.'), +}).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..f923611f6 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}>`; +}; + +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.option}${option.repeated ? '...' : ''}>` + : `[${option.option}${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 + ?