|
8 | 8 | * validates the arguments against the schema, runs the tool, and renders its |
9 | 9 | * output in the MCP result envelope. |
10 | 10 | * |
11 | | - * The schemas are read through the AI SDK's `asSchema`, and the SDK is an |
12 | | - * optional peer dependency: this module reaches it only through the lazy |
13 | | - * loader, never a static import, since the CLI loads this module for every |
14 | | - * command and `e2e init` runs before `ai` is installed. |
| 11 | + * The schemas are read through the AI SDK's `asSchema` when it is installed, |
| 12 | + * and through the schema's own Standard Schema (zod's, among others) when it |
| 13 | + * is not. The SDK is an optional peer dependency: this module reaches it only |
| 14 | + * through the lazy loader, never a static import, since the CLI loads this |
| 15 | + * module for every command and `e2e init` runs before `ai` is installed. |
15 | 16 | */ |
16 | 17 |
|
17 | 18 | import type { JSONSchema7, Tool, ToolExecutionOptions, ToolSet } from 'ai'; |
18 | 19 | import type { z } from 'zod'; |
19 | | -import { aiSdk, loadAiSdk } from '../agent/ai-sdk.ts'; |
| 20 | +import { loadAiSdk, loadAiSdkIfInstalled, loadedAiSdk } from '../agent/ai-sdk.ts'; |
20 | 21 | import { isFailedResult } from '../agent/loop-guards.ts'; |
21 | 22 | import { codedMessage, ConfigurationError, errorMessage } from '../internal/errors.ts'; |
| 23 | +import { describeIssue } from '../internal/standard-schema.ts'; |
| 24 | +import type { StandardSchemaV1 } from '../types.ts'; |
22 | 25 |
|
23 | 26 | /** One MCP content part this server emits. */ |
24 | 27 | export type McpContent = |
@@ -92,15 +95,35 @@ const CATALOG_SENTENCE_MAX = 160; |
92 | 95 | * never need. A zod schema converts synchronously; a schema that only |
93 | 96 | * resolves lazily is shown as an open object rather than awaited, since the |
94 | 97 | * catalog is rendered inline. Synchronous, so the session that renders the |
95 | | - * catalog has loaded the SDK first. |
| 98 | + * catalog has loaded the SDK first when it is installed; without it, the |
| 99 | + * schema's Standard JSON Schema converter is read instead. |
96 | 100 | */ |
97 | 101 | export function toolJsonSchema(tool: ToolSet[string]): JSONSchema7 { |
98 | | - const raw = aiSdk().asSchema(tool.inputSchema).jsonSchema; |
99 | | - if (typeof (raw as PromiseLike<JSONSchema7>).then === 'function') return { type: 'object' }; |
| 102 | + const sdk = loadedAiSdk(); |
| 103 | + const raw = sdk === undefined ? standardJsonSchema(tool.inputSchema) : sdk.asSchema(tool.inputSchema).jsonSchema; |
| 104 | + if (raw === undefined || typeof (raw as PromiseLike<JSONSchema7>).then === 'function') return { type: 'object' }; |
100 | 105 | const { $schema: _draft, ...schema } = raw as JSONSchema7; |
101 | 106 | return schema; |
102 | 107 | } |
103 | 108 |
|
| 109 | +/** A Standard Schema that may also carry the Standard JSON Schema converter. */ |
| 110 | +interface StandardToolSchema extends StandardSchemaV1 { |
| 111 | + readonly '~standard': StandardSchemaV1['~standard'] & { |
| 112 | + readonly jsonSchema?: { readonly input: (options: { readonly target: string }) => unknown }; |
| 113 | + }; |
| 114 | +} |
| 115 | + |
| 116 | +/** The tool schema as a Standard Schema v1, or undefined for a schema the AI SDK built. */ |
| 117 | +function standardSchemaOf(schema: unknown): StandardToolSchema | undefined { |
| 118 | + const props = (schema as Partial<StandardToolSchema> | undefined)?.['~standard']; |
| 119 | + return typeof props === 'object' && props !== null && typeof props.validate === 'function' ? (schema as StandardToolSchema) : undefined; |
| 120 | +} |
| 121 | + |
| 122 | +/** The draft-07 JSON Schema a Standard Schema's own converter produces, or undefined without one. */ |
| 123 | +function standardJsonSchema(schema: unknown): unknown { |
| 124 | + return standardSchemaOf(schema)?.['~standard'].jsonSchema?.input({ target: 'draft-07' }); |
| 125 | +} |
| 126 | + |
104 | 127 | /** |
105 | 128 | * One catalog line: the name, the argument names (`?` marks an optional |
106 | 129 | * one), the first sentence of the description, and the read-only mark. |
@@ -167,14 +190,27 @@ export async function invokeTool( |
167 | 190 | } |
168 | 191 |
|
169 | 192 | async function validateArgs(name: string, tool: ToolSet[string], args: Record<string, unknown>): Promise<unknown> { |
170 | | - const { asSchema } = await loadAiSdk(); |
| 193 | + const sdk = await loadAiSdkIfInstalled(); |
| 194 | + const standard = standardSchemaOf(tool.inputSchema); |
| 195 | + if (sdk === undefined && standard !== undefined) { |
| 196 | + const result = await standard['~standard'].validate(args); |
| 197 | + if (result.issues === undefined) return result.value; |
| 198 | + throw invalidArgs(name, result.issues.map(describeIssue).join('; ')); |
| 199 | + } |
| 200 | + // A schema the SDK built (`jsonSchema()`, a lazy schema) only exists with the SDK installed. |
| 201 | + const { asSchema } = sdk ?? (await loadAiSdk()); |
171 | 202 | const schema = asSchema(tool.inputSchema); |
172 | 203 | if (schema.validate === undefined) return args; |
173 | 204 | const result = await schema.validate(args); |
174 | 205 | if (result.success) return result.value; |
175 | | - throw new ConfigurationError( |
| 206 | + throw invalidArgs(name, describeValidationError(result.error)); |
| 207 | +} |
| 208 | + |
| 209 | +/** The failure a `call` with arguments its tool's schema rejects gets. */ |
| 210 | +function invalidArgs(name: string, issues: string): ConfigurationError { |
| 211 | + return new ConfigurationError( |
176 | 212 | 'INVALID_ARGUMENT', |
177 | | - `call ${name}: ${describeValidationError(result.error)}; tools {tool: ${JSON.stringify(name)}} shows its arguments`, |
| 213 | + `call ${name}: ${issues}; tools {tool: ${JSON.stringify(name)}} shows its arguments`, |
178 | 214 | ); |
179 | 215 | } |
180 | 216 |
|
|
0 commit comments