From 423c66feddfa438aab3556945013bcdcba186b34 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 3 Feb 2026 20:16:44 +0000 Subject: [PATCH 01/12] wip --- packages/client/src/client/client.ts | 12 +- packages/core/src/index.ts | 1 + packages/core/src/shared/protocol.ts | 13 +- packages/core/src/util/schema.ts | 11 +- packages/core/src/util/standardSchema.ts | 257 ++++++++++++++++++ .../src/experimental/tasks/interfaces.ts | 22 +- .../src/experimental/tasks/mcpServer.ts | 19 +- .../server/src/experimental/tasks/server.ts | 16 +- packages/server/src/server/completable.ts | 24 +- packages/server/src/server/mcp.ts | 234 +++++++++------- packages/server/src/server/server.ts | 8 +- 11 files changed, 466 insertions(+), 151 deletions(-) create mode 100644 packages/core/src/util/standardSchema.ts diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 7969b1ac7..ca83d3427 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -56,7 +56,6 @@ import { ListResourceTemplatesResultSchema, ListToolsResultSchema, mergeCapabilities, - parseSchema, Protocol, ProtocolError, ProtocolErrorCode, @@ -64,6 +63,7 @@ import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import * as z from 'zod/v4'; import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; @@ -319,7 +319,7 @@ export class Client extends Protocol { // When task creation is requested, validate and return CreateTaskResult if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); + const taskValidationResult = z.safeParse(CreateTaskResultSchema, result); if (!taskValidationResult.success) { const errorMessage = taskValidationResult.error instanceof Error @@ -331,7 +331,7 @@ export class Client extends Protocol { } // For non-task requests, validate against ElicitResultSchema - const validationResult = parseSchema(ElicitResultSchema, result); + const validationResult = z.safeParse(ElicitResultSchema, result); if (!validationResult.success) { // Type guard: if success is false, error is guaranteed to exist const errorMessage = @@ -378,7 +378,7 @@ export class Client extends Protocol { // When task creation is requested, validate and return CreateTaskResult if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); + const taskValidationResult = z.safeParse(CreateTaskResultSchema, result); if (!taskValidationResult.success) { const errorMessage = taskValidationResult.error instanceof Error @@ -392,7 +392,7 @@ export class Client extends Protocol { // For non-task requests, validate against appropriate schema based on tools presence const hasTools = params.tools || params.toolChoice; const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; - const validationResult = parseSchema(resultSchema, result); + const validationResult = z.safeParse(resultSchema, result); if (!validationResult.success) { const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); @@ -845,7 +845,7 @@ export class Client extends Protocol { fetcher: () => Promise ): void { // Validate options using Zod schema (validates autoRefresh and debounceMs) - const parseResult = parseSchema(ListChangedOptionsBaseSchema, options); + const parseResult = z.safeParse(ListChangedOptionsBaseSchema, options); if (!parseResult.success) { throw new Error(`Invalid ${listType} listChanged options: ${parseResult.error.message}`); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 63bd0034c..e4b744219 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,7 @@ export * from './shared/uriTemplate.js'; export * from './types/types.js'; export * from './util/inMemory.js'; export * from './util/schema.js'; +export * from './util/standardSchema.js'; // experimental exports export * from './experimental/index.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index dfa98a171..efdf9fe8b 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -57,8 +57,7 @@ import { SUPPORTED_PROTOCOL_VERSIONS, TaskStatusNotificationSchema } from '../types/types.js'; -import type { AnyObjectSchema, AnySchema, SchemaOutput } from '../util/schema.js'; -import { parseSchema } from '../util/schema.js'; +import * as z from 'zod/v4'; import type { ResponseMessage } from './responseMessage.js'; import type { Transport, TransportSendOptions } from './transport.js'; @@ -1060,7 +1059,7 @@ export abstract class Protocol { request: Request, resultSchema: T, options?: RequestOptions - ): AsyncGenerator>, void, void> { + ): AsyncGenerator>, void, void> { const { task } = options ?? {}; // For non-task requests, just yield the result @@ -1161,7 +1160,7 @@ export abstract class Protocol { const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {}; // Send the request - return new Promise>((resolve, reject) => { + return new Promise>((resolve, reject) => { const earlyReject = (error: unknown) => { reject(error); }; @@ -1258,9 +1257,9 @@ export abstract class Protocol { } try { - const parseResult = parseSchema(resultSchema, response.result); + const parseResult = z.safeParse(resultSchema, response.result); if (parseResult.success) { - resolve(parseResult.data as SchemaOutput); + resolve(parseResult.data as z.output); } else { reject(parseResult.error); } @@ -1328,7 +1327,7 @@ export abstract class Protocol { * * @experimental Use `client.experimental.tasks.getTaskResult()` to access this method. */ - protected async getTaskResult( + protected async getTaskResult( params: GetTaskPayloadRequest['params'], resultSchema: T, options?: RequestOptions diff --git a/packages/core/src/util/schema.ts b/packages/core/src/util/schema.ts index adecee361..fddd38877 100644 --- a/packages/core/src/util/schema.ts +++ b/packages/core/src/util/schema.ts @@ -1,14 +1,17 @@ +/** + * Internal Zod schema utilities for protocol handling. + * These are used internally by the SDK for protocol message validation. + */ + import * as z from 'zod/v4'; /** * Base type for any Zod schema. - * This is the canonical type to use when accepting user-provided schemas. */ export type AnySchema = z.core.$ZodType; /** - * A Zod schema for objects specifically (not unions). - * Use this when you need to constrain to ZodObject schemas. + * A Zod schema for objects specifically. */ export type AnyObjectSchema = z.core.$ZodObject; @@ -73,7 +76,6 @@ export function getSchemaDescription(schema: AnySchema): string | undefined { /** * Checks if a schema is optional (accepts undefined). - * Uses the public .type property which works in both zod/v4 and zod/v4/mini. */ export function isOptionalSchema(schema: AnySchema): boolean { const candidate = schema as { type?: string }; @@ -83,7 +85,6 @@ export function isOptionalSchema(schema: AnySchema): boolean { /** * Unwraps an optional schema to get the inner schema. * If the schema is not optional, returns it unchanged. - * Uses the public .def.innerType property which works in both zod/v4 and zod/v4/mini. */ export function unwrapOptionalSchema(schema: AnySchema): AnySchema { if (!isOptionalSchema(schema)) { diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts new file mode 100644 index 000000000..b7abcbd2c --- /dev/null +++ b/packages/core/src/util/standardSchema.ts @@ -0,0 +1,257 @@ +/** + * Standard JSON Schema utilities for user-provided schemas. + * These types and utilities support any schema library that implements + * the Standard Schema spec (https://standardschema.dev). + * + * Supported libraries include: Zod v4, Valibot, ArkType, and others. + */ + +import type { JsonSchemaType, jsonSchemaValidator } from '../validation/types.js'; + +// ============================================================================ +// Standard Schema Interfaces (from https://standardschema.dev) +// ============================================================================ +// These interfaces are copied from the Standard Schema spec to avoid adding +// a dependency. They match the @standard-schema/spec package. + +/** + * The base Standard interface for typed schemas. + * @see https://standardschema.dev + */ +export interface StandardTypedV1 { + readonly '~standard': StandardTypedV1.Props; +} + +export namespace StandardTypedV1 { + export interface Props { + readonly version: 1; + readonly vendor: string; + readonly types?: Types | undefined; + } + + export interface Types { + readonly input: Input; + readonly output: Output; + } + + export type InferInput = NonNullable['input']; + export type InferOutput = NonNullable['output']; +} + +/** + * The Standard Schema interface for schemas that support validation. + * @see https://standardschema.dev + */ +export interface StandardSchemaV1 { + readonly '~standard': StandardSchemaV1.Props; +} + +export namespace StandardSchemaV1 { + export interface Props extends StandardTypedV1.Props { + readonly validate: ( + value: unknown, + options?: Options | undefined + ) => Result | Promise>; + } + + export interface Options { + readonly libraryOptions?: Record | undefined; + } + + export type Result = SuccessResult | FailureResult; + + export interface SuccessResult { + readonly value: Output; + readonly issues?: undefined; + } + + export interface FailureResult { + readonly issues: ReadonlyArray; + } + + export interface Issue { + readonly message: string; + readonly path?: ReadonlyArray | undefined; + } + + export interface PathSegment { + readonly key: PropertyKey; + } + + export type InferInput = StandardTypedV1.InferInput; + export type InferOutput = StandardTypedV1.InferOutput; +} + +/** + * The Standard JSON Schema interface for schemas that can be converted to JSON Schema. + * This is the primary interface for user-provided tool and prompt schemas. + * @see https://standardschema.dev/json-schema + */ +export interface StandardJSONSchemaV1 { + readonly '~standard': StandardJSONSchemaV1.Props; +} + +export namespace StandardJSONSchemaV1 { + export interface Props extends StandardTypedV1.Props { + readonly jsonSchema: Converter; + } + + export interface Converter { + readonly input: (options: Options) => Record; + readonly output: (options: Options) => Record; + } + + export type Target = 'draft-2020-12' | 'draft-07' | 'openapi-3.0' | (object & string); + + export interface Options { + readonly target: Target; + readonly libraryOptions?: Record | undefined; + } + + export type InferInput = StandardTypedV1.InferInput; + export type InferOutput = StandardTypedV1.InferOutput; +} + +/** + * Combined interface for schemas that implement both StandardSchemaV1 and StandardJSONSchemaV1. + * Zod v4 schemas implement this combined interface. + */ +export interface StandardSchemaWithJSON { + readonly '~standard': StandardSchemaV1.Props & StandardJSONSchemaV1.Props; +} + +// ============================================================================ +// Type Guards +// ============================================================================ + +/** + * Type guard to check if a value implements StandardJSONSchemaV1 (has jsonSchema conversion). + * This is the primary interface for schemas that can be converted to JSON Schema for the wire protocol. + */ +export function isStandardJSONSchema(schema: unknown): schema is StandardJSONSchemaV1 { + return ( + schema != null && + typeof schema === 'object' && + '~standard' in schema && + typeof (schema as StandardJSONSchemaV1)['~standard']?.jsonSchema?.input === 'function' && + typeof (schema as StandardJSONSchemaV1)['~standard']?.jsonSchema?.output === 'function' + ); +} + +/** + * Type guard to check if a value implements StandardSchemaV1 (has validate method). + * Schemas that implement this interface can perform native validation. + */ +export function isStandardSchema(schema: unknown): schema is StandardSchemaV1 { + return ( + schema != null && + typeof schema === 'object' && + '~standard' in schema && + typeof (schema as StandardSchemaV1)['~standard']?.validate === 'function' + ); +} + +/** + * Type guard to check if a value implements both StandardSchemaV1 and StandardJSONSchemaV1. + * Zod v4 schemas implement this combined interface. + */ +export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSchemaWithJSON { + return isStandardJSONSchema(schema) && isStandardSchema(schema); +} + +// ============================================================================ +// JSON Schema Conversion +// ============================================================================ + +/** + * Converts a StandardJSONSchemaV1 to JSON Schema for the wire protocol. + * + * @param schema - A schema implementing StandardJSONSchemaV1 + * @param io - Whether to get the 'input' or 'output' JSON Schema (default: 'input') + * @returns JSON Schema object compatible with Draft 2020-12 + */ +export function standardSchemaToJsonSchema( + schema: StandardJSONSchemaV1, + io: 'input' | 'output' = 'input' +): Record { + return schema['~standard'].jsonSchema[io]({ target: 'draft-2020-12' }); +} + +// ============================================================================ +// Validation +// ============================================================================ + +/** + * Result type for Standard Schema validation. + */ +export type StandardSchemaValidationResult = { success: true; data: T } | { success: false; error: string }; + +/** + * Validates data against a StandardJSONSchemaV1 schema. + * + * If the schema also implements StandardSchemaV1 (has a validate method), uses native validation. + * Otherwise, falls back to JSON Schema validation using the provided validator. + * + * @param schema - A schema implementing StandardJSONSchemaV1 + * @param data - The data to validate + * @param jsonSchemaValidatorInstance - Optional JSON Schema validator for fallback validation + * @returns Validation result with typed data on success or error message on failure + */ +export async function validateStandardSchema( + schema: T, + data: unknown, + jsonSchemaValidatorInstance?: jsonSchemaValidator +): Promise>> { + // If schema also implements StandardSchemaV1, use native validation + if (isStandardSchema(schema)) { + const result = await schema['~standard'].validate(data); + if ('value' in result) { + return { success: true, data: result.value as StandardJSONSchemaV1.InferOutput }; + } + const errorMessage = result.issues.map((i: StandardSchemaV1.Issue) => i.message).join(', '); + return { success: false, error: errorMessage }; + } + + // Fall back to JSON Schema validation if validator provided + if (jsonSchemaValidatorInstance) { + const jsonSchema = standardSchemaToJsonSchema(schema, 'input'); + const validator = jsonSchemaValidatorInstance.getValidator>( + jsonSchema as JsonSchemaType + ); + const validationResult = validator(data); + + if (validationResult.valid) { + return { success: true, data: validationResult.data }; + } + return { success: false, error: validationResult.errorMessage ?? 'Validation failed' }; + } + + // No validation possible - schema doesn't have validate and no fallback validator + // In this case, we trust the data and return it as-is + return { success: true, data: data as StandardJSONSchemaV1.InferOutput }; +} + +// ============================================================================ +// Prompt Argument Extraction +// ============================================================================ + +/** + * Extracts prompt arguments from a StandardJSONSchemaV1 schema. + * Uses JSON Schema introspection to determine argument names, descriptions, and required status. + * + * @param schema - A schema implementing StandardJSONSchemaV1 + * @returns Array of prompt arguments with name, description, and required status + */ +export function promptArgumentsFromStandardSchema( + schema: StandardJSONSchemaV1 +): Array<{ name: string; description?: string; required: boolean }> { + const jsonSchema = standardSchemaToJsonSchema(schema, 'input'); + const properties = (jsonSchema.properties as Record) || {}; + const required = (jsonSchema.required as string[]) || []; + + return Object.entries(properties).map(([name, prop]) => ({ + name, + description: prop?.description, + required: required.includes(name) + })); +} diff --git a/packages/server/src/experimental/tasks/interfaces.ts b/packages/server/src/experimental/tasks/interfaces.ts index 31a84a0f3..ca3ce48e3 100644 --- a/packages/server/src/experimental/tasks/interfaces.ts +++ b/packages/server/src/experimental/tasks/interfaces.ts @@ -4,12 +4,12 @@ */ import type { - AnySchema, CallToolResult, CreateTaskResult, CreateTaskServerContext, GetTaskResult, Result, + StandardJSONSchemaV1, TaskServerContext } from '@modelcontextprotocol/core'; @@ -23,27 +23,25 @@ import type { BaseToolCallback } from '../../server/mcp.js'; * Handler for creating a task. * @experimental */ -export type CreateTaskRequestHandler = BaseToolCallback< - ResultT, - CreateTaskServerContext, - Args ->; +export type CreateTaskRequestHandler< + SendResultT extends Result, + Args extends StandardJSONSchemaV1 | undefined = undefined +> = BaseToolCallback; /** * Handler for task operations (get, getResult). * @experimental */ -export type TaskRequestHandler = BaseToolCallback< - ResultT, - TaskServerContext, - Args ->; +export type TaskRequestHandler< + SendResultT extends Result, + Args extends StandardJSONSchemaV1 | undefined = undefined +> = BaseToolCallback; /** * Interface for task-based tool handlers. * @experimental */ -export interface ToolTaskHandler { +export interface ToolTaskHandler { createTask: CreateTaskRequestHandler; getTask: TaskRequestHandler; getTaskResult: TaskRequestHandler; diff --git a/packages/server/src/experimental/tasks/mcpServer.ts b/packages/server/src/experimental/tasks/mcpServer.ts index ccf3962f0..f0118b17d 100644 --- a/packages/server/src/experimental/tasks/mcpServer.ts +++ b/packages/server/src/experimental/tasks/mcpServer.ts @@ -5,7 +5,7 @@ * @experimental */ -import type { AnySchema, TaskToolExecution, ToolAnnotations, ToolExecution } from '@modelcontextprotocol/core'; +import type { StandardJSONSchemaV1, TaskToolExecution, ToolAnnotations, ToolExecution } from '@modelcontextprotocol/core'; import type { AnyToolHandler, McpServer, RegisteredTool } from '../../server/mcp.js'; import type { ToolTaskHandler } from './interfaces.js'; @@ -19,12 +19,12 @@ interface McpServerInternal { name: string, title: string | undefined, description: string | undefined, - inputSchema: AnySchema | undefined, - outputSchema: AnySchema | undefined, + inputSchema: StandardJSONSchemaV1 | undefined, + outputSchema: StandardJSONSchemaV1 | undefined, annotations: ToolAnnotations | undefined, execution: ToolExecution | undefined, _meta: Record | undefined, - handler: AnyToolHandler + handler: AnyToolHandler ): RegisteredTool; } @@ -76,7 +76,7 @@ export class ExperimentalMcpServerTasks { * * @experimental */ - registerToolTask( + registerToolTask( name: string, config: { title?: string; @@ -89,7 +89,7 @@ export class ExperimentalMcpServerTasks { handler: ToolTaskHandler ): RegisteredTool; - registerToolTask( + registerToolTask( name: string, config: { title?: string; @@ -103,7 +103,10 @@ export class ExperimentalMcpServerTasks { handler: ToolTaskHandler ): RegisteredTool; - registerToolTask( + registerToolTask< + InputArgs extends StandardJSONSchemaV1 | undefined, + OutputArgs extends StandardJSONSchemaV1 | undefined + >( name: string, config: { title?: string; @@ -133,7 +136,7 @@ export class ExperimentalMcpServerTasks { config.annotations, execution, config._meta, - handler as AnyToolHandler + handler as AnyToolHandler ); } } diff --git a/packages/server/src/experimental/tasks/server.ts b/packages/server/src/experimental/tasks/server.ts index 813f4cf28..9c7a9ef8d 100644 --- a/packages/server/src/experimental/tasks/server.ts +++ b/packages/server/src/experimental/tasks/server.ts @@ -6,7 +6,6 @@ */ import type { - AnySchema, CancelTaskResult, GetTaskResult, ListTasksResult, @@ -16,6 +15,7 @@ import type { Result, SchemaOutput } from '@modelcontextprotocol/core'; +import type * as z from 'zod/v4'; import type { Server } from '../../server/server.js'; @@ -52,14 +52,14 @@ export class ExperimentalServerTasks { request: Request, resultSchema: T, options?: RequestOptions - ): AsyncGenerator & Result>, void, void> { + ): AsyncGenerator & Result>, void, void> { // Delegate to the server's underlying Protocol method type ServerWithRequestStream = { requestStream( request: Request, resultSchema: U, options?: RequestOptions - ): AsyncGenerator & Result>, void, void>; + ): AsyncGenerator & Result>, void, void>; }; return (this._server as unknown as ServerWithRequestStream).requestStream(request, resultSchema, options); } @@ -88,14 +88,18 @@ export class ExperimentalServerTasks { * * @experimental */ - async getTaskResult(taskId: string, resultSchema?: T, options?: RequestOptions): Promise> { + async getTaskResult( + taskId: string, + resultSchema?: T, + options?: RequestOptions + ): Promise> { return ( this._server as unknown as { - getTaskResult: ( + getTaskResult: ( params: { taskId: string }, resultSchema?: U, options?: RequestOptions - ) => Promise>; + ) => Promise>; } ).getTaskResult({ taskId }, resultSchema, options); } diff --git a/packages/server/src/server/completable.ts b/packages/server/src/server/completable.ts index 240e66e1f..b3cef81a6 100644 --- a/packages/server/src/server/completable.ts +++ b/packages/server/src/server/completable.ts @@ -1,27 +1,29 @@ -import type { AnySchema } from '@modelcontextprotocol/core'; -import type * as z from 'zod/v4'; +import type { StandardJSONSchemaV1 } from '@modelcontextprotocol/core'; export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable'); -export type CompleteCallback = ( - value: z.input, +export type CompleteCallback = ( + value: StandardJSONSchemaV1.InferInput, context?: { arguments?: Record; } -) => z.input[] | Promise[]>; +) => StandardJSONSchemaV1.InferInput[] | Promise[]>; -export type CompletableMeta = { +export type CompletableMeta = { complete: CompleteCallback; }; -export type CompletableSchema = T & { +export type CompletableSchema = T & { [COMPLETABLE_SYMBOL]: CompletableMeta; }; /** - * Wraps a Zod type to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP. + * Wraps a schema to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP. */ -export function completable(schema: T, complete: CompleteCallback): CompletableSchema { +export function completable( + schema: T, + complete: CompleteCallback +): CompletableSchema { Object.defineProperty(schema as object, COMPLETABLE_SYMBOL, { value: { complete } as CompletableMeta, enumerable: false, @@ -34,14 +36,14 @@ export function completable(schema: T, complete: CompleteCa /** * Checks if a schema is completable (has completion metadata). */ -export function isCompletable(schema: unknown): schema is CompletableSchema { +export function isCompletable(schema: unknown): schema is CompletableSchema { return !!schema && typeof schema === 'object' && COMPLETABLE_SYMBOL in (schema as object); } /** * Gets the completer callback from a completable schema, if it exists. */ -export function getCompleter(schema: T): CompleteCallback | undefined { +export function getCompleter(schema: T): CompleteCallback | undefined { const meta = (schema as unknown as { [COMPLETABLE_SYMBOL]?: CompletableMeta })[COMPLETABLE_SYMBOL]; return meta?.complete as CompleteCallback | undefined; } diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index e77e55d97..c92dce0f0 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1,5 +1,4 @@ import type { - AnySchema, BaseMetadata, CallToolRequest, CallToolResult, @@ -21,8 +20,8 @@ import type { Resource, ResourceTemplateReference, Result, - SchemaOutput, ServerContext, + StandardJSONSchemaV1, Tool, ToolAnnotations, ToolExecution, @@ -32,20 +31,18 @@ import type { import { assertCompleteRequestPrompt, assertCompleteRequestResourceTemplate, - getSchemaDescription, - getSchemaShape, - isOptionalSchema, - parseSchemaAsync, + promptArgumentsFromStandardSchema, ProtocolError, ProtocolErrorCode, - schemaToJson, - unwrapOptionalSchema, + standardSchemaToJsonSchema, UriTemplate, - validateAndWarnToolName + validateAndWarnToolName, + validateStandardSchema } from '@modelcontextprotocol/core'; import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js'; +import type { CompleteCallback } from './completable.js'; import { getCompleter, isCompletable } from './completable.js'; import type { ServerOptions } from './server.js'; import { Server } from './server.js'; @@ -132,7 +129,7 @@ export class McpServer { title: tool.title, description: tool.description, inputSchema: tool.inputSchema - ? (schemaToJson(tool.inputSchema, { io: 'input' }) as Tool['inputSchema']) + ? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema']) : EMPTY_OBJECT_JSON_SCHEMA, annotations: tool.annotations, execution: tool.execution, @@ -140,9 +137,10 @@ export class McpServer { }; if (tool.outputSchema) { - toolDefinition.outputSchema = schemaToJson(tool.outputSchema, { - io: 'output' - }) as Tool['outputSchema']; + toolDefinition.outputSchema = standardSchemaToJsonSchema( + tool.outputSchema, + 'output' + ) as Tool['outputSchema']; } return toolDefinition; @@ -162,7 +160,7 @@ export class McpServer { const isTaskRequest = !!request.params.task; const taskSupport = tool.execution?.taskSupport; - const isTaskHandler = 'createTask' in (tool.handler as AnyToolHandler); + const isTaskHandler = 'createTask' in (tool.handler as AnyToolHandler); // Validate task hint configuration if ((taskSupport === 'required' || taskSupport === 'optional') && !isTaskHandler) { @@ -230,23 +228,22 @@ export class McpServer { * Validates tool input arguments against the tool's input schema. */ private async validateToolInput< - Tool extends RegisteredTool, - Args extends Tool['inputSchema'] extends infer InputSchema - ? InputSchema extends AnySchema - ? SchemaOutput + ToolType extends RegisteredTool, + Args extends ToolType['inputSchema'] extends infer InputSchema + ? InputSchema extends StandardJSONSchemaV1 + ? StandardJSONSchemaV1.InferOutput : undefined : undefined - >(tool: Tool, args: Args, toolName: string): Promise { + >(tool: ToolType, args: Args, toolName: string): Promise { if (!tool.inputSchema) { return undefined as Args; } - const parseResult = await parseSchemaAsync(tool.inputSchema, args ?? {}); + const parseResult = await validateStandardSchema(tool.inputSchema, args ?? {}); if (!parseResult.success) { - const errorMessage = parseResult.error.issues.map((i: { message: string }) => i.message).join(', '); throw new ProtocolError( ProtocolErrorCode.InvalidParams, - `Input validation error: Invalid arguments for tool ${toolName}: ${errorMessage}` + `Input validation error: Invalid arguments for tool ${toolName}: ${parseResult.error}` ); } @@ -278,12 +275,11 @@ export class McpServer { } // if the tool has an output schema, validate structured content - const parseResult = await parseSchemaAsync(tool.outputSchema, result.structuredContent); + const parseResult = await validateStandardSchema(tool.outputSchema, result.structuredContent); if (!parseResult.success) { - const errorMessage = parseResult.error.issues.map((i: { message: string }) => i.message).join(', '); throw new ProtocolError( ProtocolErrorCode.InvalidParams, - `Output validation error: Invalid structured content for tool ${toolName}: ${errorMessage}` + `Output validation error: Invalid structured content for tool ${toolName}: ${parseResult.error}` ); } } @@ -374,20 +370,12 @@ export class McpServer { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} disabled`); } - if (!prompt.argsSchema) { - return EMPTY_COMPLETION_RESULT; - } - - const promptShape = getSchemaShape(prompt.argsSchema); - const field = promptShape?.[request.params.argument.name]; - if (!isCompletable(field)) { - return EMPTY_COMPLETION_RESULT; - } - - const completer = getCompleter(field); + // Look up completer from the stored completers map + const completer = prompt.completers?.get(request.params.argument.name); if (!completer) { return EMPTY_COMPLETION_RESULT; } + const suggestions = await completer(request.params.argument.value, request.params.context); return createCompletionResult(suggestions); } @@ -676,17 +664,21 @@ export class McpServer { name: string, title: string | undefined, description: string | undefined, - argsSchema: AnySchema | undefined, - callback: PromptCallback + argsSchema: StandardJSONSchemaV1 | undefined, + callback: PromptCallback ): RegisteredPrompt { // Track current schema and callback for handler regeneration let currentArgsSchema = argsSchema; let currentCallback = callback; + // Extract completable callbacks from schema at registration time + const completers = extractCompleters(argsSchema); + const registeredPrompt: RegisteredPrompt = { title, description, argsSchema, + completers, handler: createPromptHandler(name, argsSchema, callback), enabled: true, disable: () => registeredPrompt.update({ enabled: false }), @@ -705,10 +697,12 @@ export class McpServer { if (updates.argsSchema !== undefined) { registeredPrompt.argsSchema = updates.argsSchema; currentArgsSchema = updates.argsSchema; + // Re-extract completers when schema changes + registeredPrompt.completers = extractCompleters(updates.argsSchema); needsHandlerRegen = true; } if (updates.callback !== undefined) { - currentCallback = updates.callback as PromptCallback; + currentCallback = updates.callback as PromptCallback; needsHandlerRegen = true; } if (needsHandlerRegen) { @@ -721,18 +715,9 @@ export class McpServer { }; this._registeredPrompts[name] = registeredPrompt; - // If any argument uses a Completable schema, enable completions capability - if (argsSchema) { - const shape = getSchemaShape(argsSchema); - if (shape) { - const hasCompletable = Object.values(shape).some(field => { - const inner = unwrapOptionalSchema(field); - return isCompletable(inner); - }); - if (hasCompletable) { - this.setCompletionRequestHandler(); - } - } + // Enable completions capability if any completers were found + if (completers && completers.size > 0) { + this.setCompletionRequestHandler(); } return registeredPrompt; @@ -742,12 +727,12 @@ export class McpServer { name: string, title: string | undefined, description: string | undefined, - inputSchema: AnySchema | undefined, - outputSchema: AnySchema | undefined, + inputSchema: StandardJSONSchemaV1 | undefined, + outputSchema: StandardJSONSchemaV1 | undefined, annotations: ToolAnnotations | undefined, execution: ToolExecution | undefined, _meta: Record | undefined, - handler: AnyToolHandler + handler: AnyToolHandler ): RegisteredTool { // Validate tool name according to SEP specification validateAndWarnToolName(name); @@ -788,7 +773,7 @@ export class McpServer { } if (updates.callback !== undefined) { registeredTool.handler = updates.callback; - currentHandler = updates.callback as AnyToolHandler; + currentHandler = updates.callback as AnyToolHandler; needsExecutorRegen = true; } if (needsExecutorRegen) { @@ -813,7 +798,10 @@ export class McpServer { /** * Registers a tool with a config object and callback. */ - registerTool( + registerTool< + OutputArgs extends StandardJSONSchemaV1, + InputArgs extends StandardJSONSchemaV1 | undefined = undefined + >( name: string, config: { title?: string; @@ -840,14 +828,14 @@ export class McpServer { annotations, { taskSupport: 'forbidden' }, _meta, - cb as ToolCallback + cb as ToolCallback ); } /** * Registers a prompt with a config object and callback. */ - registerPrompt( + registerPrompt( name: string, config: { title?: string; @@ -867,7 +855,7 @@ export class McpServer { title, description, argsSchema, - cb as PromptCallback + cb as PromptCallback ); this.setPromptRequestHandlers(); @@ -980,19 +968,25 @@ export class ResourceTemplate { } } -export type BaseToolCallback = Args extends AnySchema - ? (args: SchemaOutput, ctx: Ctx) => ResultT | Promise - : (ctx: Ctx) => ResultT | Promise; +export type BaseToolCallback< + SendResultT extends Result, + Ctx extends ServerContext, + Args extends StandardJSONSchemaV1 | undefined +> = Args extends StandardJSONSchemaV1 + ? (args: StandardJSONSchemaV1.InferOutput, ctx: Ctx) => SendResultT | Promise + : (ctx: Ctx) => SendResultT | Promise; /** * Callback for a tool handler registered with Server.tool(). */ -export type ToolCallback = BaseToolCallback; +export type ToolCallback = BaseToolCallback; /** * Supertype that can handle both regular tools (simple callback) and task-based tools (task handler object). */ -export type AnyToolHandler = ToolCallback | ToolTaskHandler; +export type AnyToolHandler = + | ToolCallback + | ToolTaskHandler; /** * Internal executor type that encapsulates handler invocation with proper types. @@ -1002,12 +996,12 @@ type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; - handler: AnyToolHandler; + handler: AnyToolHandler; /** @internal */ executor: ToolExecutor; enabled: boolean; @@ -1017,11 +1011,11 @@ export type RegisteredTool = { name?: string | null; title?: string; description?: string; - paramsSchema?: AnySchema; - outputSchema?: AnySchema; + paramsSchema?: StandardJSONSchemaV1; + outputSchema?: StandardJSONSchemaV1; annotations?: ToolAnnotations; _meta?: Record; - callback?: ToolCallback; + callback?: ToolCallback; enabled?: boolean; }): void; remove(): void; @@ -1032,7 +1026,10 @@ export type RegisteredTool = { * When inputSchema is defined, the handler is called with (args, ctx). * When inputSchema is undefined, the handler is called with just (ctx). */ -function createToolExecutor(inputSchema: AnySchema | undefined, handler: AnyToolHandler): ToolExecutor { +function createToolExecutor( + inputSchema: StandardJSONSchemaV1 | undefined, + handler: AnyToolHandler +): ToolExecutor { const isTaskHandler = 'createTask' in handler; if (isTaskHandler) { @@ -1127,8 +1124,8 @@ export type RegisteredResourceTemplate = { remove(): void; }; -export type PromptCallback = Args extends AnySchema - ? (args: SchemaOutput, ctx: ServerContext) => GetPromptResult | Promise +export type PromptCallback = Args extends StandardJSONSchemaV1 + ? (args: StandardJSONSchemaV1.InferOutput, ctx: ServerContext) => GetPromptResult | Promise : (ctx: ServerContext) => GetPromptResult | Promise; /** @@ -1146,13 +1143,15 @@ type TaskHandlerInternal = { export type RegisteredPrompt = { title?: string; description?: string; - argsSchema?: AnySchema; + argsSchema?: StandardJSONSchemaV1; + /** @internal Completable callbacks keyed by argument name */ + completers?: Map>; /** @internal */ handler: PromptHandler; enabled: boolean; enable(): void; disable(): void; - update(updates: { + update(updates: { name?: string | null; title?: string; description?: string; @@ -1169,19 +1168,18 @@ export type RegisteredPrompt = { */ function createPromptHandler( name: string, - argsSchema: AnySchema | undefined, - callback: PromptCallback + argsSchema: StandardJSONSchemaV1 | undefined, + callback: PromptCallback ): PromptHandler { if (argsSchema) { - const typedCallback = callback as (args: SchemaOutput, ctx: ServerContext) => GetPromptResult | Promise; + const typedCallback = callback as (args: unknown, ctx: ServerContext) => GetPromptResult | Promise; return async (args, ctx) => { - const parseResult = await parseSchemaAsync(argsSchema, args); + const parseResult = await validateStandardSchema(argsSchema, args); if (!parseResult.success) { - const errorMessage = parseResult.error.issues.map((i: { message: string }) => i.message).join(', '); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid arguments for prompt ${name}: ${errorMessage}`); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid arguments for prompt ${name}: ${parseResult.error}`); } - return typedCallback(parseResult.data as SchemaOutput, ctx); + return typedCallback(parseResult.data, ctx); }; } else { const typedCallback = callback as (ctx: ServerContext) => GetPromptResult | Promise; @@ -1192,16 +1190,8 @@ function createPromptHandler( } } -function promptArgumentsFromSchema(schema: AnySchema): PromptArgument[] { - const shape = getSchemaShape(schema); - if (!shape) return []; - return Object.entries(shape).map(([name, field]): PromptArgument => { - return { - name, - description: getSchemaDescription(field), - required: !isOptionalSchema(field) - }; - }); +function promptArgumentsFromSchema(schema: StandardJSONSchemaV1): PromptArgument[] { + return promptArgumentsFromStandardSchema(schema); } function createCompletionResult(suggestions: readonly unknown[]): CompleteResult { @@ -1221,3 +1211,63 @@ const EMPTY_COMPLETION_RESULT: CompleteResult = { hasMore: false } }; + +// ============================================================================ +// Zod-specific helpers for Completable feature +// These are internal helpers for the completable prompt argument feature +// which requires Zod-specific schema introspection. +// ============================================================================ + +/** @internal Zod schema shape type for completable introspection */ +type ZodSchemaShape = Record; + +/** @internal Gets the shape of a Zod object schema */ +function getZodSchemaShape(schema: unknown): ZodSchemaShape | undefined { + const candidate = schema as { shape?: unknown }; + if (candidate.shape && typeof candidate.shape === 'object') { + return candidate.shape as ZodSchemaShape; + } + return undefined; +} + +/** @internal Checks if a Zod schema is optional */ +function isZodOptionalSchema(schema: unknown): boolean { + const candidate = schema as { type?: string }; + return candidate.type === 'optional'; +} + +/** @internal Unwraps an optional Zod schema */ +function unwrapZodOptionalSchema(schema: unknown): unknown { + if (!isZodOptionalSchema(schema)) { + return schema; + } + const candidate = schema as { def?: { innerType?: unknown } }; + return candidate.def?.innerType ?? schema; +} + +/** + * @internal Extracts completable callbacks from a schema at registration time. + * This allows completion to work without runtime Zod introspection. + */ +function extractCompleters( + schema: StandardJSONSchemaV1 | undefined +): Map> | undefined { + if (!schema) return undefined; + + const shape = getZodSchemaShape(schema); + if (!shape) return undefined; + + const completers = new Map>(); + + for (const [argName, field] of Object.entries(shape)) { + const inner = unwrapZodOptionalSchema(field); + if (isCompletable(inner)) { + const callback = getCompleter(inner); + if (callback) { + completers.set(argName, callback as unknown as CompleteCallback); + } + } + } + + return completers.size > 0 ? completers : undefined; +} diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 613766a58..512284ba0 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -46,13 +46,13 @@ import { ListRootsResultSchema, LoggingLevelSchema, mergeCapabilities, - parseSchema, Protocol, ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; +import * as z from 'zod/v4'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; @@ -119,7 +119,7 @@ export class Server extends Protocol { const transportSessionId: string | undefined = ctx.sessionId || (ctx.http?.req?.headers.get('mcp-session-id') as string) || undefined; const { level } = request.params; - const parseResult = parseSchema(LoggingLevelSchema, level); + const parseResult = z.safeParse(LoggingLevelSchema, level); if (parseResult.success) { this._loggingLevels.set(transportSessionId, parseResult.data); } @@ -213,7 +213,7 @@ export class Server extends Protocol { // When task creation is requested, validate and return CreateTaskResult if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); + const taskValidationResult = z.safeParse(CreateTaskResultSchema, result); if (!taskValidationResult.success) { const errorMessage = taskValidationResult.error instanceof Error @@ -225,7 +225,7 @@ export class Server extends Protocol { } // For non-task requests, validate against CallToolResultSchema - const validationResult = parseSchema(CallToolResultSchema, result); + const validationResult = z.safeParse(CallToolResultSchema, result); if (!validationResult.success) { const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); From c0c5241a0b19a5e27dfc9abd2ef15256a3fbc9c9 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 3 Feb 2026 20:23:01 +0000 Subject: [PATCH 02/12] refactor: use schema utility functions instead of direct zod imports - Replace z.safeParse with parseSchema utility in client.ts and protocol.ts - Replace z.core.$ZodType with AnySchema alias - Replace z.core.$ZodObject with AnyObjectSchema alias - Replace z.output with SchemaOutput alias - Remove direct zod import from protocol.ts (now uses schema utilities) --- packages/client/src/client/client.ts | 12 ++++++------ packages/core/src/shared/protocol.ts | 13 +++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index ca83d3427..7969b1ac7 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -56,6 +56,7 @@ import { ListResourceTemplatesResultSchema, ListToolsResultSchema, mergeCapabilities, + parseSchema, Protocol, ProtocolError, ProtocolErrorCode, @@ -63,7 +64,6 @@ import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; -import * as z from 'zod/v4'; import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; @@ -319,7 +319,7 @@ export class Client extends Protocol { // When task creation is requested, validate and return CreateTaskResult if (params.task) { - const taskValidationResult = z.safeParse(CreateTaskResultSchema, result); + const taskValidationResult = parseSchema(CreateTaskResultSchema, result); if (!taskValidationResult.success) { const errorMessage = taskValidationResult.error instanceof Error @@ -331,7 +331,7 @@ export class Client extends Protocol { } // For non-task requests, validate against ElicitResultSchema - const validationResult = z.safeParse(ElicitResultSchema, result); + const validationResult = parseSchema(ElicitResultSchema, result); if (!validationResult.success) { // Type guard: if success is false, error is guaranteed to exist const errorMessage = @@ -378,7 +378,7 @@ export class Client extends Protocol { // When task creation is requested, validate and return CreateTaskResult if (params.task) { - const taskValidationResult = z.safeParse(CreateTaskResultSchema, result); + const taskValidationResult = parseSchema(CreateTaskResultSchema, result); if (!taskValidationResult.success) { const errorMessage = taskValidationResult.error instanceof Error @@ -392,7 +392,7 @@ export class Client extends Protocol { // For non-task requests, validate against appropriate schema based on tools presence const hasTools = params.tools || params.toolChoice; const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; - const validationResult = z.safeParse(resultSchema, result); + const validationResult = parseSchema(resultSchema, result); if (!validationResult.success) { const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); @@ -845,7 +845,7 @@ export class Client extends Protocol { fetcher: () => Promise ): void { // Validate options using Zod schema (validates autoRefresh and debounceMs) - const parseResult = z.safeParse(ListChangedOptionsBaseSchema, options); + const parseResult = parseSchema(ListChangedOptionsBaseSchema, options); if (!parseResult.success) { throw new Error(`Invalid ${listType} listChanged options: ${parseResult.error.message}`); } diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index efdf9fe8b..f6413d745 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1,6 +1,8 @@ import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; import type { CreateTaskOptions, QueuedMessage, TaskMessageQueue, TaskStore } from '../experimental/tasks/interfaces.js'; import { isTerminal } from '../experimental/tasks/interfaces.js'; +import type { AnyObjectSchema, AnySchema, SchemaOutput } from '../util/schema.js'; +import { parseSchema } from '../util/schema.js'; import type { AuthInfo, CancelledNotification, @@ -57,7 +59,6 @@ import { SUPPORTED_PROTOCOL_VERSIONS, TaskStatusNotificationSchema } from '../types/types.js'; -import * as z from 'zod/v4'; import type { ResponseMessage } from './responseMessage.js'; import type { Transport, TransportSendOptions } from './transport.js'; @@ -1059,7 +1060,7 @@ export abstract class Protocol { request: Request, resultSchema: T, options?: RequestOptions - ): AsyncGenerator>, void, void> { + ): AsyncGenerator>, void, void> { const { task } = options ?? {}; // For non-task requests, just yield the result @@ -1160,7 +1161,7 @@ export abstract class Protocol { const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {}; // Send the request - return new Promise>((resolve, reject) => { + return new Promise>((resolve, reject) => { const earlyReject = (error: unknown) => { reject(error); }; @@ -1257,9 +1258,9 @@ export abstract class Protocol { } try { - const parseResult = z.safeParse(resultSchema, response.result); + const parseResult = parseSchema(resultSchema, response.result); if (parseResult.success) { - resolve(parseResult.data as z.output); + resolve(parseResult.data as SchemaOutput); } else { reject(parseResult.error); } @@ -1327,7 +1328,7 @@ export abstract class Protocol { * * @experimental Use `client.experimental.tasks.getTaskResult()` to access this method. */ - protected async getTaskResult( + protected async getTaskResult( params: GetTaskPayloadRequest['params'], resultSchema: T, options?: RequestOptions From a075e1de93f15b1f6ddbcbfae278d6014ccd883b Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 3 Feb 2026 20:29:59 +0000 Subject: [PATCH 03/12] feat: add ArkType and Valibot examples for Standard Schema support - Add arktypeExample.ts demonstrating ArkType schema validation - Add valibotExample.ts demonstrating Valibot schema validation - Fix type guards to accept function-based schemas (ArkType uses functions) - Fix validation result handling to check issues array first (Valibot compatibility) - Add arktype, valibot, and @valibot/to-json-schema dependencies Both examples demonstrate: - Tool registration with inputSchema and outputSchema - Native validation via ~standard.validate() - JSON Schema conversion via ~standard.jsonSchema.input() --- examples/server/package.json | 7 +- examples/server/src/arktypeExample.ts | 153 +++++++++++++++++++++ examples/server/src/valibotExample.ts | 162 +++++++++++++++++++++++ packages/core/src/util/standardSchema.ts | 38 +++--- pnpm-lock.yaml | 58 ++++++++ 5 files changed, 399 insertions(+), 19 deletions(-) create mode 100644 examples/server/src/arktypeExample.ts create mode 100644 examples/server/src/valibotExample.ts diff --git a/examples/server/package.json b/examples/server/package.json index f86cc1375..2a0df7473 100644 --- a/examples/server/package.json +++ b/examples/server/package.json @@ -36,14 +36,17 @@ "dependencies": { "@hono/node-server": "catalog:runtimeServerOnly", "@modelcontextprotocol/examples-shared": "workspace:^", - "@modelcontextprotocol/node": "workspace:^", - "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/express": "workspace:^", "@modelcontextprotocol/hono": "workspace:^", + "@modelcontextprotocol/node": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", + "@valibot/to-json-schema": "^1.5.0", + "arktype": "^2.1.29", "better-auth": "^1.4.17", "cors": "catalog:runtimeServerOnly", "express": "catalog:runtimeServerOnly", "hono": "catalog:runtimeServerOnly", + "valibot": "^1.2.0", "zod": "catalog:runtimeShared" }, "devDependencies": { diff --git a/examples/server/src/arktypeExample.ts b/examples/server/src/arktypeExample.ts new file mode 100644 index 000000000..3a6e56a48 --- /dev/null +++ b/examples/server/src/arktypeExample.ts @@ -0,0 +1,153 @@ +#!/usr/bin/env node +/** + * Example MCP server using ArkType for schema validation + * This demonstrates how to use ArkType schemas with the MCP SDK's + * StandardJSONSchemaV1 support for tool input/output schemas. + * + * ArkType implements the Standard Schema spec and provides built-in + * JSON Schema conversion via toJsonSchema(). + */ + +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import { type } from 'arktype'; + +const server = new McpServer({ + name: 'mcp-arktype-example', + version: '1.0.0' +}); + +// Define schemas using ArkType +const weatherInputSchema = type({ + city: 'string', + country: 'string' +}); + +const weatherOutputSchema = type({ + temperature: { + celsius: 'number', + fahrenheit: 'number' + }, + conditions: "'sunny' | 'cloudy' | 'rainy' | 'stormy' | 'snowy'", + humidity: 'number', + wind: { + speed_kmh: 'number', + direction: 'string' + } +}); + +// Register a tool with ArkType schemas +server.registerTool( + 'get_weather', + { + description: 'Get weather information for a city (using ArkType validation)', + inputSchema: weatherInputSchema, + outputSchema: weatherOutputSchema + }, + async ({ city, country }) => { + console.error(`Getting weather for ${city}, ${country}`); + + // Simulate weather API call + const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; + const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][ + Math.floor(Math.random() * 5) + ] as 'sunny' | 'cloudy' | 'rainy' | 'stormy' | 'snowy'; + + const structuredContent = { + temperature: { + celsius: temp_c, + fahrenheit: Math.round(((temp_c * 9) / 5 + 32) * 10) / 10 + }, + conditions, + humidity: Math.round(Math.random() * 100), + wind: { + speed_kmh: Math.round(Math.random() * 50), + direction: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)]! + } + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(structuredContent, null, 2) + } + ], + structuredContent + }; + } +); + +// Another tool example - calculator +const calcInputSchema = type({ + operation: "'add' | 'subtract' | 'multiply' | 'divide'", + a: 'number', + b: 'number' +}); + +const calcOutputSchema = type({ + result: 'number', + operation: 'string', + expression: 'string' +}); + +server.registerTool( + 'calculate', + { + description: 'Perform basic arithmetic operations (using ArkType validation)', + inputSchema: calcInputSchema, + outputSchema: calcOutputSchema + }, + async ({ operation, a, b }) => { + let result: number; + switch (operation) { + case 'add': + result = a + b; + break; + case 'subtract': + result = a - b; + break; + case 'multiply': + result = a * b; + break; + case 'divide': + if (b === 0) { + return { + content: [{ type: 'text', text: 'Error: Division by zero' }], + isError: true + }; + } + result = a / b; + break; + } + + const structuredContent = { + result, + operation, + expression: `${a} ${operation} ${b} = ${result}` + }; + + return { + content: [ + { + type: 'text', + text: structuredContent.expression + } + ], + structuredContent + }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('ArkType Example Server running on stdio'); +} + +try { + await main(); +} catch (error) { + console.error('Server error:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/server/src/valibotExample.ts b/examples/server/src/valibotExample.ts new file mode 100644 index 000000000..188b54d46 --- /dev/null +++ b/examples/server/src/valibotExample.ts @@ -0,0 +1,162 @@ +#!/usr/bin/env node +/** + * Example MCP server using Valibot for schema validation + * This demonstrates how to use Valibot schemas with the MCP SDK's + * StandardJSONSchemaV1 support for tool input/output schemas. + * + * Valibot implements the Standard Schema spec. Use toStandardJsonSchema() + * from @valibot/to-json-schema to create StandardJSONSchemaV1-compliant schemas. + */ + +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import * as v from 'valibot'; + +const server = new McpServer({ + name: 'mcp-valibot-example', + version: '1.0.0' +}); + +// Define schemas using Valibot and wrap with toStandardJsonSchema +const weatherInputSchema = toStandardJsonSchema( + v.object({ + city: v.pipe(v.string(), v.description('City name')), + country: v.pipe(v.string(), v.description('Country code (e.g., US, UK)')) + }) +); + +const weatherOutputSchema = toStandardJsonSchema( + v.object({ + temperature: v.object({ + celsius: v.number(), + fahrenheit: v.number() + }), + conditions: v.picklist(['sunny', 'cloudy', 'rainy', 'stormy', 'snowy']), + humidity: v.pipe(v.number(), v.minValue(0), v.maxValue(100)), + wind: v.object({ + speed_kmh: v.number(), + direction: v.string() + }) + }) +); + +// Register a tool with Valibot schemas +server.registerTool( + 'get_weather', + { + description: 'Get weather information for a city (using Valibot validation)', + inputSchema: weatherInputSchema, + outputSchema: weatherOutputSchema + }, + async ({ city, country }) => { + console.error(`Getting weather for ${city}, ${country}`); + + // Simulate weather API call + const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; + const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][ + Math.floor(Math.random() * 5) + ] as 'sunny' | 'cloudy' | 'rainy' | 'stormy' | 'snowy'; + + const structuredContent = { + temperature: { + celsius: temp_c, + fahrenheit: Math.round(((temp_c * 9) / 5 + 32) * 10) / 10 + }, + conditions, + humidity: Math.round(Math.random() * 100), + wind: { + speed_kmh: Math.round(Math.random() * 50), + direction: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)]! + } + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(structuredContent, null, 2) + } + ], + structuredContent + }; + } +); + +// Another tool example - calculator +const calcInputSchema = toStandardJsonSchema( + v.object({ + operation: v.picklist(['add', 'subtract', 'multiply', 'divide']), + a: v.number(), + b: v.number() + }) +); + +const calcOutputSchema = toStandardJsonSchema( + v.object({ + result: v.number(), + operation: v.string(), + expression: v.string() + }) +); + +server.registerTool( + 'calculate', + { + description: 'Perform basic arithmetic operations (using Valibot validation)', + inputSchema: calcInputSchema, + outputSchema: calcOutputSchema + }, + async ({ operation, a, b }) => { + let result: number; + switch (operation) { + case 'add': + result = a + b; + break; + case 'subtract': + result = a - b; + break; + case 'multiply': + result = a * b; + break; + case 'divide': + if (b === 0) { + return { + content: [{ type: 'text', text: 'Error: Division by zero' }], + isError: true + }; + } + result = a / b; + break; + } + + const structuredContent = { + result, + operation, + expression: `${a} ${operation} ${b} = ${result}` + }; + + return { + content: [ + { + type: 'text', + text: structuredContent.expression + } + ], + structuredContent + }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Valibot Example Server running on stdio'); +} + +try { + await main(); +} catch (error) { + console.error('Server error:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index b7abcbd2c..a3934994a 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -127,28 +127,29 @@ export interface StandardSchemaWithJSON { /** * Type guard to check if a value implements StandardJSONSchemaV1 (has jsonSchema conversion). * This is the primary interface for schemas that can be converted to JSON Schema for the wire protocol. + * Note: Some libraries (e.g., ArkType) use function-based schemas, so we check for both objects and functions. */ export function isStandardJSONSchema(schema: unknown): schema is StandardJSONSchemaV1 { - return ( - schema != null && - typeof schema === 'object' && - '~standard' in schema && - typeof (schema as StandardJSONSchemaV1)['~standard']?.jsonSchema?.input === 'function' && - typeof (schema as StandardJSONSchemaV1)['~standard']?.jsonSchema?.output === 'function' - ); + if (schema == null) return false; + const schemaType = typeof schema; + if (schemaType !== 'object' && schemaType !== 'function') return false; + if (!('~standard' in (schema as object))) return false; + const std = (schema as StandardJSONSchemaV1)['~standard']; + return typeof std?.jsonSchema?.input === 'function' && typeof std?.jsonSchema?.output === 'function'; } /** * Type guard to check if a value implements StandardSchemaV1 (has validate method). * Schemas that implement this interface can perform native validation. + * Note: Some libraries (e.g., ArkType) use function-based schemas, so we check for both objects and functions. */ export function isStandardSchema(schema: unknown): schema is StandardSchemaV1 { - return ( - schema != null && - typeof schema === 'object' && - '~standard' in schema && - typeof (schema as StandardSchemaV1)['~standard']?.validate === 'function' - ); + if (schema == null) return false; + const schemaType = typeof schema; + if (schemaType !== 'object' && schemaType !== 'function') return false; + if (!('~standard' in (schema as object))) return false; + const std = (schema as StandardSchemaV1)['~standard']; + return typeof std?.validate === 'function'; } /** @@ -205,11 +206,14 @@ export async function validateStandardSchema( // If schema also implements StandardSchemaV1, use native validation if (isStandardSchema(schema)) { const result = await schema['~standard'].validate(data); - if ('value' in result) { - return { success: true, data: result.value as StandardJSONSchemaV1.InferOutput }; + // Per Standard Schema spec: FailureResult has issues array, SuccessResult has value without issues + // Some libraries (e.g., Valibot) always include value, so we check issues first + if (result.issues && result.issues.length > 0) { + const errorMessage = result.issues.map((i: StandardSchemaV1.Issue) => i.message).join(', '); + return { success: false, error: errorMessage }; } - const errorMessage = result.issues.map((i: StandardSchemaV1.Issue) => i.message).join(', '); - return { success: false, error: errorMessage }; + // At this point we have a SuccessResult which has value + return { success: true, data: (result as StandardSchemaV1.SuccessResult).value as StandardJSONSchemaV1.InferOutput }; } // Fall back to JSON Schema validation if validator provided diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e51ed15ed..99b9e73ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -332,6 +332,12 @@ importers: '@modelcontextprotocol/server': specifier: workspace:^ version: link:../../packages/server + '@valibot/to-json-schema': + specifier: ^1.5.0 + version: 1.5.0(valibot@1.2.0(typescript@5.9.3)) + arktype: + specifier: ^2.1.29 + version: 2.1.29 better-auth: specifier: ^1.4.17 version: 1.4.17(better-sqlite3@12.6.2)(vitest@4.0.16(@types/node@24.10.4)(tsx@4.21.0)(yaml@2.8.2)) @@ -344,6 +350,9 @@ importers: hono: specifier: catalog:runtimeServerOnly version: 4.11.4 + valibot: + specifier: ^1.2.0 + version: 1.2.0(typescript@5.9.3) zod: specifier: catalog:runtimeShared version: 4.3.5 @@ -940,6 +949,12 @@ importers: packages: + '@ark/schema@0.56.0': + resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==} + + '@ark/util@0.56.0': + resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==} + '@babel/generator@7.28.5': resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} @@ -2352,6 +2367,11 @@ packages: cpu: [x64] os: [win32] + '@valibot/to-json-schema@1.5.0': + resolution: {integrity: sha512-GE7DmSr1C2UCWPiV0upRH6mv0cCPsqYGs819fb6srCS1tWhyXrkGGe+zxUiwzn/L1BOfADH4sNjY/YHCuP8phQ==} + peerDependencies: + valibot: ^1.2.0 + '@vitest/expect@4.0.16': resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} @@ -2431,6 +2451,12 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + arkregex@0.0.5: + resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==} + + arktype@2.1.29: + resolution: {integrity: sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ==} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -4340,6 +4366,14 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -4532,6 +4566,12 @@ packages: snapshots: + '@ark/schema@0.56.0': + dependencies: + '@ark/util': 0.56.0 + + '@ark/util@0.56.0': {} + '@babel/generator@7.28.5': dependencies: '@babel/parser': 7.28.5 @@ -5709,6 +5749,10 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3))': + dependencies: + valibot: 1.2.0(typescript@5.9.3) + '@vitest/expect@4.0.16': dependencies: '@standard-schema/spec': 1.1.0 @@ -5793,6 +5837,16 @@ snapshots: argparse@2.0.1: {} + arkregex@0.0.5: + dependencies: + '@ark/util': 0.56.0 + + arktype@2.1.29: + dependencies: + '@ark/schema': 0.56.0 + '@ark/util': 0.56.0 + arkregex: 0.0.5 + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -7934,6 +7988,10 @@ snapshots: util-deprecate@1.0.2: {} + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + vary@1.1.2: {} vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(tsx@4.21.0)(yaml@2.8.2)): From b93a01e34a3bcb6a79cd5221059310b41f5e1b1b Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 3 Feb 2026 20:35:39 +0000 Subject: [PATCH 04/12] test: add comprehensive integration tests for Standard Schema support Tests cover: - ArkType tool registration (input/output schemas) - Valibot tool registration (with descriptions) - Tool validation with valid/invalid input for both libraries - Mixed schema libraries in the same server (Zod + ArkType + Valibot) - Prompt completions with Zod completable - Error message quality from all three libraries - Type inference verification All libraries produce clear, user-friendly error messages: - ArkType: "age must be a number (was a string)" - Valibot: "Invalid type: Expected string but received 123" - Zod: "Invalid input: expected string, received number" --- pnpm-lock.yaml | 9 + test/integration/package.json | 21 +- test/integration/test/standardSchema.test.ts | 716 +++++++++++++++++++ 3 files changed, 737 insertions(+), 9 deletions(-) create mode 100644 test/integration/test/standardSchema.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99b9e73ce..ef18a478b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -934,9 +934,18 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@valibot/to-json-schema': + specifier: ^1.5.0 + version: 1.5.0(valibot@1.2.0(typescript@5.9.3)) + arktype: + specifier: ^2.1.29 + version: 2.1.29 supertest: specifier: catalog:devTools version: 7.1.4 + valibot: + specifier: ^1.2.0 + version: 1.2.0(typescript@5.9.3) vitest: specifier: catalog:devTools version: 4.0.16(@types/node@24.10.4)(tsx@4.21.0)(yaml@2.8.2) diff --git a/test/integration/package.json b/test/integration/package.json index f4b97e4ca..d54c95b50 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -32,19 +32,22 @@ "client": "tsx scripts/cli.ts client" }, "devDependencies": { - "@modelcontextprotocol/core": "workspace:^", + "@cfworker/json-schema": "catalog:runtimeShared", "@modelcontextprotocol/client": "workspace:^", - "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", "@modelcontextprotocol/express": "workspace:^", "@modelcontextprotocol/node": "workspace:^", - "@cfworker/json-schema": "catalog:runtimeShared", - "zod": "catalog:runtimeShared", - "vitest": "catalog:devTools", - "supertest": "catalog:devTools", - "wrangler": "catalog:devTools", + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/test-helpers": "workspace:^" + "@valibot/to-json-schema": "^1.5.0", + "arktype": "^2.1.29", + "supertest": "catalog:devTools", + "valibot": "^1.2.0", + "vitest": "catalog:devTools", + "wrangler": "catalog:devTools", + "zod": "catalog:runtimeShared" } } diff --git a/test/integration/test/standardSchema.test.ts b/test/integration/test/standardSchema.test.ts new file mode 100644 index 000000000..f575f0c61 --- /dev/null +++ b/test/integration/test/standardSchema.test.ts @@ -0,0 +1,716 @@ +/** + * Integration tests for Standard Schema support (StandardJSONSchemaV1) + * Tests ArkType and Valibot schemas with the MCP SDK + */ + +import { Client } from '@modelcontextprotocol/client'; +import type { CallToolResult, TextContent } from '@modelcontextprotocol/core'; +import { + CallToolResultSchema, + CompleteResultSchema, + InMemoryTransport, + ListPromptsResultSchema, + ListToolsResultSchema +} from '@modelcontextprotocol/core'; +import { completable, McpServer } from '@modelcontextprotocol/server'; +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import { type } from 'arktype'; +import * as v from 'valibot'; +import { beforeEach, describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +describe('Standard Schema Support', () => { + let mcpServer: McpServer; + let client: Client; + + beforeEach(async () => { + mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + client = new Client({ + name: 'test client', + version: '1.0' + }); + }); + + async function connectClientAndServer() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + } + + describe('ArkType schemas', () => { + describe('tool registration', () => { + test('should register tool with ArkType input schema', async () => { + const inputSchema = type({ + name: 'string', + age: 'number' + }); + + mcpServer.registerTool( + 'greet', + { + description: 'Greet a person', + inputSchema + }, + async ({ name, age }) => ({ + content: [{ type: 'text', text: `Hello ${name}, you are ${age} years old` }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('greet'); + expect(result.tools[0].inputSchema).toMatchObject({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + } + }); + // Check required array contains both fields (order may vary by library) + expect(result.tools[0].inputSchema.required).toEqual(expect.arrayContaining(['name', 'age'])); + }); + + test('should register tool with ArkType input and output schemas', async () => { + const inputSchema = type({ x: 'number', y: 'number' }); + const outputSchema = type({ result: 'number', operation: 'string' }); + + mcpServer.registerTool( + 'add', + { + description: 'Add two numbers', + inputSchema, + outputSchema + }, + async ({ x, y }) => ({ + content: [{ type: 'text', text: `${x + y}` }], + structuredContent: { result: x + y, operation: 'addition' } + }) + ); + + await connectClientAndServer(); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + + expect(result.tools[0].outputSchema).toMatchObject({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + result: { type: 'number' }, + operation: { type: 'string' } + } + }); + expect(result.tools[0].outputSchema!.required).toEqual(expect.arrayContaining(['result', 'operation'])); + }); + }); + + describe('tool validation', () => { + test('should validate valid input and execute tool', async () => { + const inputSchema = type({ value: 'number' }); + + mcpServer.registerTool( + 'double', + { inputSchema }, + async ({ value }) => ({ + content: [{ type: 'text', text: `${value * 2}` }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request( + { + method: 'tools/call', + params: { name: 'double', arguments: { value: 21 } } + }, + CallToolResultSchema + ); + + expect(result.content[0]).toEqual({ type: 'text', text: '42' }); + }); + + test('should return validation error for invalid input type', async () => { + const inputSchema = type({ value: 'number' }); + + mcpServer.registerTool( + 'double', + { inputSchema }, + async ({ value }) => ({ + content: [{ type: 'text', text: `${value * 2}` }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request( + { + method: 'tools/call', + params: { name: 'double', arguments: { value: 'not a number' } } + }, + CallToolResultSchema + ); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + expect(errorText).toContain('value'); + expect(errorText).toContain('number'); + }); + + test('should return validation error for invalid enum value', async () => { + const inputSchema = type({ + operation: "'add' | 'subtract' | 'multiply'" + }); + + mcpServer.registerTool( + 'calculate', + { inputSchema }, + async ({ operation }) => ({ + content: [{ type: 'text', text: operation }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request( + { + method: 'tools/call', + params: { name: 'calculate', arguments: { operation: 'divide' } } + }, + CallToolResultSchema + ); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + expect(errorText).toMatch(/add|subtract|multiply/); + }); + + test('should return validation error for missing required field', async () => { + const inputSchema = type({ name: 'string', age: 'number' }); + + mcpServer.registerTool( + 'greet', + { inputSchema }, + async ({ name, age }) => ({ + content: [{ type: 'text', text: `Hello ${name}, ${age}` }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request( + { + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'Alice' } } + }, + CallToolResultSchema + ); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + expect(errorText).toContain('age'); + }); + }); + }); + + describe('Valibot schemas', () => { + describe('tool registration', () => { + test('should register tool with Valibot input schema', async () => { + const inputSchema = toStandardJsonSchema( + v.object({ + name: v.string(), + age: v.number() + }) + ); + + mcpServer.registerTool( + 'greet', + { + description: 'Greet a person', + inputSchema + }, + async ({ name, age }) => ({ + content: [{ type: 'text', text: `Hello ${name}, you are ${age} years old` }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('greet'); + expect(result.tools[0].inputSchema).toMatchObject({ + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + }, + required: ['name', 'age'] + }); + }); + + test('should register tool with Valibot schema with descriptions', async () => { + const inputSchema = toStandardJsonSchema( + v.object({ + city: v.pipe(v.string(), v.description('The city name')), + country: v.pipe(v.string(), v.description('The country code')) + }) + ); + + mcpServer.registerTool('weather', { inputSchema }, async () => ({ + content: [{ type: 'text', text: 'sunny' }] + })); + + await connectClientAndServer(); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + + expect(result.tools[0].inputSchema.properties).toMatchObject({ + city: { type: 'string', description: 'The city name' }, + country: { type: 'string', description: 'The country code' } + }); + }); + }); + + describe('tool validation', () => { + test('should validate valid input and execute tool', async () => { + const inputSchema = toStandardJsonSchema(v.object({ value: v.number() })); + + mcpServer.registerTool( + 'double', + { inputSchema }, + async ({ value }) => ({ + content: [{ type: 'text', text: `${value * 2}` }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request( + { + method: 'tools/call', + params: { name: 'double', arguments: { value: 21 } } + }, + CallToolResultSchema + ); + + expect(result.content[0]).toEqual({ type: 'text', text: '42' }); + }); + + test('should return validation error for invalid input type', async () => { + const inputSchema = toStandardJsonSchema(v.object({ value: v.number() })); + + mcpServer.registerTool( + 'double', + { inputSchema }, + async ({ value }) => ({ + content: [{ type: 'text', text: `${value * 2}` }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request( + { + method: 'tools/call', + params: { name: 'double', arguments: { value: 'not a number' } } + }, + CallToolResultSchema + ); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + expect(errorText).toContain('number'); + }); + + test('should return validation error for invalid picklist value', async () => { + const inputSchema = toStandardJsonSchema( + v.object({ + operation: v.picklist(['add', 'subtract', 'multiply']) + }) + ); + + mcpServer.registerTool( + 'calculate', + { inputSchema }, + async ({ operation }) => ({ + content: [{ type: 'text', text: operation }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request( + { + method: 'tools/call', + params: { name: 'calculate', arguments: { operation: 'divide' } } + }, + CallToolResultSchema + ); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + }); + + test('should validate min/max constraints', async () => { + const inputSchema = toStandardJsonSchema( + v.object({ + percentage: v.pipe(v.number(), v.minValue(0), v.maxValue(100)) + }) + ); + + mcpServer.registerTool( + 'setPercentage', + { inputSchema }, + async ({ percentage }) => ({ + content: [{ type: 'text', text: `${percentage}%` }] + }) + ); + + await connectClientAndServer(); + + // Valid value + const validResult = await client.request( + { + method: 'tools/call', + params: { name: 'setPercentage', arguments: { percentage: 50 } } + }, + CallToolResultSchema + ); + expect(validResult.isError).toBeFalsy(); + + // Invalid value (too high) + const invalidResult = await client.request( + { + method: 'tools/call', + params: { name: 'setPercentage', arguments: { percentage: 150 } } + }, + CallToolResultSchema + ); + expect(invalidResult.isError).toBe(true); + const errorText = (invalidResult.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + }); + }); + }); + + describe('Mixed schema libraries', () => { + test('should support tools with different schema libraries in same server', async () => { + // Zod tool + mcpServer.registerTool( + 'zod-tool', + { inputSchema: z.object({ value: z.string() }) }, + async ({ value }) => ({ content: [{ type: 'text', text: `zod: ${value}` }] }) + ); + + // ArkType tool + mcpServer.registerTool( + 'arktype-tool', + { inputSchema: type({ value: 'string' }) }, + async ({ value }) => ({ content: [{ type: 'text', text: `arktype: ${value}` }] }) + ); + + // Valibot tool + mcpServer.registerTool( + 'valibot-tool', + { inputSchema: toStandardJsonSchema(v.object({ value: v.string() })) }, + async ({ value }) => ({ content: [{ type: 'text', text: `valibot: ${value}` }] }) + ); + + await connectClientAndServer(); + + const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + expect(tools.tools).toHaveLength(3); + + // Call each tool + const zodResult = await client.request( + { method: 'tools/call', params: { name: 'zod-tool', arguments: { value: 'test' } } }, + CallToolResultSchema + ); + expect((zodResult.content[0] as TextContent).text).toBe('zod: test'); + + const arktypeResult = await client.request( + { method: 'tools/call', params: { name: 'arktype-tool', arguments: { value: 'test' } } }, + CallToolResultSchema + ); + expect((arktypeResult.content[0] as TextContent).text).toBe('arktype: test'); + + const valibotResult = await client.request( + { method: 'tools/call', params: { name: 'valibot-tool', arguments: { value: 'test' } } }, + CallToolResultSchema + ); + expect((valibotResult.content[0] as TextContent).text).toBe('valibot: test'); + }); + }); + + describe('Prompt completions with Zod completable', () => { + // Note: completable() is currently Zod-specific + // These tests verify that Zod schemas with completable still work + + test('should support completion with Zod completable schemas', async () => { + mcpServer.registerPrompt( + 'greeting', + { + argsSchema: z.object({ + name: completable(z.string(), value => + ['Alice', 'Bob', 'Charlie'].filter(n => n.toLowerCase().startsWith(value.toLowerCase())) + ) + }) + }, + async ({ name }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `Hello ${name}` } }] + }) + ); + + await connectClientAndServer(); + + // Test completion + const result = await client.request( + { + method: 'completion/complete', + params: { + ref: { type: 'ref/prompt', name: 'greeting' }, + argument: { name: 'name', value: 'a' } + } + }, + CompleteResultSchema + ); + + expect(result.completion.values).toEqual(['Alice']); + }); + + test('should return all completions when prefix is empty', async () => { + mcpServer.registerPrompt( + 'greeting', + { + argsSchema: z.object({ + name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) + }) + }, + async ({ name }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `Hello ${name}` } }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request( + { + method: 'completion/complete', + params: { + ref: { type: 'ref/prompt', name: 'greeting' }, + argument: { name: 'name', value: '' } + } + }, + CompleteResultSchema + ); + + expect(result.completion.values).toEqual(['Alice', 'Bob', 'Charlie']); + expect(result.completion.total).toBe(3); + }); + }); + + describe('Error message quality', () => { + test('ArkType should provide descriptive error messages', async () => { + const inputSchema = type({ + email: 'string', + age: 'number', + status: "'active' | 'inactive'" + }); + + mcpServer.registerTool('test', { inputSchema }, async () => ({ + content: [{ type: 'text', text: 'ok' }] + })); + + await connectClientAndServer(); + + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'test', + arguments: { + email: 123, + age: 'not a number', + status: 'unknown' + } + } + }, + CallToolResultSchema + ); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + + // Check that error mentions the specific issues + expect(errorText).toContain('Input validation error'); + // ArkType should mention type mismatches + expect(errorText).toMatch(/email|age|status/i); + }); + + test('Valibot should provide descriptive error messages', async () => { + const inputSchema = toStandardJsonSchema( + v.object({ + email: v.string(), + age: v.number(), + status: v.picklist(['active', 'inactive']) + }) + ); + + mcpServer.registerTool('test', { inputSchema }, async () => ({ + content: [{ type: 'text', text: 'ok' }] + })); + + await connectClientAndServer(); + + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'test', + arguments: { + email: 123, + age: 'not a number', + status: 'unknown' + } + } + }, + CallToolResultSchema + ); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + + // Check that error mentions the specific issues + expect(errorText).toContain('Input validation error'); + // Valibot should provide "Invalid type" messages + expect(errorText).toContain('Invalid type'); + }); + + test('Zod should provide descriptive error messages', async () => { + const inputSchema = z.object({ + email: z.string(), + age: z.number(), + status: z.enum(['active', 'inactive']) + }); + + mcpServer.registerTool('test', { inputSchema }, async () => ({ + content: [{ type: 'text', text: 'ok' }] + })); + + await connectClientAndServer(); + + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'test', + arguments: { + email: 123, + age: 'not a number', + status: 'unknown' + } + } + }, + CallToolResultSchema + ); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + + // Check that error mentions the specific issues + expect(errorText).toContain('Input validation error'); + }); + }); + + describe('Type inference', () => { + test('ArkType callback should receive correctly typed arguments', async () => { + const inputSchema = type({ + name: 'string', + count: 'number', + enabled: 'boolean' + }); + + // This test verifies TypeScript compilation succeeds with correct types + mcpServer.registerTool( + 'typed-tool', + { inputSchema }, + async ({ name, count, enabled }) => { + // TypeScript should infer these types correctly + const _name: string = name; + const _count: number = count; + const _enabled: boolean = enabled; + + return { + content: [{ type: 'text', text: `${_name}: ${_count}, enabled: ${_enabled}` }] + }; + } + ); + + await connectClientAndServer(); + + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'typed-tool', + arguments: { name: 'test', count: 42, enabled: true } + } + }, + CallToolResultSchema + ); + + expect((result.content[0] as TextContent).text).toBe('test: 42, enabled: true'); + }); + + test('Valibot callback should receive correctly typed arguments', async () => { + const inputSchema = toStandardJsonSchema( + v.object({ + name: v.string(), + count: v.number(), + enabled: v.boolean() + }) + ); + + mcpServer.registerTool( + 'typed-tool', + { inputSchema }, + async ({ name, count, enabled }) => { + // TypeScript should infer these types correctly + const _name: string = name; + const _count: number = count; + const _enabled: boolean = enabled; + + return { + content: [{ type: 'text', text: `${_name}: ${_count}, enabled: ${_enabled}` }] + }; + } + ); + + await connectClientAndServer(); + + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'typed-tool', + arguments: { name: 'test', count: 42, enabled: true } + } + }, + CallToolResultSchema + ); + + expect((result.content[0] as TextContent).text).toBe('test: 42, enabled: true'); + }); + }); +}); From fe48476b269b2a97569a75c06e53f8117fc45e39 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 3 Feb 2026 20:59:35 +0000 Subject: [PATCH 05/12] chore: fix lint and formatting issues - Add eslint-disable for namespace rule in standardSchema.ts (matches Standard Schema spec design) - Remove unused imports in standardSchema.test.ts - Fix switch case braces in example files - Apply prettier formatting --- examples/server/src/arktypeExample.ts | 21 ++- examples/server/src/valibotExample.ts | 21 ++- packages/core/src/shared/protocol.ts | 4 +- packages/core/src/util/standardSchema.ts | 18 +- .../src/experimental/tasks/interfaces.ts | 9 +- .../src/experimental/tasks/mcpServer.ts | 5 +- .../server/src/experimental/tasks/server.ts | 6 +- packages/server/src/server/completable.ts | 5 +- packages/server/src/server/mcp.ts | 18 +- packages/server/src/server/server.ts | 2 +- test/integration/test/standardSchema.test.ts | 154 ++++++------------ 11 files changed, 102 insertions(+), 161 deletions(-) diff --git a/examples/server/src/arktypeExample.ts b/examples/server/src/arktypeExample.ts index 3a6e56a48..1e748b50e 100644 --- a/examples/server/src/arktypeExample.ts +++ b/examples/server/src/arktypeExample.ts @@ -48,9 +48,12 @@ server.registerTool( // Simulate weather API call const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; - const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][ - Math.floor(Math.random() * 5) - ] as 'sunny' | 'cloudy' | 'rainy' | 'stormy' | 'snowy'; + const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][Math.floor(Math.random() * 5)] as + | 'sunny' + | 'cloudy' + | 'rainy' + | 'stormy' + | 'snowy'; const structuredContent = { temperature: { @@ -100,16 +103,19 @@ server.registerTool( async ({ operation, a, b }) => { let result: number; switch (operation) { - case 'add': + case 'add': { result = a + b; break; - case 'subtract': + } + case 'subtract': { result = a - b; break; - case 'multiply': + } + case 'multiply': { result = a * b; break; - case 'divide': + } + case 'divide': { if (b === 0) { return { content: [{ type: 'text', text: 'Error: Division by zero' }], @@ -118,6 +124,7 @@ server.registerTool( } result = a / b; break; + } } const structuredContent = { diff --git a/examples/server/src/valibotExample.ts b/examples/server/src/valibotExample.ts index 188b54d46..42d7aef95 100644 --- a/examples/server/src/valibotExample.ts +++ b/examples/server/src/valibotExample.ts @@ -53,9 +53,12 @@ server.registerTool( // Simulate weather API call const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; - const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][ - Math.floor(Math.random() * 5) - ] as 'sunny' | 'cloudy' | 'rainy' | 'stormy' | 'snowy'; + const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][Math.floor(Math.random() * 5)] as + | 'sunny' + | 'cloudy' + | 'rainy' + | 'stormy' + | 'snowy'; const structuredContent = { temperature: { @@ -109,16 +112,19 @@ server.registerTool( async ({ operation, a, b }) => { let result: number; switch (operation) { - case 'add': + case 'add': { result = a + b; break; - case 'subtract': + } + case 'subtract': { result = a - b; break; - case 'multiply': + } + case 'multiply': { result = a * b; break; - case 'divide': + } + case 'divide': { if (b === 0) { return { content: [{ type: 'text', text: 'Error: Division by zero' }], @@ -127,6 +133,7 @@ server.registerTool( } result = a / b; break; + } } const structuredContent = { diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index f6413d745..dfa98a171 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1,8 +1,6 @@ import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; import type { CreateTaskOptions, QueuedMessage, TaskMessageQueue, TaskStore } from '../experimental/tasks/interfaces.js'; import { isTerminal } from '../experimental/tasks/interfaces.js'; -import type { AnyObjectSchema, AnySchema, SchemaOutput } from '../util/schema.js'; -import { parseSchema } from '../util/schema.js'; import type { AuthInfo, CancelledNotification, @@ -59,6 +57,8 @@ import { SUPPORTED_PROTOCOL_VERSIONS, TaskStatusNotificationSchema } from '../types/types.js'; +import type { AnyObjectSchema, AnySchema, SchemaOutput } from '../util/schema.js'; +import { parseSchema } from '../util/schema.js'; import type { ResponseMessage } from './responseMessage.js'; import type { Transport, TransportSendOptions } from './transport.js'; diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index a3934994a..445649ae8 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -6,6 +6,10 @@ * Supported libraries include: Zod v4, Valibot, ArkType, and others. */ +/* eslint-disable @typescript-eslint/no-namespace */ +// Namespaces are used here to match the Standard Schema spec interface design, +// enabling ergonomic type inference like `StandardJSONSchemaV1.InferOutput`. + import type { JsonSchemaType, jsonSchemaValidator } from '../validation/types.js'; // ============================================================================ @@ -48,10 +52,7 @@ export interface StandardSchemaV1 { export namespace StandardSchemaV1 { export interface Props extends StandardTypedV1.Props { - readonly validate: ( - value: unknown, - options?: Options | undefined - ) => Result | Promise>; + readonly validate: (value: unknown, options?: Options | undefined) => Result | Promise>; } export interface Options { @@ -171,10 +172,7 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch * @param io - Whether to get the 'input' or 'output' JSON Schema (default: 'input') * @returns JSON Schema object compatible with Draft 2020-12 */ -export function standardSchemaToJsonSchema( - schema: StandardJSONSchemaV1, - io: 'input' | 'output' = 'input' -): Record { +export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record { return schema['~standard'].jsonSchema[io]({ target: 'draft-2020-12' }); } @@ -219,9 +217,7 @@ export async function validateStandardSchema( // Fall back to JSON Schema validation if validator provided if (jsonSchemaValidatorInstance) { const jsonSchema = standardSchemaToJsonSchema(schema, 'input'); - const validator = jsonSchemaValidatorInstance.getValidator>( - jsonSchema as JsonSchemaType - ); + const validator = jsonSchemaValidatorInstance.getValidator>(jsonSchema as JsonSchemaType); const validationResult = validator(data); if (validationResult.valid) { diff --git a/packages/server/src/experimental/tasks/interfaces.ts b/packages/server/src/experimental/tasks/interfaces.ts index ca3ce48e3..26cffb8c6 100644 --- a/packages/server/src/experimental/tasks/interfaces.ts +++ b/packages/server/src/experimental/tasks/interfaces.ts @@ -32,10 +32,11 @@ export type CreateTaskRequestHandler< * Handler for task operations (get, getResult). * @experimental */ -export type TaskRequestHandler< - SendResultT extends Result, - Args extends StandardJSONSchemaV1 | undefined = undefined -> = BaseToolCallback; +export type TaskRequestHandler = BaseToolCallback< + SendResultT, + TaskServerContext, + Args +>; /** * Interface for task-based tool handlers. diff --git a/packages/server/src/experimental/tasks/mcpServer.ts b/packages/server/src/experimental/tasks/mcpServer.ts index f0118b17d..0a8aae234 100644 --- a/packages/server/src/experimental/tasks/mcpServer.ts +++ b/packages/server/src/experimental/tasks/mcpServer.ts @@ -103,10 +103,7 @@ export class ExperimentalMcpServerTasks { handler: ToolTaskHandler ): RegisteredTool; - registerToolTask< - InputArgs extends StandardJSONSchemaV1 | undefined, - OutputArgs extends StandardJSONSchemaV1 | undefined - >( + registerToolTask( name: string, config: { title?: string; diff --git a/packages/server/src/experimental/tasks/server.ts b/packages/server/src/experimental/tasks/server.ts index 9c7a9ef8d..e60500788 100644 --- a/packages/server/src/experimental/tasks/server.ts +++ b/packages/server/src/experimental/tasks/server.ts @@ -88,11 +88,7 @@ export class ExperimentalServerTasks { * * @experimental */ - async getTaskResult( - taskId: string, - resultSchema?: T, - options?: RequestOptions - ): Promise> { + async getTaskResult(taskId: string, resultSchema?: T, options?: RequestOptions): Promise> { return ( this._server as unknown as { getTaskResult: ( diff --git a/packages/server/src/server/completable.ts b/packages/server/src/server/completable.ts index b3cef81a6..df822711d 100644 --- a/packages/server/src/server/completable.ts +++ b/packages/server/src/server/completable.ts @@ -20,10 +20,7 @@ export type CompletableSchema = T & { /** * Wraps a schema to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP. */ -export function completable( - schema: T, - complete: CompleteCallback -): CompletableSchema { +export function completable(schema: T, complete: CompleteCallback): CompletableSchema { Object.defineProperty(schema as object, COMPLETABLE_SYMBOL, { value: { complete } as CompletableMeta, enumerable: false, diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index c92dce0f0..08b5a0c11 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -137,10 +137,7 @@ export class McpServer { }; if (tool.outputSchema) { - toolDefinition.outputSchema = standardSchemaToJsonSchema( - tool.outputSchema, - 'output' - ) as Tool['outputSchema']; + toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; } return toolDefinition; @@ -798,10 +795,7 @@ export class McpServer { /** * Registers a tool with a config object and callback. */ - registerTool< - OutputArgs extends StandardJSONSchemaV1, - InputArgs extends StandardJSONSchemaV1 | undefined = undefined - >( + registerTool( name: string, config: { title?: string; @@ -984,9 +978,7 @@ export type ToolCallback = - | ToolCallback - | ToolTaskHandler; +export type AnyToolHandler = ToolCallback | ToolTaskHandler; /** * Internal executor type that encapsulates handler invocation with proper types. @@ -1249,9 +1241,7 @@ function unwrapZodOptionalSchema(schema: unknown): unknown { * @internal Extracts completable callbacks from a schema at registration time. * This allows completion to work without runtime Zod introspection. */ -function extractCompleters( - schema: StandardJSONSchemaV1 | undefined -): Map> | undefined { +function extractCompleters(schema: StandardJSONSchemaV1 | undefined): Map> | undefined { if (!schema) return undefined; const shape = getZodSchemaShape(schema); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 512284ba0..615217f8c 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -52,8 +52,8 @@ import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; -import * as z from 'zod/v4'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; +import * as z from 'zod/v4'; import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; diff --git a/test/integration/test/standardSchema.test.ts b/test/integration/test/standardSchema.test.ts index f575f0c61..a53616969 100644 --- a/test/integration/test/standardSchema.test.ts +++ b/test/integration/test/standardSchema.test.ts @@ -4,14 +4,8 @@ */ import { Client } from '@modelcontextprotocol/client'; -import type { CallToolResult, TextContent } from '@modelcontextprotocol/core'; -import { - CallToolResultSchema, - CompleteResultSchema, - InMemoryTransport, - ListPromptsResultSchema, - ListToolsResultSchema -} from '@modelcontextprotocol/core'; +import type { TextContent } from '@modelcontextprotocol/core'; +import { CallToolResultSchema, CompleteResultSchema, InMemoryTransport, ListToolsResultSchema } from '@modelcontextprotocol/core'; import { completable, McpServer } from '@modelcontextprotocol/server'; import { toStandardJsonSchema } from '@valibot/to-json-schema'; import { type } from 'arktype'; @@ -113,13 +107,9 @@ describe('Standard Schema Support', () => { test('should validate valid input and execute tool', async () => { const inputSchema = type({ value: 'number' }); - mcpServer.registerTool( - 'double', - { inputSchema }, - async ({ value }) => ({ - content: [{ type: 'text', text: `${value * 2}` }] - }) - ); + mcpServer.registerTool('double', { inputSchema }, async ({ value }) => ({ + content: [{ type: 'text', text: `${value * 2}` }] + })); await connectClientAndServer(); @@ -137,13 +127,9 @@ describe('Standard Schema Support', () => { test('should return validation error for invalid input type', async () => { const inputSchema = type({ value: 'number' }); - mcpServer.registerTool( - 'double', - { inputSchema }, - async ({ value }) => ({ - content: [{ type: 'text', text: `${value * 2}` }] - }) - ); + mcpServer.registerTool('double', { inputSchema }, async ({ value }) => ({ + content: [{ type: 'text', text: `${value * 2}` }] + })); await connectClientAndServer(); @@ -167,13 +153,9 @@ describe('Standard Schema Support', () => { operation: "'add' | 'subtract' | 'multiply'" }); - mcpServer.registerTool( - 'calculate', - { inputSchema }, - async ({ operation }) => ({ - content: [{ type: 'text', text: operation }] - }) - ); + mcpServer.registerTool('calculate', { inputSchema }, async ({ operation }) => ({ + content: [{ type: 'text', text: operation }] + })); await connectClientAndServer(); @@ -194,13 +176,9 @@ describe('Standard Schema Support', () => { test('should return validation error for missing required field', async () => { const inputSchema = type({ name: 'string', age: 'number' }); - mcpServer.registerTool( - 'greet', - { inputSchema }, - async ({ name, age }) => ({ - content: [{ type: 'text', text: `Hello ${name}, ${age}` }] - }) - ); + mcpServer.registerTool('greet', { inputSchema }, async ({ name, age }) => ({ + content: [{ type: 'text', text: `Hello ${name}, ${age}` }] + })); await connectClientAndServer(); @@ -284,13 +262,9 @@ describe('Standard Schema Support', () => { test('should validate valid input and execute tool', async () => { const inputSchema = toStandardJsonSchema(v.object({ value: v.number() })); - mcpServer.registerTool( - 'double', - { inputSchema }, - async ({ value }) => ({ - content: [{ type: 'text', text: `${value * 2}` }] - }) - ); + mcpServer.registerTool('double', { inputSchema }, async ({ value }) => ({ + content: [{ type: 'text', text: `${value * 2}` }] + })); await connectClientAndServer(); @@ -308,13 +282,9 @@ describe('Standard Schema Support', () => { test('should return validation error for invalid input type', async () => { const inputSchema = toStandardJsonSchema(v.object({ value: v.number() })); - mcpServer.registerTool( - 'double', - { inputSchema }, - async ({ value }) => ({ - content: [{ type: 'text', text: `${value * 2}` }] - }) - ); + mcpServer.registerTool('double', { inputSchema }, async ({ value }) => ({ + content: [{ type: 'text', text: `${value * 2}` }] + })); await connectClientAndServer(); @@ -339,13 +309,9 @@ describe('Standard Schema Support', () => { }) ); - mcpServer.registerTool( - 'calculate', - { inputSchema }, - async ({ operation }) => ({ - content: [{ type: 'text', text: operation }] - }) - ); + mcpServer.registerTool('calculate', { inputSchema }, async ({ operation }) => ({ + content: [{ type: 'text', text: operation }] + })); await connectClientAndServer(); @@ -369,13 +335,9 @@ describe('Standard Schema Support', () => { }) ); - mcpServer.registerTool( - 'setPercentage', - { inputSchema }, - async ({ percentage }) => ({ - content: [{ type: 'text', text: `${percentage}%` }] - }) - ); + mcpServer.registerTool('setPercentage', { inputSchema }, async ({ percentage }) => ({ + content: [{ type: 'text', text: `${percentage}%` }] + })); await connectClientAndServer(); @@ -407,18 +369,14 @@ describe('Standard Schema Support', () => { describe('Mixed schema libraries', () => { test('should support tools with different schema libraries in same server', async () => { // Zod tool - mcpServer.registerTool( - 'zod-tool', - { inputSchema: z.object({ value: z.string() }) }, - async ({ value }) => ({ content: [{ type: 'text', text: `zod: ${value}` }] }) - ); + mcpServer.registerTool('zod-tool', { inputSchema: z.object({ value: z.string() }) }, async ({ value }) => ({ + content: [{ type: 'text', text: `zod: ${value}` }] + })); // ArkType tool - mcpServer.registerTool( - 'arktype-tool', - { inputSchema: type({ value: 'string' }) }, - async ({ value }) => ({ content: [{ type: 'text', text: `arktype: ${value}` }] }) - ); + mcpServer.registerTool('arktype-tool', { inputSchema: type({ value: 'string' }) }, async ({ value }) => ({ + content: [{ type: 'text', text: `arktype: ${value}` }] + })); // Valibot tool mcpServer.registerTool( @@ -642,20 +600,16 @@ describe('Standard Schema Support', () => { }); // This test verifies TypeScript compilation succeeds with correct types - mcpServer.registerTool( - 'typed-tool', - { inputSchema }, - async ({ name, count, enabled }) => { - // TypeScript should infer these types correctly - const _name: string = name; - const _count: number = count; - const _enabled: boolean = enabled; - - return { - content: [{ type: 'text', text: `${_name}: ${_count}, enabled: ${_enabled}` }] - }; - } - ); + mcpServer.registerTool('typed-tool', { inputSchema }, async ({ name, count, enabled }) => { + // TypeScript should infer these types correctly + const _name: string = name; + const _count: number = count; + const _enabled: boolean = enabled; + + return { + content: [{ type: 'text', text: `${_name}: ${_count}, enabled: ${_enabled}` }] + }; + }); await connectClientAndServer(); @@ -682,20 +636,16 @@ describe('Standard Schema Support', () => { }) ); - mcpServer.registerTool( - 'typed-tool', - { inputSchema }, - async ({ name, count, enabled }) => { - // TypeScript should infer these types correctly - const _name: string = name; - const _count: number = count; - const _enabled: boolean = enabled; - - return { - content: [{ type: 'text', text: `${_name}: ${_count}, enabled: ${_enabled}` }] - }; - } - ); + mcpServer.registerTool('typed-tool', { inputSchema }, async ({ name, count, enabled }) => { + // TypeScript should infer these types correctly + const _name: string = name; + const _count: number = count; + const _enabled: boolean = enabled; + + return { + content: [{ type: 'text', text: `${_name}: ${_count}, enabled: ${_enabled}` }] + }; + }); await connectClientAndServer(); From 2983603e22147bb453892710afd39a058c1d20a1 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 3 Feb 2026 21:08:04 +0000 Subject: [PATCH 06/12] fix: add missing imports after rebase - Add AnySchema import to experimental/tasks/server.ts - Add parseSchema import to server/server.ts --- packages/server/src/experimental/tasks/server.ts | 4 ++-- packages/server/src/server/server.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/server/src/experimental/tasks/server.ts b/packages/server/src/experimental/tasks/server.ts index e60500788..0f63e4969 100644 --- a/packages/server/src/experimental/tasks/server.ts +++ b/packages/server/src/experimental/tasks/server.ts @@ -6,14 +6,14 @@ */ import type { + AnySchema, CancelTaskResult, GetTaskResult, ListTasksResult, Request, RequestOptions, ResponseMessage, - Result, - SchemaOutput + Result } from '@modelcontextprotocol/core'; import type * as z from 'zod/v4'; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 615217f8c..2ce4ddb27 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -46,6 +46,7 @@ import { ListRootsResultSchema, LoggingLevelSchema, mergeCapabilities, + parseSchema, Protocol, ProtocolError, ProtocolErrorCode, From 93e3858a7be5b2a82152cc75849db9a2e2704596 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 3 Feb 2026 21:12:20 +0000 Subject: [PATCH 07/12] chore: simplify examples and reduce docstrings - Simplify ArkType and Valibot examples to minimal single-tool demos - Remove verbose docstrings and section dividers from standardSchema.ts --- examples/server/src/arktypeExample.ts | 156 ++-------------------- examples/server/src/valibotExample.ts | 163 ++--------------------- packages/core/src/util/standardSchema.ts | 100 ++------------ 3 files changed, 35 insertions(+), 384 deletions(-) diff --git a/examples/server/src/arktypeExample.ts b/examples/server/src/arktypeExample.ts index 1e748b50e..ff96a334a 100644 --- a/examples/server/src/arktypeExample.ts +++ b/examples/server/src/arktypeExample.ts @@ -1,160 +1,28 @@ #!/usr/bin/env node /** - * Example MCP server using ArkType for schema validation - * This demonstrates how to use ArkType schemas with the MCP SDK's - * StandardJSONSchemaV1 support for tool input/output schemas. - * - * ArkType implements the Standard Schema spec and provides built-in - * JSON Schema conversion via toJsonSchema(). + * Minimal MCP server using ArkType for schema validation. + * ArkType implements the Standard Schema spec with built-in JSON Schema conversion. */ import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; import { type } from 'arktype'; const server = new McpServer({ - name: 'mcp-arktype-example', + name: 'arktype-example', version: '1.0.0' }); -// Define schemas using ArkType -const weatherInputSchema = type({ - city: 'string', - country: 'string' -}); - -const weatherOutputSchema = type({ - temperature: { - celsius: 'number', - fahrenheit: 'number' - }, - conditions: "'sunny' | 'cloudy' | 'rainy' | 'stormy' | 'snowy'", - humidity: 'number', - wind: { - speed_kmh: 'number', - direction: 'string' - } -}); - -// Register a tool with ArkType schemas -server.registerTool( - 'get_weather', - { - description: 'Get weather information for a city (using ArkType validation)', - inputSchema: weatherInputSchema, - outputSchema: weatherOutputSchema - }, - async ({ city, country }) => { - console.error(`Getting weather for ${city}, ${country}`); - - // Simulate weather API call - const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; - const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][Math.floor(Math.random() * 5)] as - | 'sunny' - | 'cloudy' - | 'rainy' - | 'stormy' - | 'snowy'; - - const structuredContent = { - temperature: { - celsius: temp_c, - fahrenheit: Math.round(((temp_c * 9) / 5 + 32) * 10) / 10 - }, - conditions, - humidity: Math.round(Math.random() * 100), - wind: { - speed_kmh: Math.round(Math.random() * 50), - direction: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)]! - } - }; - - return { - content: [ - { - type: 'text', - text: JSON.stringify(structuredContent, null, 2) - } - ], - structuredContent - }; - } -); - -// Another tool example - calculator -const calcInputSchema = type({ - operation: "'add' | 'subtract' | 'multiply' | 'divide'", - a: 'number', - b: 'number' -}); - -const calcOutputSchema = type({ - result: 'number', - operation: 'string', - expression: 'string' -}); - +// Register a tool with ArkType schema server.registerTool( - 'calculate', + 'greet', { - description: 'Perform basic arithmetic operations (using ArkType validation)', - inputSchema: calcInputSchema, - outputSchema: calcOutputSchema + description: 'Generate a greeting', + inputSchema: type({ name: 'string' }) }, - async ({ operation, a, b }) => { - let result: number; - switch (operation) { - case 'add': { - result = a + b; - break; - } - case 'subtract': { - result = a - b; - break; - } - case 'multiply': { - result = a * b; - break; - } - case 'divide': { - if (b === 0) { - return { - content: [{ type: 'text', text: 'Error: Division by zero' }], - isError: true - }; - } - result = a / b; - break; - } - } - - const structuredContent = { - result, - operation, - expression: `${a} ${operation} ${b} = ${result}` - }; - - return { - content: [ - { - type: 'text', - text: structuredContent.expression - } - ], - structuredContent - }; - } + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) ); -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error('ArkType Example Server running on stdio'); -} - -try { - await main(); -} catch (error) { - console.error('Server error:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/examples/server/src/valibotExample.ts b/examples/server/src/valibotExample.ts index 42d7aef95..46ab793c0 100644 --- a/examples/server/src/valibotExample.ts +++ b/examples/server/src/valibotExample.ts @@ -1,11 +1,8 @@ #!/usr/bin/env node /** - * Example MCP server using Valibot for schema validation - * This demonstrates how to use Valibot schemas with the MCP SDK's - * StandardJSONSchemaV1 support for tool input/output schemas. - * - * Valibot implements the Standard Schema spec. Use toStandardJsonSchema() - * from @valibot/to-json-schema to create StandardJSONSchemaV1-compliant schemas. + * Minimal MCP server using Valibot for schema validation. + * Use toStandardJsonSchema() from @valibot/to-json-schema to create + * StandardJSONSchemaV1-compliant schemas. */ import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'; @@ -13,157 +10,21 @@ import { toStandardJsonSchema } from '@valibot/to-json-schema'; import * as v from 'valibot'; const server = new McpServer({ - name: 'mcp-valibot-example', + name: 'valibot-example', version: '1.0.0' }); -// Define schemas using Valibot and wrap with toStandardJsonSchema -const weatherInputSchema = toStandardJsonSchema( - v.object({ - city: v.pipe(v.string(), v.description('City name')), - country: v.pipe(v.string(), v.description('Country code (e.g., US, UK)')) - }) -); - -const weatherOutputSchema = toStandardJsonSchema( - v.object({ - temperature: v.object({ - celsius: v.number(), - fahrenheit: v.number() - }), - conditions: v.picklist(['sunny', 'cloudy', 'rainy', 'stormy', 'snowy']), - humidity: v.pipe(v.number(), v.minValue(0), v.maxValue(100)), - wind: v.object({ - speed_kmh: v.number(), - direction: v.string() - }) - }) -); - -// Register a tool with Valibot schemas +// Register a tool with Valibot schema server.registerTool( - 'get_weather', + 'greet', { - description: 'Get weather information for a city (using Valibot validation)', - inputSchema: weatherInputSchema, - outputSchema: weatherOutputSchema + description: 'Generate a greeting', + inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) }, - async ({ city, country }) => { - console.error(`Getting weather for ${city}, ${country}`); - - // Simulate weather API call - const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; - const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][Math.floor(Math.random() * 5)] as - | 'sunny' - | 'cloudy' - | 'rainy' - | 'stormy' - | 'snowy'; - - const structuredContent = { - temperature: { - celsius: temp_c, - fahrenheit: Math.round(((temp_c * 9) / 5 + 32) * 10) / 10 - }, - conditions, - humidity: Math.round(Math.random() * 100), - wind: { - speed_kmh: Math.round(Math.random() * 50), - direction: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)]! - } - }; - - return { - content: [ - { - type: 'text', - text: JSON.stringify(structuredContent, null, 2) - } - ], - structuredContent - }; - } -); - -// Another tool example - calculator -const calcInputSchema = toStandardJsonSchema( - v.object({ - operation: v.picklist(['add', 'subtract', 'multiply', 'divide']), - a: v.number(), - b: v.number() + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] }) ); -const calcOutputSchema = toStandardJsonSchema( - v.object({ - result: v.number(), - operation: v.string(), - expression: v.string() - }) -); - -server.registerTool( - 'calculate', - { - description: 'Perform basic arithmetic operations (using Valibot validation)', - inputSchema: calcInputSchema, - outputSchema: calcOutputSchema - }, - async ({ operation, a, b }) => { - let result: number; - switch (operation) { - case 'add': { - result = a + b; - break; - } - case 'subtract': { - result = a - b; - break; - } - case 'multiply': { - result = a * b; - break; - } - case 'divide': { - if (b === 0) { - return { - content: [{ type: 'text', text: 'Error: Division by zero' }], - isError: true - }; - } - result = a / b; - break; - } - } - - const structuredContent = { - result, - operation, - expression: `${a} ${operation} ${b} = ${result}` - }; - - return { - content: [ - { - type: 'text', - text: structuredContent.expression - } - ], - structuredContent - }; - } -); - -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); - console.error('Valibot Example Server running on stdio'); -} - -try { - await main(); -} catch (error) { - console.error('Server error:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index 445649ae8..e93008e1b 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -1,27 +1,15 @@ /** - * Standard JSON Schema utilities for user-provided schemas. - * These types and utilities support any schema library that implements - * the Standard Schema spec (https://standardschema.dev). - * - * Supported libraries include: Zod v4, Valibot, ArkType, and others. + * Standard Schema utilities for user-provided schemas. + * Supports Zod v4, Valibot, ArkType, and other Standard Schema implementations. + * @see https://standardschema.dev */ /* eslint-disable @typescript-eslint/no-namespace */ -// Namespaces are used here to match the Standard Schema spec interface design, -// enabling ergonomic type inference like `StandardJSONSchemaV1.InferOutput`. import type { JsonSchemaType, jsonSchemaValidator } from '../validation/types.js'; -// ============================================================================ -// Standard Schema Interfaces (from https://standardschema.dev) -// ============================================================================ -// These interfaces are copied from the Standard Schema spec to avoid adding -// a dependency. They match the @standard-schema/spec package. +// Standard Schema interfaces (from https://standardschema.dev) -/** - * The base Standard interface for typed schemas. - * @see https://standardschema.dev - */ export interface StandardTypedV1 { readonly '~standard': StandardTypedV1.Props; } @@ -42,10 +30,6 @@ export namespace StandardTypedV1 { export type InferOutput = NonNullable['output']; } -/** - * The Standard Schema interface for schemas that support validation. - * @see https://standardschema.dev - */ export interface StandardSchemaV1 { readonly '~standard': StandardSchemaV1.Props; } @@ -83,11 +67,6 @@ export namespace StandardSchemaV1 { export type InferOutput = StandardTypedV1.InferOutput; } -/** - * The Standard JSON Schema interface for schemas that can be converted to JSON Schema. - * This is the primary interface for user-provided tool and prompt schemas. - * @see https://standardschema.dev/json-schema - */ export interface StandardJSONSchemaV1 { readonly '~standard': StandardJSONSchemaV1.Props; } @@ -113,23 +92,13 @@ export namespace StandardJSONSchemaV1 { export type InferOutput = StandardTypedV1.InferOutput; } -/** - * Combined interface for schemas that implement both StandardSchemaV1 and StandardJSONSchemaV1. - * Zod v4 schemas implement this combined interface. - */ +/** Combined interface for schemas with both validation and JSON Schema conversion (e.g., Zod v4). */ export interface StandardSchemaWithJSON { readonly '~standard': StandardSchemaV1.Props & StandardJSONSchemaV1.Props; } -// ============================================================================ -// Type Guards -// ============================================================================ +// Type guards -/** - * Type guard to check if a value implements StandardJSONSchemaV1 (has jsonSchema conversion). - * This is the primary interface for schemas that can be converted to JSON Schema for the wire protocol. - * Note: Some libraries (e.g., ArkType) use function-based schemas, so we check for both objects and functions. - */ export function isStandardJSONSchema(schema: unknown): schema is StandardJSONSchemaV1 { if (schema == null) return false; const schemaType = typeof schema; @@ -139,11 +108,6 @@ export function isStandardJSONSchema(schema: unknown): schema is StandardJSONSch return typeof std?.jsonSchema?.input === 'function' && typeof std?.jsonSchema?.output === 'function'; } -/** - * Type guard to check if a value implements StandardSchemaV1 (has validate method). - * Schemas that implement this interface can perform native validation. - * Note: Some libraries (e.g., ArkType) use function-based schemas, so we check for both objects and functions. - */ export function isStandardSchema(schema: unknown): schema is StandardSchemaV1 { if (schema == null) return false; const schemaType = typeof schema; @@ -153,68 +117,36 @@ export function isStandardSchema(schema: unknown): schema is StandardSchemaV1 { return typeof std?.validate === 'function'; } -/** - * Type guard to check if a value implements both StandardSchemaV1 and StandardJSONSchemaV1. - * Zod v4 schemas implement this combined interface. - */ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSchemaWithJSON { return isStandardJSONSchema(schema) && isStandardSchema(schema); } -// ============================================================================ -// JSON Schema Conversion -// ============================================================================ +// JSON Schema conversion -/** - * Converts a StandardJSONSchemaV1 to JSON Schema for the wire protocol. - * - * @param schema - A schema implementing StandardJSONSchemaV1 - * @param io - Whether to get the 'input' or 'output' JSON Schema (default: 'input') - * @returns JSON Schema object compatible with Draft 2020-12 - */ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record { return schema['~standard'].jsonSchema[io]({ target: 'draft-2020-12' }); } -// ============================================================================ // Validation -// ============================================================================ -/** - * Result type for Standard Schema validation. - */ export type StandardSchemaValidationResult = { success: true; data: T } | { success: false; error: string }; -/** - * Validates data against a StandardJSONSchemaV1 schema. - * - * If the schema also implements StandardSchemaV1 (has a validate method), uses native validation. - * Otherwise, falls back to JSON Schema validation using the provided validator. - * - * @param schema - A schema implementing StandardJSONSchemaV1 - * @param data - The data to validate - * @param jsonSchemaValidatorInstance - Optional JSON Schema validator for fallback validation - * @returns Validation result with typed data on success or error message on failure - */ export async function validateStandardSchema( schema: T, data: unknown, jsonSchemaValidatorInstance?: jsonSchemaValidator ): Promise>> { - // If schema also implements StandardSchemaV1, use native validation + // Use native validation if available if (isStandardSchema(schema)) { const result = await schema['~standard'].validate(data); - // Per Standard Schema spec: FailureResult has issues array, SuccessResult has value without issues - // Some libraries (e.g., Valibot) always include value, so we check issues first if (result.issues && result.issues.length > 0) { const errorMessage = result.issues.map((i: StandardSchemaV1.Issue) => i.message).join(', '); return { success: false, error: errorMessage }; } - // At this point we have a SuccessResult which has value return { success: true, data: (result as StandardSchemaV1.SuccessResult).value as StandardJSONSchemaV1.InferOutput }; } - // Fall back to JSON Schema validation if validator provided + // Fall back to JSON Schema validation if (jsonSchemaValidatorInstance) { const jsonSchema = standardSchemaToJsonSchema(schema, 'input'); const validator = jsonSchemaValidatorInstance.getValidator>(jsonSchema as JsonSchemaType); @@ -226,22 +158,12 @@ export async function validateStandardSchema( return { success: false, error: validationResult.errorMessage ?? 'Validation failed' }; } - // No validation possible - schema doesn't have validate and no fallback validator - // In this case, we trust the data and return it as-is + // No validation - trust the data return { success: true, data: data as StandardJSONSchemaV1.InferOutput }; } -// ============================================================================ -// Prompt Argument Extraction -// ============================================================================ +// Prompt argument extraction -/** - * Extracts prompt arguments from a StandardJSONSchemaV1 schema. - * Uses JSON Schema introspection to determine argument names, descriptions, and required status. - * - * @param schema - A schema implementing StandardJSONSchemaV1 - * @returns Array of prompt arguments with name, description, and required status - */ export function promptArgumentsFromStandardSchema( schema: StandardJSONSchemaV1 ): Array<{ name: string; description?: string; required: boolean }> { From 6f38754ff3c0c34ade80b31e1c7114ce9fdf0f1a Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 3 Feb 2026 21:19:58 +0000 Subject: [PATCH 08/12] refactor: use schema utility aliases and add deps to catalog - Add arktype, valibot, @valibot/to-json-schema to pnpm catalog - Replace z.safeParse with parseSchema in server.ts - Replace z.output/z.core.$ZodType with SchemaOutput/AnySchema aliases - Remove unused zod imports --- examples/server/package.json | 6 +++--- .../server/src/experimental/tasks/server.ts | 14 ++++++------- packages/server/src/server/server.ts | 7 +++---- pnpm-lock.yaml | 21 +++++++++++++------ pnpm-workspace.yaml | 3 +++ test/integration/package.json | 6 +++--- 6 files changed, 34 insertions(+), 23 deletions(-) diff --git a/examples/server/package.json b/examples/server/package.json index 2a0df7473..4bd313256 100644 --- a/examples/server/package.json +++ b/examples/server/package.json @@ -40,13 +40,13 @@ "@modelcontextprotocol/hono": "workspace:^", "@modelcontextprotocol/node": "workspace:^", "@modelcontextprotocol/server": "workspace:^", - "@valibot/to-json-schema": "^1.5.0", - "arktype": "^2.1.29", + "@valibot/to-json-schema": "catalog:devTools", + "arktype": "catalog:devTools", "better-auth": "^1.4.17", "cors": "catalog:runtimeServerOnly", "express": "catalog:runtimeServerOnly", "hono": "catalog:runtimeServerOnly", - "valibot": "^1.2.0", + "valibot": "catalog:devTools", "zod": "catalog:runtimeShared" }, "devDependencies": { diff --git a/packages/server/src/experimental/tasks/server.ts b/packages/server/src/experimental/tasks/server.ts index 0f63e4969..813f4cf28 100644 --- a/packages/server/src/experimental/tasks/server.ts +++ b/packages/server/src/experimental/tasks/server.ts @@ -13,9 +13,9 @@ import type { Request, RequestOptions, ResponseMessage, - Result + Result, + SchemaOutput } from '@modelcontextprotocol/core'; -import type * as z from 'zod/v4'; import type { Server } from '../../server/server.js'; @@ -52,14 +52,14 @@ export class ExperimentalServerTasks { request: Request, resultSchema: T, options?: RequestOptions - ): AsyncGenerator & Result>, void, void> { + ): AsyncGenerator & Result>, void, void> { // Delegate to the server's underlying Protocol method type ServerWithRequestStream = { requestStream( request: Request, resultSchema: U, options?: RequestOptions - ): AsyncGenerator & Result>, void, void>; + ): AsyncGenerator & Result>, void, void>; }; return (this._server as unknown as ServerWithRequestStream).requestStream(request, resultSchema, options); } @@ -88,14 +88,14 @@ export class ExperimentalServerTasks { * * @experimental */ - async getTaskResult(taskId: string, resultSchema?: T, options?: RequestOptions): Promise> { + async getTaskResult(taskId: string, resultSchema?: T, options?: RequestOptions): Promise> { return ( this._server as unknown as { - getTaskResult: ( + getTaskResult: ( params: { taskId: string }, resultSchema?: U, options?: RequestOptions - ) => Promise>; + ) => Promise>; } ).getTaskResult({ taskId }, resultSchema, options); } diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 2ce4ddb27..613766a58 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -54,7 +54,6 @@ import { SdkErrorCode } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; -import * as z from 'zod/v4'; import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; @@ -120,7 +119,7 @@ export class Server extends Protocol { const transportSessionId: string | undefined = ctx.sessionId || (ctx.http?.req?.headers.get('mcp-session-id') as string) || undefined; const { level } = request.params; - const parseResult = z.safeParse(LoggingLevelSchema, level); + const parseResult = parseSchema(LoggingLevelSchema, level); if (parseResult.success) { this._loggingLevels.set(transportSessionId, parseResult.data); } @@ -214,7 +213,7 @@ export class Server extends Protocol { // When task creation is requested, validate and return CreateTaskResult if (params.task) { - const taskValidationResult = z.safeParse(CreateTaskResultSchema, result); + const taskValidationResult = parseSchema(CreateTaskResultSchema, result); if (!taskValidationResult.success) { const errorMessage = taskValidationResult.error instanceof Error @@ -226,7 +225,7 @@ export class Server extends Protocol { } // For non-task requests, validate against CallToolResultSchema - const validationResult = z.safeParse(CallToolResultSchema, result); + const validationResult = parseSchema(CallToolResultSchema, result); if (!validationResult.success) { const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef18a478b..43c950f67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,12 @@ catalogs: '@typescript/native-preview': specifier: ^7.0.0-dev.20251217.1 version: 7.0.0-dev.20260105.1 + '@valibot/to-json-schema': + specifier: ^1.5.0 + version: 1.5.0 + arktype: + specifier: ^2.1.29 + version: 2.1.29 eslint: specifier: ^9.39.2 version: 9.39.2 @@ -66,6 +72,9 @@ catalogs: typescript-eslint: specifier: ^8.48.1 version: 8.51.0 + valibot: + specifier: ^1.2.0 + version: 1.2.0 vite-tsconfig-paths: specifier: ^5.1.4 version: 5.1.4 @@ -333,10 +342,10 @@ importers: specifier: workspace:^ version: link:../../packages/server '@valibot/to-json-schema': - specifier: ^1.5.0 + specifier: catalog:devTools version: 1.5.0(valibot@1.2.0(typescript@5.9.3)) arktype: - specifier: ^2.1.29 + specifier: catalog:devTools version: 2.1.29 better-auth: specifier: ^1.4.17 @@ -351,7 +360,7 @@ importers: specifier: catalog:runtimeServerOnly version: 4.11.4 valibot: - specifier: ^1.2.0 + specifier: catalog:devTools version: 1.2.0(typescript@5.9.3) zod: specifier: catalog:runtimeShared @@ -935,16 +944,16 @@ importers: specifier: workspace:^ version: link:../../common/vitest-config '@valibot/to-json-schema': - specifier: ^1.5.0 + specifier: catalog:devTools version: 1.5.0(valibot@1.2.0(typescript@5.9.3)) arktype: - specifier: ^2.1.29 + specifier: catalog:devTools version: 2.1.29 supertest: specifier: catalog:devTools version: 7.1.4 valibot: - specifier: ^1.2.0 + specifier: catalog:devTools version: 1.2.0(typescript@5.9.3) vitest: specifier: catalog:devTools diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0eaa89471..1f9ce76c9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,9 @@ packages: catalogs: devTools: '@eslint/js': ^9.39.2 + '@valibot/to-json-schema': ^1.5.0 + arktype: ^2.1.29 + valibot: ^1.2.0 wrangler: ^4.14.4 '@types/content-type': ^1.1.8 '@types/cors': ^2.8.17 diff --git a/test/integration/package.json b/test/integration/package.json index d54c95b50..32e790e8f 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -42,10 +42,10 @@ "@modelcontextprotocol/test-helpers": "workspace:^", "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", - "@valibot/to-json-schema": "^1.5.0", - "arktype": "^2.1.29", + "@valibot/to-json-schema": "catalog:devTools", + "arktype": "catalog:devTools", "supertest": "catalog:devTools", - "valibot": "^1.2.0", + "valibot": "catalog:devTools", "vitest": "catalog:devTools", "wrangler": "catalog:devTools", "zod": "catalog:runtimeShared" From afefc799465e0dc63ff9529c13d98704cc8f6efb Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 3 Feb 2026 21:42:52 +0000 Subject: [PATCH 09/12] docs: clarify that completable() only works with Zod schemas Update comments in extractCompleters to explicitly document that the completable feature only supports Zod schemas due to reliance on Zod-specific .shape property introspection. --- packages/server/src/server/mcp.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 08b5a0c11..4d60fde28 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1206,8 +1206,8 @@ const EMPTY_COMPLETION_RESULT: CompleteResult = { // ============================================================================ // Zod-specific helpers for Completable feature -// These are internal helpers for the completable prompt argument feature -// which requires Zod-specific schema introspection. +// These are internal helpers for the completable prompt argument feature. +// NOTE: completable() only works with Zod schemas due to Zod-specific introspection. // ============================================================================ /** @internal Zod schema shape type for completable introspection */ @@ -1238,8 +1238,8 @@ function unwrapZodOptionalSchema(schema: unknown): unknown { } /** - * @internal Extracts completable callbacks from a schema at registration time. - * This allows completion to work without runtime Zod introspection. + * @internal Extracts completable callbacks from a Zod schema at registration time. + * NOTE: This only works with Zod schemas. ArkType and Valibot are not supported. */ function extractCompleters(schema: StandardJSONSchemaV1 | undefined): Map> | undefined { if (!schema) return undefined; From 79971054a99fee443bcb203c256bec934eb92c49 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 3 Feb 2026 21:49:22 +0000 Subject: [PATCH 10/12] revert: remove extractCompleters optimization, use original completable lookup Revert the completable handling back to the original approach where completable fields are looked up at request time rather than pre-extracted at registration time. This removes: - extractCompleters() function and completers Map optimization - CompleteCallback type import (unused) - completers field from RegisteredPrompt The original approach directly inspects the schema at completion request time using getSchemaShape/isCompletable/getCompleter. --- packages/server/src/server/mcp.ts | 75 ++++++++++++------------------- 1 file changed, 28 insertions(+), 47 deletions(-) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 4d60fde28..798c6ebf6 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -42,7 +42,6 @@ import { import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js'; -import type { CompleteCallback } from './completable.js'; import { getCompleter, isCompletable } from './completable.js'; import type { ServerOptions } from './server.js'; import { Server } from './server.js'; @@ -367,8 +366,17 @@ export class McpServer { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} disabled`); } - // Look up completer from the stored completers map - const completer = prompt.completers?.get(request.params.argument.name); + if (!prompt.argsSchema) { + return EMPTY_COMPLETION_RESULT; + } + + const promptShape = getSchemaShape(prompt.argsSchema); + const field = promptShape?.[request.params.argument.name]; + if (!isCompletable(field)) { + return EMPTY_COMPLETION_RESULT; + } + + const completer = getCompleter(field); if (!completer) { return EMPTY_COMPLETION_RESULT; } @@ -668,14 +676,10 @@ export class McpServer { let currentArgsSchema = argsSchema; let currentCallback = callback; - // Extract completable callbacks from schema at registration time - const completers = extractCompleters(argsSchema); - const registeredPrompt: RegisteredPrompt = { title, description, argsSchema, - completers, handler: createPromptHandler(name, argsSchema, callback), enabled: true, disable: () => registeredPrompt.update({ enabled: false }), @@ -694,8 +698,6 @@ export class McpServer { if (updates.argsSchema !== undefined) { registeredPrompt.argsSchema = updates.argsSchema; currentArgsSchema = updates.argsSchema; - // Re-extract completers when schema changes - registeredPrompt.completers = extractCompleters(updates.argsSchema); needsHandlerRegen = true; } if (updates.callback !== undefined) { @@ -712,9 +714,18 @@ export class McpServer { }; this._registeredPrompts[name] = registeredPrompt; - // Enable completions capability if any completers were found - if (completers && completers.size > 0) { - this.setCompletionRequestHandler(); + // If any argument uses a Completable schema, enable completions capability + if (argsSchema) { + const shape = getSchemaShape(argsSchema); + if (shape) { + const hasCompletable = Object.values(shape).some(field => { + const inner = unwrapOptionalSchema(field); + return isCompletable(inner); + }); + if (hasCompletable) { + this.setCompletionRequestHandler(); + } + } } return registeredPrompt; @@ -1136,8 +1147,6 @@ export type RegisteredPrompt = { title?: string; description?: string; argsSchema?: StandardJSONSchemaV1; - /** @internal Completable callbacks keyed by argument name */ - completers?: Map>; /** @internal */ handler: PromptHandler; enabled: boolean; @@ -1210,54 +1219,26 @@ const EMPTY_COMPLETION_RESULT: CompleteResult = { // NOTE: completable() only works with Zod schemas due to Zod-specific introspection. // ============================================================================ -/** @internal Zod schema shape type for completable introspection */ -type ZodSchemaShape = Record; - /** @internal Gets the shape of a Zod object schema */ -function getZodSchemaShape(schema: unknown): ZodSchemaShape | undefined { +function getSchemaShape(schema: unknown): Record | undefined { const candidate = schema as { shape?: unknown }; if (candidate.shape && typeof candidate.shape === 'object') { - return candidate.shape as ZodSchemaShape; + return candidate.shape as Record; } return undefined; } /** @internal Checks if a Zod schema is optional */ -function isZodOptionalSchema(schema: unknown): boolean { +function isOptionalSchema(schema: unknown): boolean { const candidate = schema as { type?: string }; return candidate.type === 'optional'; } /** @internal Unwraps an optional Zod schema */ -function unwrapZodOptionalSchema(schema: unknown): unknown { - if (!isZodOptionalSchema(schema)) { +function unwrapOptionalSchema(schema: unknown): unknown { + if (!isOptionalSchema(schema)) { return schema; } const candidate = schema as { def?: { innerType?: unknown } }; return candidate.def?.innerType ?? schema; } - -/** - * @internal Extracts completable callbacks from a Zod schema at registration time. - * NOTE: This only works with Zod schemas. ArkType and Valibot are not supported. - */ -function extractCompleters(schema: StandardJSONSchemaV1 | undefined): Map> | undefined { - if (!schema) return undefined; - - const shape = getZodSchemaShape(schema); - if (!shape) return undefined; - - const completers = new Map>(); - - for (const [argName, field] of Object.entries(shape)) { - const inner = unwrapZodOptionalSchema(field); - if (isCompletable(inner)) { - const callback = getCompleter(inner); - if (callback) { - completers.set(argName, callback as unknown as CompleteCallback); - } - } - } - - return completers.size > 0 ? completers : undefined; -} From c4cfd8e7d376238ca151f1e7b8a68466e870e9b9 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 3 Feb 2026 21:49:44 +0000 Subject: [PATCH 11/12] style: remove verbose comment block --- packages/server/src/server/mcp.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 798c6ebf6..8f0e3bba6 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1213,12 +1213,6 @@ const EMPTY_COMPLETION_RESULT: CompleteResult = { } }; -// ============================================================================ -// Zod-specific helpers for Completable feature -// These are internal helpers for the completable prompt argument feature. -// NOTE: completable() only works with Zod schemas due to Zod-specific introspection. -// ============================================================================ - /** @internal Gets the shape of a Zod object schema */ function getSchemaShape(schema: unknown): Record | undefined { const candidate = schema as { shape?: unknown }; From cb1e1ef437a7cae81448e01899ed733805de17d0 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 3 Feb 2026 21:53:37 +0000 Subject: [PATCH 12/12] refactor: remove promptArgumentsFromSchema wrapper, use imported function directly --- packages/server/src/server/mcp.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 8f0e3bba6..03500adec 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -516,7 +516,7 @@ export class McpServer { name, title: prompt.title, description: prompt.description, - arguments: prompt.argsSchema ? promptArgumentsFromSchema(prompt.argsSchema) : undefined + arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined }; }) }) @@ -1191,10 +1191,6 @@ function createPromptHandler( } } -function promptArgumentsFromSchema(schema: StandardJSONSchemaV1): PromptArgument[] { - return promptArgumentsFromStandardSchema(schema); -} - function createCompletionResult(suggestions: readonly unknown[]): CompleteResult { const values = suggestions.map(String).slice(0, 100); return {