Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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:
Expand Down
6 changes: 1 addition & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 14 additions & 2 deletions src/decorators/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, MCPTool>
resources: Map<string, MCPResource>
prompts: Map<string, MCPPrompt>
resourceHandlers: ResourceHandlers
jsonSchemaValidator?: JsonSchemaValidator
}

const mcpDecoratorsPlugin: FastifyPluginAsync<MCPDecoratorsOptions> = 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', (
Expand All @@ -36,6 +38,16 @@ const mcpDecoratorsPlugin: FastifyPluginAsync<MCPDecoratorsOptions> = 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
Expand Down
19 changes: 18 additions & 1 deletion src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/routes/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -25,10 +26,11 @@ interface MCPPubSubRoutesOptions {
localStreams: Map<string, Set<any>>
taskStore?: TaskStore
taskWaiters?: TaskWaiters
jsonSchemaValidator?: JsonSchemaValidator
}

const mcpPubSubRoutesPlugin: FastifyPluginAsync<MCPPubSubRoutesOptions> = 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

Expand Down Expand Up @@ -285,6 +287,7 @@ const mcpPubSubRoutesPlugin: FastifyPluginAsync<MCPPubSubRoutesOptions> = async
sessionStore,
taskStore,
taskWaiters,
jsonSchemaValidator,
sessionId,
protocolVersion: (request as any).mcpProtocolVersion ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION
})
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import type { Options } from 'ajv'
import type {
JSONRPCMessage,
JSONRPCNotification,
Expand Down Expand Up @@ -226,6 +227,12 @@ export interface MCPPluginOptions {
toolName: string,
context: ToolAccessContext
) => boolean | Promise<boolean>
/**
* 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?: {
Expand Down
1 change: 1 addition & 0 deletions src/validation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
65 changes: 65 additions & 0 deletions src/validation/json-schema-validator.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createJsonSchemaValidator>

/**
* 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
})
Comment thread
rozzilla marked this conversation as resolved.
Comment thread
rozzilla marked this conversation as resolved.

// Compiled validator cache, mirroring the TypeBox cache in validator.ts
const compiledValidators = new Map<string, ReturnType<typeof ajv.compile>>()

/**
* 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 }
}
26 changes: 26 additions & 0 deletions test-d/index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,3 +330,29 @@ expectAssignable<MCPPluginOptions>({ canAccessTool: async () => false })
// Non-boolean returns and non-function values are rejected
expectNotAssignable<MCPPluginOptions>({ canAccessTool: () => 'yes' })
expectNotAssignable<MCPPluginOptions>({ canAccessTool: true })

// ─── Plugin options ─────────────────────────────────────────────────

// validateJsonSchemaInputs is an optional object
expectAssignable<MCPPluginOptions>({ validateJsonSchemaInputs: {} })

expectAssignable<MCPPluginOptions>({
validateJsonSchemaInputs: {
allErrors: true,
useDefaults: false
}
})

expectAssignable<MCPPluginOptions>({})

expectNotAssignable<MCPPluginOptions>({
validateJsonSchemaInputs: true
})

expectNotAssignable<MCPPluginOptions>({
validateJsonSchemaInputs: false
})

expectNotAssignable<MCPPluginOptions>({
validateJsonSchemaInputs: 'yes'
})
Loading