diff --git a/examples/server/package.json b/examples/server/package.json index f86cc1375..4bd313256 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": "catalog:devTools", + "arktype": "catalog:devTools", "better-auth": "^1.4.17", "cors": "catalog:runtimeServerOnly", "express": "catalog:runtimeServerOnly", "hono": "catalog:runtimeServerOnly", + "valibot": "catalog:devTools", "zod": "catalog:runtimeShared" }, "devDependencies": { diff --git a/examples/server/src/arktypeExample.ts b/examples/server/src/arktypeExample.ts new file mode 100644 index 000000000..ff96a334a --- /dev/null +++ b/examples/server/src/arktypeExample.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env node +/** + * 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: 'arktype-example', + version: '1.0.0' +}); + +// Register a tool with ArkType schema +server.registerTool( + 'greet', + { + description: 'Generate a greeting', + inputSchema: type({ name: 'string' }) + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) +); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/examples/server/src/valibotExample.ts b/examples/server/src/valibotExample.ts new file mode 100644 index 000000000..46ab793c0 --- /dev/null +++ b/examples/server/src/valibotExample.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/** + * 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'; +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import * as v from 'valibot'; + +const server = new McpServer({ + name: 'valibot-example', + version: '1.0.0' +}); + +// Register a tool with Valibot schema +server.registerTool( + 'greet', + { + description: 'Generate a greeting', + inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) +); + +const transport = new StdioServerTransport(); +await server.connect(transport); 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/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..e93008e1b --- /dev/null +++ b/packages/core/src/util/standardSchema.ts @@ -0,0 +1,179 @@ +/** + * 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 */ + +import type { JsonSchemaType, jsonSchemaValidator } from '../validation/types.js'; + +// Standard Schema interfaces (from 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']; +} + +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; +} + +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 with both validation and JSON Schema conversion (e.g., Zod v4). */ +export interface StandardSchemaWithJSON { + readonly '~standard': StandardSchemaV1.Props & StandardJSONSchemaV1.Props; +} + +// Type guards + +export function isStandardJSONSchema(schema: unknown): schema is StandardJSONSchemaV1 { + 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'; +} + +export function isStandardSchema(schema: unknown): schema is StandardSchemaV1 { + 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'; +} + +export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSchemaWithJSON { + return isStandardJSONSchema(schema) && isStandardSchema(schema); +} + +// JSON Schema conversion + +export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record { + return schema['~standard'].jsonSchema[io]({ target: 'draft-2020-12' }); +} + +// Validation + +export type StandardSchemaValidationResult = { success: true; data: T } | { success: false; error: string }; + +export async function validateStandardSchema( + schema: T, + data: unknown, + jsonSchemaValidatorInstance?: jsonSchemaValidator +): Promise>> { + // Use native validation if available + if (isStandardSchema(schema)) { + const result = await schema['~standard'].validate(data); + if (result.issues && result.issues.length > 0) { + const errorMessage = result.issues.map((i: StandardSchemaV1.Issue) => i.message).join(', '); + return { success: false, error: errorMessage }; + } + return { success: true, data: (result as StandardSchemaV1.SuccessResult).value as StandardJSONSchemaV1.InferOutput }; + } + + // Fall back to JSON Schema validation + 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 - trust the data + return { success: true, data: data as StandardJSONSchemaV1.InferOutput }; +} + +// Prompt argument extraction + +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..26cffb8c6 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,18 +23,17 @@ 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, +export type TaskRequestHandler = BaseToolCallback< + SendResultT, TaskServerContext, Args >; @@ -43,7 +42,7 @@ export type TaskRequestHandler { +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..0a8aae234 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,7 @@ export class ExperimentalMcpServerTasks { handler: ToolTaskHandler ): RegisteredTool; - registerToolTask( + registerToolTask( name: string, config: { title?: string; @@ -133,7 +133,7 @@ export class ExperimentalMcpServerTasks { config.annotations, execution, config._meta, - handler as AnyToolHandler + handler as AnyToolHandler ); } } diff --git a/packages/server/src/server/completable.ts b/packages/server/src/server/completable.ts index 240e66e1f..df822711d 100644 --- a/packages/server/src/server/completable.ts +++ b/packages/server/src/server/completable.ts @@ -1,27 +1,26 @@ -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 +33,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..03500adec 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,16 +31,13 @@ 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'; @@ -132,7 +128,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 +136,7 @@ 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 +156,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 +224,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 +271,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}` ); } } @@ -388,6 +380,7 @@ export class McpServer { if (!completer) { return EMPTY_COMPLETION_RESULT; } + const suggestions = await completer(request.params.argument.value, request.params.context); return createCompletionResult(suggestions); } @@ -523,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 }; }) }) @@ -676,8 +669,8 @@ 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; @@ -708,7 +701,7 @@ export class McpServer { needsHandlerRegen = true; } if (updates.callback !== undefined) { - currentCallback = updates.callback as PromptCallback; + currentCallback = updates.callback as PromptCallback; needsHandlerRegen = true; } if (needsHandlerRegen) { @@ -742,12 +735,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 +781,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 +806,7 @@ export class McpServer { /** * Registers a tool with a config object and callback. */ - registerTool( + registerTool( name: string, config: { title?: string; @@ -840,14 +833,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 +860,7 @@ export class McpServer { title, description, argsSchema, - cb as PromptCallback + cb as PromptCallback ); this.setPromptRequestHandlers(); @@ -980,19 +973,23 @@ 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 +999,12 @@ type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; - handler: AnyToolHandler; + handler: AnyToolHandler; /** @internal */ executor: ToolExecutor; enabled: boolean; @@ -1017,11 +1014,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 +1029,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 +1127,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 +1146,13 @@ type TaskHandlerInternal = { export type RegisteredPrompt = { title?: string; description?: string; - argsSchema?: AnySchema; + argsSchema?: StandardJSONSchemaV1; /** @internal */ handler: PromptHandler; enabled: boolean; enable(): void; disable(): void; - update(updates: { + update(updates: { name?: string | null; title?: string; description?: string; @@ -1169,19 +1169,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,18 +1191,6 @@ 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 createCompletionResult(suggestions: readonly unknown[]): CompleteResult { const values = suggestions.map(String).slice(0, 100); return { @@ -1221,3 +1208,27 @@ const EMPTY_COMPLETION_RESULT: CompleteResult = { hasMore: false } }; + +/** @internal Gets the shape of a Zod object schema */ +function getSchemaShape(schema: unknown): Record | undefined { + const candidate = schema as { shape?: unknown }; + if (candidate.shape && typeof candidate.shape === 'object') { + return candidate.shape as Record; + } + return undefined; +} + +/** @internal Checks if a Zod schema is optional */ +function isOptionalSchema(schema: unknown): boolean { + const candidate = schema as { type?: string }; + return candidate.type === 'optional'; +} + +/** @internal Unwraps an optional Zod schema */ +function unwrapOptionalSchema(schema: unknown): unknown { + if (!isOptionalSchema(schema)) { + return schema; + } + const candidate = schema as { def?: { innerType?: unknown } }; + return candidate.def?.innerType ?? schema; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e51ed15ed..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 @@ -332,6 +341,12 @@ importers: '@modelcontextprotocol/server': specifier: workspace:^ version: link:../../packages/server + '@valibot/to-json-schema': + specifier: catalog:devTools + version: 1.5.0(valibot@1.2.0(typescript@5.9.3)) + arktype: + specifier: catalog:devTools + 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 +359,9 @@ importers: hono: specifier: catalog:runtimeServerOnly version: 4.11.4 + valibot: + specifier: catalog:devTools + version: 1.2.0(typescript@5.9.3) zod: specifier: catalog:runtimeShared version: 4.3.5 @@ -925,9 +943,18 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@valibot/to-json-schema': + specifier: catalog:devTools + version: 1.5.0(valibot@1.2.0(typescript@5.9.3)) + arktype: + specifier: catalog:devTools + version: 2.1.29 supertest: specifier: catalog:devTools version: 7.1.4 + valibot: + specifier: catalog:devTools + 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) @@ -940,6 +967,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 +2385,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 +2469,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 +4384,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 +4584,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 +5767,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 +5855,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 +8006,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)): 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 f4b97e4ca..32e790e8f 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": "catalog:devTools", + "arktype": "catalog:devTools", + "supertest": "catalog:devTools", + "valibot": "catalog:devTools", + "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..a53616969 --- /dev/null +++ b/test/integration/test/standardSchema.test.ts @@ -0,0 +1,666 @@ +/** + * Integration tests for Standard Schema support (StandardJSONSchemaV1) + * Tests ArkType and Valibot schemas with the MCP SDK + */ + +import { Client } from '@modelcontextprotocol/client'; +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'; +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'); + }); + }); +});