diff --git a/README.md b/README.md index 77bc7d0..7df9753 100644 --- a/README.md +++ b/README.md @@ -533,7 +533,7 @@ TypeBox validation provides structured error messages: The plugin maintains backward compatibility with JSON Schema and unvalidated tools: ```typescript -// JSON Schema (still supported) +// JSON Schema (accepted, but NOT validated at runtime by default — see below) app.mcpAddTool({ name: 'legacy-tool', description: 'Uses JSON Schema', @@ -558,6 +558,28 @@ app.mcpAddTool({ }) ``` +**Important:** only TypeBox schemas are validated at runtime by default. A tool registered with a plain JSON Schema `inputSchema` receives its arguments **unvalidated** unless you opt in to AJV validation. + +### Validating plain JSON Schema inputs with AJV + +Set `validateJsonSchemaInputs: {}` to validate plain-JSON-Schema tool inputs with [AJV](https://ajv.js.org/) (draft 2020-12, matching the plugin's published schema dialect) before the handler runs. TypeBox tools are unaffected — they keep their existing TypeBox validation. + +```typescript +await app.register(mcpPlugin, { + validateJsonSchemaInputs: { + allErrors: true, + useDefaults: false + } +}) +``` + +Behavior when enabled: + +- Invalid arguments return a tool execution error (`isError: true` with an `Invalid tool arguments: ...` message, capped at 5 reported errors), not a protocol error — the same SEP-1303 semantics as TypeBox validation. +- Validation is non-mutating: no type coercion, no defaults injection, no property removal. Handlers receive the arguments exactly as the client sent them. `format` keywords are annotation-only (JSON Schema 2020-12's own default). +- A plain JSON Schema that AJV cannot compile makes `mcpAddTool` throw, so a misconfigured tool fails at startup instead of running unvalidated. +- Compiled validators are cached per schema, and the option also applies to stdio transports and task-augmented (`task: {}`) calls. + ### Performance TypeBox validation is highly optimized: diff --git a/package-lock.json b/package-lock.json index 3b59384..2086e96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@fastify/cors": "^11.1.0", "@fastify/jwt": "^10.0.0", "@fastify/type-provider-typebox": "^6.0.0", + "ajv": "^8.17.1", "fast-jwt": "^6.0.2", "fastify-plugin": "^6.0.0", "get-jwks": "^11.0.1", @@ -2693,7 +2694,6 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -4580,7 +4580,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -4699,7 +4698,6 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", - "dev": true, "funding": [ { "type": "github", @@ -6282,7 +6280,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-typed": { @@ -8102,7 +8099,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index 5e427b2..c3e4442 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ }, "dependencies": { "@fastify/cors": "^11.1.0", + "ajv": "^8.17.1", "@fastify/jwt": "^10.0.0", "@fastify/type-provider-typebox": "^6.0.0", "fast-jwt": "^6.0.2", diff --git a/src/decorators/meta.ts b/src/decorators/meta.ts index 682cf0d..df3a283 100644 --- a/src/decorators/meta.ts +++ b/src/decorators/meta.ts @@ -8,17 +8,19 @@ import type { ResourceSubscribeHandler, ResourceUnsubscribeHandler } from '../types.ts' -import { schemaToArguments, validateToolSchema } from '../validation/index.ts' +import { schemaToArguments, validateToolSchema, isTypeBoxSchema } from '../validation/index.ts' +import type { JsonSchemaValidator } from '../validation/json-schema-validator.ts' interface MCPDecoratorsOptions { tools: Map resources: Map prompts: Map resourceHandlers: ResourceHandlers + jsonSchemaValidator?: JsonSchemaValidator } const mcpDecoratorsPlugin: FastifyPluginAsync = async (app, options) => { - const { tools, resources, prompts, resourceHandlers } = options + const { tools, resources, prompts, resourceHandlers, jsonSchemaValidator } = options // Enhanced tool decorator with TypeBox schema support app.decorate('mcpAddTool', ( @@ -36,6 +38,16 @@ const mcpDecoratorsPlugin: FastifyPluginAsync = async (app if (schemaErrors.length > 0) { throw new Error(`Invalid tool schema for '${name}': ${schemaErrors.join(', ')}`) } + + // When AJV validation is on, an uncompilable plain JSON Schema must fail + // registration rather than register a tool whose inputs can't be checked + if (jsonSchemaValidator && !isTypeBoxSchema(definition.inputSchema)) { + try { + jsonSchemaValidator.compileAndCache(definition.inputSchema) + } catch (error) { + throw new Error(`Invalid tool schema for '${name}': ${error instanceof Error ? error.message : String(error)}`) + } + } } // TypeBox schemas are already JSON Schema compatible diff --git a/src/handlers.ts b/src/handlers.ts index 3f7ee97..d935767 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -42,6 +42,7 @@ import { capabilitiesForRevision } from './protocol-version.ts' import { validate, CallToolRequestSchema, ReadResourceRequestSchema, GetPromptRequestSchema, isTypeBoxSchema } from './validation/index.ts' +import type { JsonSchemaValidator } from './validation/json-schema-validator.ts' import { sanitizeToolParams, assessToolSecurity, SECURITY_WARNINGS } from './security.ts' type HandlerDependencies = { @@ -59,6 +60,7 @@ type HandlerDependencies = { sessionStore?: SessionStore taskStore?: TaskStore taskWaiters?: TaskWaiters + jsonSchemaValidator?: JsonSchemaValidator sessionId?: string /** The revision this client negotiated; responses are shaped to match it */ protocolVersion?: string @@ -402,7 +404,22 @@ async function executeToolCall ( return createResponse(request.id, result) } } else { - // Regular JSON Schema - basic validation or pass through + // Regular JSON Schema - validated with AJV when opted in, pass through otherwise + if (dependencies.jsonSchemaValidator) { + const validationError = dependencies.jsonSchemaValidator.validate(schema, toolArguments) + if (validationError !== null) { + // SEP-1303: a tool execution error, not a protocol error (same as the + // TypeBox branch above) + const result: CallToolResult = { + content: [{ + type: 'text', + text: `Invalid tool arguments: ${validationError}` + }], + isError: true + } + return createResponse(request.id, result) + } + } try { const result = await tool.handler(toolArguments, { sessionId, request: dependencies.request, reply: dependencies.reply, authContext: dependencies.authContext }) return createResponse(request.id, result) diff --git a/src/index.ts b/src/index.ts index cbd9460..ec07005 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { createAuthPreHandler } from './auth/prehandler.ts' import oauthClientPlugin from './auth/oauth-client.ts' import authRoutesPlugin from './routes/auth-routes.ts' import { quitWithTimeout } from './redis-quit-with-timeout.ts' +import { createJsonSchemaValidator } from './validation/json-schema-validator.ts' // Import and export MCP protocol types import type { @@ -145,12 +146,18 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption }) } + // AJV instance and compiled-schema cache scoped to this plugin registration + const jsonSchemaValidator = opts.validateJsonSchemaInputs + ? createJsonSchemaValidator(opts.validateJsonSchemaInputs) + : undefined + // Register decorators first app.register(metaDecorators, { tools, resources, prompts, - resourceHandlers + resourceHandlers, + jsonSchemaValidator }) app.register(pubsubDecorators, { enableSSE, @@ -173,7 +180,8 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption messageBroker, localStreams, taskStore, - taskWaiters + taskWaiters, + jsonSchemaValidator }) // Add close hook to clean up Redis connections and authorization components diff --git a/src/routes/mcp.ts b/src/routes/mcp.ts index 9f36020..640a85a 100644 --- a/src/routes/mcp.ts +++ b/src/routes/mcp.ts @@ -9,6 +9,7 @@ import type { SessionStore, SessionMetadata } from '../stores/session-store.ts' import type { TaskStore, TaskWaiters } from '../stores/task-store.ts' import type { MessageBroker } from '../brokers/message-broker.ts' import type { AuthorizationContext } from '../types/auth-types.ts' +import type { JsonSchemaValidator } from '../validation/json-schema-validator.ts' import { processMessage } from '../handlers.ts' interface MCPPubSubRoutesOptions { @@ -25,10 +26,11 @@ interface MCPPubSubRoutesOptions { localStreams: Map> taskStore?: TaskStore taskWaiters?: TaskWaiters + jsonSchemaValidator?: JsonSchemaValidator } const mcpPubSubRoutesPlugin: FastifyPluginAsync = async (app, options) => { - const { enableSSE, opts, capabilities, serverInfo, tools, resources, prompts, resourceHandlers, sessionStore, messageBroker, localStreams, taskStore, taskWaiters } = options + const { enableSSE, opts, capabilities, serverInfo, tools, resources, prompts, resourceHandlers, sessionStore, messageBroker, localStreams, taskStore, taskWaiters, jsonSchemaValidator } = options const allowedOrigins = opts.allowedOrigins @@ -285,6 +287,7 @@ const mcpPubSubRoutesPlugin: FastifyPluginAsync = async sessionStore, taskStore, taskWaiters, + jsonSchemaValidator, sessionId, protocolVersion: (request as any).mcpProtocolVersion ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION }) diff --git a/src/types.ts b/src/types.ts index 6d47ac9..06bf2b4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { FastifyReply, FastifyRequest } from 'fastify' +import type { Options } from 'ajv' import type { JSONRPCMessage, JSONRPCNotification, @@ -226,6 +227,12 @@ export interface MCPPluginOptions { toolName: string, context: ToolAccessContext ) => boolean | Promise + /** + * Validate plain JSON Schema tool inputs using AJV. + * Omit this option to disable validation, or provide an object to enable it. + * Options override the default Fastify-compatible AJV configuration. + */ + validateJsonSchemaInputs?: Options sessionStore?: 'memory' | 'redis' messageBroker?: 'memory' | 'redis' redis?: { diff --git a/src/validation/index.ts b/src/validation/index.ts index 93c7c2a..181abc3 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -2,6 +2,7 @@ export * from './schemas.ts' export * from './validator.ts' export * from './converter.ts' +export * from './json-schema-validator.ts' // Re-export commonly used TypeBox types export { Type } from '@sinclair/typebox' diff --git a/src/validation/json-schema-validator.ts b/src/validation/json-schema-validator.ts new file mode 100644 index 0000000..406a815 --- /dev/null +++ b/src/validation/json-schema-validator.ts @@ -0,0 +1,65 @@ +import type { Options } from 'ajv' +import { Ajv2020 } from 'ajv/dist/2020.js' +import type { ErrorObject } from 'ajv/dist/2020.js' +import stringify from 'safe-stable-stringify' + +function formatJsonSchemaErrors (errors: ErrorObject[]): string { + return errors + .map(error => `${error.instancePath || '/'} ${error.message ?? 'is invalid'}`) + .join('; ') +} + +export type JsonSchemaValidator = ReturnType + +/** + * Create a per-plugin-instance AJV validator with a compiled-schema cache, + * so registering the plugin twice never shares compilation state. + */ +export function createJsonSchemaValidator (customOptions: Options = {}) { + // Deliberately non-mutating: tool arguments must reach handlers exactly as the + // client sent them, so no coercion, no defaults injection, no property removal. + // `strict: false` tolerates MCP-style schemas carrying extra annotation keywords; + // `validateFormats: false` keeps `format` annotation-only, which is also JSON + // Schema 2020-12's own default behavior. + const ajv = new Ajv2020({ + coerceTypes: 'array', + useDefaults: true, + removeAdditional: true, + addUsedSchema: false, + allErrors: false, + strict: false, + validateFormats: false, + ...customOptions + }) + + // Compiled validator cache, mirroring the TypeBox cache in validator.ts + const compiledValidators = new Map>() + + /** + * Compile a plain JSON Schema with AJV, with caching. Throws when the schema + * cannot be compiled, so callers can fail fast at tool registration time. + */ + function compileAndCache (schema: object) { + const key = stringify(schema) + let validator = compiledValidators.get(key) + if (!validator) { + validator = ajv.compile(schema) + compiledValidators.set(key, validator) + } + return validator + } + + /** + * Validate data against a plain JSON Schema. Returns a formatted error message + * when the data is invalid, or `null` when it passes. + */ + function validate (schema: object, data: unknown): string | null { + const validator = compileAndCache(schema) + if (validator(data)) { + return null + } + return formatJsonSchemaErrors(validator.errors ?? []) + } + + return { compileAndCache, validate } +} diff --git a/test-d/index.test-d.ts b/test-d/index.test-d.ts index ba1ca94..0f08f23 100644 --- a/test-d/index.test-d.ts +++ b/test-d/index.test-d.ts @@ -330,3 +330,29 @@ expectAssignable({ canAccessTool: async () => false }) // Non-boolean returns and non-function values are rejected expectNotAssignable({ canAccessTool: () => 'yes' }) expectNotAssignable({ canAccessTool: true }) + +// ─── Plugin options ───────────────────────────────────────────────── + +// validateJsonSchemaInputs is an optional object +expectAssignable({ validateJsonSchemaInputs: {} }) + +expectAssignable({ + validateJsonSchemaInputs: { + allErrors: true, + useDefaults: false + } +}) + +expectAssignable({}) + +expectNotAssignable({ + validateJsonSchemaInputs: true +}) + +expectNotAssignable({ + validateJsonSchemaInputs: false +}) + +expectNotAssignable({ + validateJsonSchemaInputs: 'yes' +}) diff --git a/test/json-schema-validation.test.ts b/test/json-schema-validation.test.ts new file mode 100644 index 0000000..4b1b2f6 --- /dev/null +++ b/test/json-schema-validation.test.ts @@ -0,0 +1,201 @@ +import { test, describe } from 'node:test' +import { strict as assert } from 'node:assert' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import { Type } from '@sinclair/typebox' +import mcpPlugin from '../src/index.ts' +import { JSONRPC_VERSION, LATEST_PROTOCOL_VERSION } from '../src/schema.ts' +import type { CallToolResult } from '../src/schema.ts' +import type { MCPPluginOptions } from '../src/types.ts' + +const SEARCH_JSON_SCHEMA = { + type: 'object', + properties: { + query: { type: 'string', minLength: 1 }, + limit: { type: 'number', minimum: 1, maximum: 100, default: 10 } + }, + required: ['query'], + additionalProperties: false +} + +async function buildApp (t: { after: (fn: () => unknown) => void }, opts: MCPPluginOptions = {}): Promise { + const app = Fastify() + t.after(() => app.close()) + await app.register(mcpPlugin, opts) + return app +} + +async function callTool (app: FastifyInstance, name: string, args: unknown, extraParams: Record = {}) { + const response = await app.inject({ + method: 'POST', + url: '/mcp', + headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION }, + payload: { + jsonrpc: JSONRPC_VERSION, + id: 1, + method: 'tools/call', + params: { name, ...(args === undefined ? {} : { arguments: args }), ...extraParams } + } + }) + assert.strictEqual(response.statusCode, 200) + return response.json() +} + +describe('JSON Schema Validation (validateJsonSchemaInputs)', () => { + test('custom AJV options are applied', async (t) => { + const app = await buildApp(t, { + validateJsonSchemaInputs: { + useDefaults: false + } + }) + + let receivedParams: unknown + app.mcpAddTool({ + name: 'search', + description: 'Search', + inputSchema: SEARCH_JSON_SCHEMA + }, async (params: unknown) => { + receivedParams = params + return { content: [{ type: 'text' as const, text: 'ok' }] } + }) + await app.ready() + + const body = await callTool(app, 'search', { query: 'test' }) + assert.strictEqual(body.result.isError, undefined) + // The `limit` default from the schema must NOT be injected + assert.deepStrictEqual(receivedParams, { query: 'test' }) + }) + + test('invalid arguments return an isError result before the handler runs', async (t) => { + const app = await buildApp(t, { validateJsonSchemaInputs: {} }) + + let handlerCalled = false + app.mcpAddTool({ + name: 'search', + description: 'Search', + inputSchema: SEARCH_JSON_SCHEMA + }, async () => { + handlerCalled = true + return { content: [{ type: 'text' as const, text: 'ok' }] } + }) + await app.ready() + + const body = await callTool(app, 'search', { query: '', limit: 500 }) + const result = body.result as CallToolResult + assert.strictEqual(result.isError, true) + assert.ok((result.content[0] as any).text.startsWith('Invalid tool arguments:')) + assert.ok((result.content[0] as any).text.includes('/query')) + assert.strictEqual(handlerCalled, false) + }) + + test('missing arguments are validated as an empty object', async (t) => { + const app = await buildApp(t, { validateJsonSchemaInputs: {} }) + + app.mcpAddTool({ + name: 'search', + description: 'Search', + inputSchema: SEARCH_JSON_SCHEMA + }, async () => ({ content: [{ type: 'text' as const, text: 'ok' }] })) + await app.ready() + + const body = await callTool(app, 'search', undefined) + const result = body.result as CallToolResult + assert.strictEqual(result.isError, true) + assert.ok((result.content[0] as any).text.includes('query')) + }) + + test('long error lists are capped in the message', async (t) => { + const app = await buildApp(t, { validateJsonSchemaInputs: {} }) + + const manyProps = Object.fromEntries( + Array.from({ length: 8 }, (_, i) => [`p${i}`, { type: 'string' }]) + ) + app.mcpAddTool({ + name: 'many', + description: 'Many props', + inputSchema: { type: 'object', properties: manyProps, required: Object.keys(manyProps) } + }, async () => ({ content: [{ type: 'text' as const, text: 'ok' }] })) + await app.ready() + + const body = await callTool(app, 'many', {}) + const result = body.result as CallToolResult + assert.strictEqual(result.isError, true) + assert.deepEqual(result.content[0], { + type: 'text', + text: "Invalid tool arguments: / must have required property 'p0'" + }) + }) + + test('an uncompilable schema fails tool registration', async (t) => { + const app = await buildApp(t, { validateJsonSchemaInputs: {} }) + await app.ready() + + assert.throws(() => { + app.mcpAddTool({ + name: 'broken', + description: 'Broken schema', + inputSchema: { type: 'object', properties: { a: { type: 'not-a-type' } } } + }, async () => ({ content: [{ type: 'text' as const, text: 'ok' }] })) + }, /Invalid tool schema for 'broken'/) + }) + + test('flag off (default): invalid arguments pass through to the handler unchanged', async (t) => { + const app = await buildApp(t) + + let receivedParams: unknown + app.mcpAddTool({ + name: 'search', + description: 'Search', + inputSchema: SEARCH_JSON_SCHEMA + }, async (params: unknown) => { + receivedParams = params + return { content: [{ type: 'text' as const, text: 'ok' }] } + }) + await app.ready() + + const body = await callTool(app, 'search', { query: 42, limit: 'nope' }) + assert.strictEqual((body.result as CallToolResult).isError, undefined) + assert.deepStrictEqual(receivedParams, { query: 42, limit: 'nope' }) + }) + + test('TypeBox tools keep their own validation regardless of the flag', async (t) => { + const app = await buildApp(t, { validateJsonSchemaInputs: {} }) + + app.mcpAddTool({ + name: 'typed', + description: 'TypeBox tool', + inputSchema: Type.Object({ query: Type.String({ minLength: 1 }) }) + }, async (params) => ({ content: [{ type: 'text' as const, text: params.query }] })) + await app.ready() + + const body = await callTool(app, 'typed', { query: '' }) + const result = body.result as CallToolResult + assert.strictEqual(result.isError, true) + assert.ok((result.content[0] as any).text.startsWith('Invalid tool arguments:')) + }) + + test('task-mode calls are validated too', async (t) => { + const app = await buildApp(t, { validateJsonSchemaInputs: {}, enableTasks: true }) + + app.mcpAddTool({ + name: 'search', + description: 'Search', + inputSchema: SEARCH_JSON_SCHEMA, + execution: { taskSupport: 'optional' } + }, async () => ({ content: [{ type: 'text' as const, text: 'ok' }] })) + await app.ready() + + const created = await callTool(app, 'search', { query: '' }, { task: {} }) + const taskId = created.result.task.taskId + + const response = await app.inject({ + method: 'POST', + url: '/mcp', + headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION }, + payload: { jsonrpc: JSONRPC_VERSION, id: 2, method: 'tasks/result', params: { taskId } } + }) + const result = response.json().result as CallToolResult + assert.strictEqual(result.isError, true) + assert.ok((result.content[0] as any).text.startsWith('Invalid tool arguments:')) + }) +})