npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@platformatic/mcp

v2.6.0

Published

Scalable Fastify adapter for the Model Context Protocol (MCP)

Readme

Fastify MCP Server

A Fastify plugin that implements the Model Context Protocol (MCP) server using JSON-RPC 2.0. This plugin enables Fastify applications to expose tools, resources, and prompts following the MCP 2025-06-18 specification with full elicitation support.

Installation

npm install @platformatic/mcp

TypeBox Support (Optional)

For type-safe schema validation, install TypeBox:

npm install @sinclair/typebox

Features

  • Complete MCP 2025-11-25 Support: Implements the current Model Context Protocol revision, negotiating down to 2025-06-18, 2025-03-26 and 2024-11-05 for older clients
  • Tasks (experimental): Task-augmented tool calls with polling, deferred result retrieval and cancellation
  • Elicitation Support: Server-to-client information requests in both form and URL mode, with schema validation
  • Icons: Optional icon metadata on tools, resources, resource templates and prompts
  • TypeBox Validation: Type-safe schema validation with automatic TypeScript inference
  • Security Enhancements: Input sanitization, rate limiting, and security assessment
  • Multiple Transport Support: HTTP/SSE and stdio transports for flexible communication
  • SSE Streaming: Server-Sent Events for real-time communication
  • Horizontal Scaling: Redis-backed session management and message broadcasting
  • Session Persistence: Message history and reconnection support with Last-Event-ID
  • Memory & Redis Backends: Seamless switching between local and distributed storage
  • Production Ready: Comprehensive test coverage, security features, and authentication support

Quick Start

import Fastify from 'fastify'
import mcpPlugin from '@platformatic/mcp'
// Or use named import:
// import { mcpPlugin } from '@platformatic/mcp'

const app = Fastify({ logger: true })

// Register the MCP plugin
await app.register(mcpPlugin, {
  serverInfo: {
    name: 'my-mcp-server',
    version: '1.0.0'
  },
  capabilities: {
    tools: { listChanged: true },
    resources: { subscribe: true },
    prompts: {}
  },
  instructions: 'This server provides custom tools and resources'
})

// Add tools, resources, and prompts with handlers
app.mcpAddTool({
  name: 'calculator',
  description: 'Performs basic arithmetic operations',
  inputSchema: {
    type: 'object',
    properties: {
      operation: { type: 'string', enum: ['add', 'subtract', 'multiply', 'divide'] },
      a: { type: 'number' },
      b: { type: 'number' }
    },
    required: ['operation', 'a', 'b']
  }
}, async (params) => {
  const { operation, a, b } = params
  let result
  switch (operation) {
    case 'add': result = a + b; break
    case 'subtract': result = a - b; break
    case 'multiply': result = a * b; break
    case 'divide': result = a / b; break
    default: throw new Error('Invalid operation')
  }
  return {
    content: [{ type: 'text', text: `Result: ${result}` }]
  }
})

app.mcpAddResource({
  uri: 'file://config.json',
  name: 'Application Config',
  description: 'Server configuration file',
  mimeType: 'application/json'
}, async (uri, context) => {
  // Read and return the configuration file
  const config = { setting1: 'value1', setting2: 'value2' }
  return {
    contents: [{
      uri,
      text: JSON.stringify(config, null, 2),
      mimeType: 'application/json'
    }]
  }
})

app.mcpAddPrompt({
  name: 'code-review',
  description: 'Generates code review comments',
  arguments: [{
    name: 'language',
    description: 'Programming language',
    required: true
  }]
}, async (name, args, context) => {
  const language = args?.language || 'javascript'
  return {
    messages: [{
      role: 'user',
      content: {
        type: 'text',
        text: `Please review this ${language} code for best practices, potential bugs, and improvements.`
      }
    }]
  }
})

await app.listen({ port: 3000 })

In-process MCP Client

The plugin decorates the Fastify instance with mcpClient(), a client that talks to the server through app.inject() — no port binding, so it's equally useful for tests or for driving the server from other in-process code:

import { test } from 'node:test'
import assert from 'node:assert/strict'
import Fastify from 'fastify'
import type { FastifyInstance } from 'fastify'
import mcpPlugin from '@platformatic/mcp'

test('calls an MCP tool', async (t) => {
  const app: FastifyInstance = Fastify()
  t.after(() => app.close())

  await app.register(mcpPlugin, { enableSSE: true })

  app.mcpAddTool({
    name: 'echo',
    description: 'Echo a message',
    inputSchema: {
      type: 'object',
      properties: {
        message: { type: 'string' }
      },
      required: ['message'],
      additionalProperties: false
    }
  }, async ({ message }) => {
    return {
      content: [{ type: 'text', text: message }]
    }
  })

  await app.ready()

  const client = app.mcpClient()

  await client.initialize()

  const response = await client.callTool('echo', {
    message: 'hello'
  })

  assert.equal(response.statusCode, 200)
  assert.ok('result' in response.body)
  assert.deepEqual(response.body.result, {
    content: [{ type: 'text', text: 'hello' }]
  })
})

The client:

  • Uses app.inject() only (no port binding).
  • Manages sequential JSON-RPC request IDs per client instance.
  • initialize() performs the complete MCP lifecycle handshake (initialize plus notifications/initialized).
  • initialize() rejects when notifications/initialized is not accepted with an empty 202 or 204 response.
  • Commits mcp-session-id and negotiated protocol version only after the full initialization handshake succeeds.
  • Forwards headers passed to initialize() to both lifecycle requests, except MCP-managed mcp-session-id and mcp-protocol-version on notifications/initialized.
  • Captures committed mcp-session-id from successful initialization and sends it automatically on later requests.
  • Lets you pass custom headers (including authorization) globally or per request.

Responses are a discriminated union — narrow with 'result' in response.body or 'error' in response.body. Malformed or non-JSON-RPC responses throw a coded Fastify error instead of returning.

Note: no OAuth/JWT credentials are generated for you.

Protocol Version Negotiation

The server answers initialize with the client's requested revision when it is one it supports, and otherwise offers the newest one it has:

import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from '@platformatic/mcp'

LATEST_PROTOCOL_VERSION      // '2025-11-25'
SUPPORTED_PROTOCOL_VERSIONS  // ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']

On the HTTP transport, requests after initialize must carry the agreed revision in the MCP-Protocol-Version header. An unsupported value is answered with 400; an absent header is treated as 2025-03-26, which predates the header.

Responses are shaped to the revision the client negotiated. A client on an older revision never sees a field or method that revision does not define:

| | < 2025-11-25 | 2025-11-25 | |---|---|---| | tasks capability | not advertised | advertised | | tasks/* methods | -32601 method not found | available | | task field on tools/call | ignored | honoured | | icons on listings | stripped | present | | execution.taskSupport | stripped | present | | $schema on tool schemas | omitted | JSON Schema 2020-12 | | URL mode elicitation | refused | available |

The negotiated revision is stored on the session and is authoritative: a request whose MCP-Protocol-Version header contradicts what the session agreed is rejected with 400, so a client cannot opt into newer behaviour after negotiating an older revision. When the header is omitted the session's revision is used in preference to the 2025-03-26 default. initialize is exempt, so a client may re-negotiate on an existing session.

A request sent without an Mcp-Session-Id has no session to remember the negotiation, so it is judged solely by its header. A client that omits it falls back to 2025-03-26 and will not see 2025-11-25 features. Compliant clients always send the header.

Sessions

Session management is part of the Streamable HTTP transport and works whether or not SSE is enabled:

  • initialize on POST /mcp creates a session and returns its id in the Mcp-Session-Id response header. Clients send it back on later requests.
  • DELETE /mcp with the Mcp-Session-Id header terminates the session (204), answering 404 for an unknown session and 400 when the header is missing.
  • enableSSE only controls GET /mcp. Without it, GET /mcp answers 405 with Allow: POST, DELETE.

With SSE disabled, requests that do not carry an Mcp-Session-Id keep working statelessly and no session is created for them. With SSE enabled, every request without a known session id gets a new session, as before.

Sessions expire after sessionTtlMs milliseconds without activity (default 3600000, one hour), so clients that disconnect without sending DELETE do not leave sessions behind. Every request carrying the session id, every message stored for it, and every heartbeat on an open SSE stream counts as activity, so a session in use is never expired from under a live connection. The Redis store applies this as the key TTL; the memory store checks it on every read and sweeps expired sessions on an unref'd timer that is cleared when the server closes.

await app.register(mcpPlugin, {
  sessionTtlMs: 15 * 60_000 // expire sessions after 15 idle minutes
})

Request Body Errors

Body-parser failures and bodies that are not JSON-RPC messages on POST /mcp are answered with a JSON-RPC error (with id: null) while keeping the HTTP status code:

| Cause | Status | JSON-RPC error | | --- | --- | --- | | Malformed or empty JSON | 400 | -32700 Parse error | | Body larger than Fastify's bodyLimit | 413 | -32600 Invalid Request | | Unsupported Content-Type | 415 | -32600 Invalid Request | | Not a JSON-RPC request, notification or response (null, a number, a string, a text/plain body, or an object with none of method, result or error) | 400 | -32600 Invalid Request |

A body that is not a JSON-RPC message is rejected before any SSE session is created for it. A JSON-RPC response posted by the client is answered with 202 and handed to onClientResponse.

The body-parser error handler is scoped to the POST /mcp route. Any other error on that route, and every error on your other routes, still goes to your own (or Fastify's default) error handler.

Origin Validation

Browser clients can be protected against DNS rebinding by allow-listing origins. A rejected origin is answered with 403:

await app.register(mcpPlugin, {
  allowedOrigins: ['https://app.example.com'] // omit to disable, '*' or true to allow any
})

Requests without an Origin header are always accepted — the header is set by browsers, so its absence means the request did not come from one.

allowedOrigins must be true, '*' or an array of strings; any other value (including a single origin string) makes registration throw. Wrap a single origin in an array.

CORS

The plugin does not set CORS headers on /mcp; browser clients need the application to configure CORS for it, for example with @fastify/cors:

import cors from '@fastify/cors'

await app.register(cors, {
  origin: ['https://app.example.com'],
  methods: ['GET', 'POST', 'DELETE'],
  allowedHeaders: [
    'Content-Type',
    'Authorization',
    'Last-Event-ID',
    'Mcp-Session-Id',
    'Mcp-Protocol-Version',
    'Mcp-Method',
    'Mcp-Name'
  ],
  exposedHeaders: ['Mcp-Session-Id']
})
await app.register(mcpPlugin, { /* ... */ })

allowedHeaders does not accept wildcards, so list any Mcp-Param-* headers your clients send. When authorization is enabled, the /.well-known/* discovery endpoints answer cross-origin requests from any origin on their own; that CORS handling is scoped to those routes and does not affect the rest of the application.

Tasks (MCP 2025-11-25, experimental)

Tasks let a tool call return immediately with a task handle while the work continues in the background. The client then polls tasks/get and collects the result with tasks/result.

Enable the feature on the plugin, then opt individual tools in with execution.taskSupport:

await app.register(mcpPlugin, { enableTasks: true })

app.mcpAddTool({
  name: 'generate-report',
  description: 'Builds a large report',
  inputSchema: Type.Object({ month: Type.String() }),
  execution: { taskSupport: 'optional' } // 'forbidden' (default) | 'optional' | 'required'
}, async (params) => {
  return { content: [{ type: 'text', text: await buildReport(params.month) }] }
})

A client opts in per call by adding a task field:

// -> tools/call
{ "name": "generate-report", "arguments": { "month": "July" }, "task": { "ttl": 60000 } }
// <- CreateTaskResult
{ "task": { "taskId": "…", "status": "working", "ttl": 60000, "pollInterval": 1000 } }

Supported operations: tasks/get, tasks/result (blocks until the task is terminal), tasks/list and tasks/cancel, plus optional notifications/tasks/status pushes over SSE. A blocked tasks/result stops waiting when its client disconnects, and when the server closes it answers with a JSON-RPC -32603 "Server shutting down" error, so app.close() never waits on a task running on another instance.

Retention defaults to 60 seconds when the client does not request a ttl. Tasks are meant for long-running work, so if your tools can outlive that, raise the bounds — otherwise a task can expire before it finishes and its tasks/result returns "not found":

await app.register(mcpPlugin, {
  enableTasks: true,
  taskDefaultTtlMs: 15 * 60_000, // retention when the client requests no ttl
  taskMaxTtlMs: 60 * 60_000      // ceiling on a client-requested ttl
})

A client-requested ttl must be a non-negative integer number of milliseconds; anything else (a string, a negative or fractional number) is rejected with -32602 Invalid params. A valid ttl is clamped between 1 second and taskMaxTtlMs, and the effective value is returned in the CreateTaskResult.

Tasks are stored in memory by default and in Redis when a redis option is given, so any instance can serve a poll for a task created on another.

tasks/list returns the caller's tasks newest first, 50 per page. When more remain, the result carries an opaque nextCursor; pass it back as params.cursor to fetch the next page. An unrecognised cursor is rejected with -32602 (Invalid params). The Redis store keeps a per-owner index, so a listing only reads the caller's own tasks.

Custom TaskStore implementations must implement list(owner, { cursor, limit }), where owner is { subject, issuer } and must match a record's authSubject and authIssuer exactly, and return { tasks, nextCursor? }, throwing InvalidTaskCursorError for a cursor they did not issue.

Each caller (token issuer and subject) may keep at most taskMaxRetainedPerPrincipal tasks stored (default 100). Finished tasks count until their ttl expires, so one caller cannot fill the store with results it never collects. When a caller is at its quota, its oldest finished task is evicted to make room; if all of them are still running, the tools/call fails with -32029 "Task limit reached". Without authorization every caller shares a single quota, so raise it if you expect many concurrent anonymous tasks. The in-memory store also holds at most 1000 tasks in total; when full, it evicts the caller's oldest finished task, then the oldest finished task of any caller, before refusing. The Redis store enforces the quota atomically with a per-caller sorted set (mcp:tasks:owner:<issuer>:<subject>, each part percent-encoded, or mcp:tasks:anonymous), so it holds across instances. Custom TaskStore implementations should throw TaskLimitError from create() to produce the same response.

The instance running a task holds a lease on it and renews it every third of taskLeaseMs (default 30 seconds). If that instance dies or shuts down before the task finishes, renewals stop. Once the lease lapses, the next tasks/get or tasks/result (including one already waiting) marks the task failed with the status message "Task owner lost", so the task no longer reads working until its ttl runs out. A caller at taskMaxRetainedPerPrincipal gets the same treatment for their orphaned tasks when they create a new one, so tasks nobody polls cannot keep them locked out of their quota. The check is a compare-and-set, so a renewal or completion that lands first always wins. With Redis, the lease is set and checked against the Redis server clock, so clock skew between instances can never fail a task that is still running, nor delay failing one whose owner is gone:

await app.register(mcpPlugin, {
  enableTasks: true,
  taskMaxRetainedPerPrincipal: 20, // tasks each caller may keep, finished ones included
  taskLeaseMs: 30_000 // a dead instance's tasks are failed this long after its last renewal
})

Security: when authorization is enabled, tasks are bound to the issuer and sub of the token that created them (a subject is only unique per issuer, so both must match, as for sessions) and a requestor can only reach its own. Another user's task answers tasks/get, tasks/result and tasks/cancel exactly like an unknown task id, and never appears in tasks/list. Tasks created by a version that only recorded the subject have no issuer; with authorization enabled they are unreachable and simply expire with their ttl. Without authorization no requestor can be identified, so tasks are reachable by anyone holding the (random UUID) task id, and tasks/list is both unadvertised and refused — otherwise it would hand every anonymous task's id to any caller and defeat that model.

Elicitation Support (MCP 2025-11-25)

The plugin supports the elicitation capability, allowing servers to request structured information from clients. This enables dynamic data collection with schema validation.

Two modes are available. Form mode collects structured data through the client, and URL mode sends the user out of band for anything sensitive — credentials, payments, third-party OAuth — so it never passes through the MCP client:

// URL mode: returns the elicitation id, or null if the request could not be sent
const elicitationId = await app.mcpElicitUrl(
  sessionId,
  'Authorize access to your Example Co files',
  'https://mcp.example.com/connect'
)

// Later, once the out-of-band interaction finishes
if (elicitationId) {
  await app.mcpNotifyElicitationComplete(sessionId, elicitationId)
}

Servers MUST NOT request passwords, API keys or payment details through form mode — use URL mode for those. URLs are rejected if they are malformed, use a scheme other than http(s), or embed credentials.

Basic Elicitation

Elicitation is a client capability: there is nothing to declare in the server's capabilities. The client opts in by sending capabilities: { elicitation: {} } (form mode only), or { elicitation: { form: {}, url: {} } }, in its initialize request. mcpElicit() returns false, and mcpElicitUrl() null, when the client did not declare the mode being requested, as the spec forbids sending it.

The request is delivered over the session's SSE stream, so the client must have it open (GET /mcp). The client answers by POSTing a JSON-RPC response with the same id; the plugin acknowledges it with 202 and passes it to onClientResponse.

import { Type } from '@sinclair/typebox'

// Register plugin with elicitation support
await app.register(mcpPlugin, {
  enableSSE: true, // Required for elicitation
  // Receives the client's answer (an ElicitResult) to each elicitation request
  onClientResponse: async (response, { sessionId }) => {
    if ('result' in response) {
      app.log.info({ sessionId, id: response.id, result: response.result }, 'Elicitation answered')
    }
  }
})

// In your tool handler, request information from the client
app.mcpAddTool({
  name: 'collect-user-info',
  description: 'Collect user information',
  inputSchema: Type.Object({})
}, async (_params, { sessionId }) => {
  if (!sessionId) {
    return { 
      content: [{ type: 'text', text: 'No session available' }],
      isError: true 
    }
  }

  // Request user details with schema validation
  const success = await app.mcpElicit(sessionId, 'Please enter your details', {
    type: 'object',
    properties: {
      name: { 
        type: 'string', 
        description: 'Your full name',
        minLength: 1,
        maxLength: 100
      },
      email: {
        type: 'string',
        description: 'Your email address',
        format: 'email'
      },
      age: {
        type: 'integer',
        description: 'Your age',
        minimum: 0,
        maximum: 150
      },
      preferences: {
        type: 'array',
        items: {
          type: 'string',
          enum: ['newsletter', 'updates', 'marketing']
        },
        description: 'Communication preferences'
      }
    },
    required: ['name', 'email']
  })

  if (success) {
    return { 
      content: [{ type: 'text', text: 'Information request sent to client' }] 
    }
  } else {
    return { 
      content: [{ type: 'text', text: 'Failed to send elicitation request' }],
      isError: true 
    }
  }
})

Advanced Elicitation with Custom Request IDs

// Request with custom ID for tracking
const requestId = 'user-profile-123'
const success = await app.mcpElicit(
  sessionId, 
  'Complete your profile setup',
  {
    type: 'object',
    properties: {
      avatar: { type: 'string', description: 'Avatar URL' },
      bio: { type: 'string', maxLength: 500, description: 'Short bio' },
      skills: {
        type: 'array',
        items: {
          type: 'string',
          enum: ['typescript', 'rust', 'go', 'python']
        },
        maxItems: 4,
        description: 'Your skills'
      }
    }
  },
  requestId
)

Security Considerations for Elicitation

⚠️ Important Security Notes:

  • Elicitation requests are automatically validated for length and complexity
  • Schema depth and size are limited to prevent DoS attacks
  • Rate limiting should be implemented for production use
  • Always validate client responses before using the data
  • Never request sensitive information without explicit user consent

TypeBox Schema Validation

The plugin supports TypeBox schemas for type-safe validation with automatic TypeScript inference. This eliminates the need for manual type definitions and provides compile-time type checking.

Benefits

  • Type Safety: Automatic TypeScript type inference from schemas
  • Runtime Validation: Input validation with structured error messages
  • Zero Duplication: Single source of truth for both types and validation
  • IDE Support: Full autocomplete and IntelliSense for validated parameters
  • Performance: Compiled validators with caching for optimal performance

Basic Usage

import { Type } from '@sinclair/typebox'
import Fastify from 'fastify'
import mcpPlugin from '@platformatic/mcp'

const app = Fastify({ logger: true })

await app.register(mcpPlugin, {
  serverInfo: { name: 'my-server', version: '1.0.0' },
  capabilities: { tools: {} }
})

// Define TypeBox schema
const SearchToolSchema = Type.Object({
  query: Type.String({ minLength: 1, description: 'Search query' }),
  limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100, description: 'Maximum results' })),
  filters: Type.Optional(Type.Array(Type.String(), { description: 'Filter criteria' }))
})

// Register tool with TypeBox schema
app.mcpAddTool({
  name: 'search',
  description: 'Search for files',
  inputSchema: SearchToolSchema
}, async (params) => {
  // params is automatically typed as:
  // {
  //   query: string;
  //   limit?: number;
  //   filters?: string[];
  // }
  const { query, limit = 10, filters = [] } = params
  
  return {
    content: [{
      type: 'text',
      text: `Searching for "${query}" with limit ${limit} and filters: ${filters.join(', ')}`
    }]
  }
})

Schema Types

Tool Input Schemas

// Complex nested schema
const ComplexToolSchema = Type.Object({
  user: Type.Object({
    name: Type.String(),
    age: Type.Number({ minimum: 0 })
  }),
  preferences: Type.Object({
    theme: Type.Union([
      Type.Literal('light'),
      Type.Literal('dark'),
      Type.Literal('auto')
    ]),
    notifications: Type.Boolean()
  }),
  tags: Type.Array(Type.String())
})

app.mcpAddTool({
  name: 'update-profile',
  description: 'Update user profile',
  inputSchema: ComplexToolSchema
}, async (params) => {
  // Fully typed nested object
  const { user, preferences, tags } = params
  return { content: [{ type: 'text', text: `Updated profile for ${user.name}` }] }
})

Resource URI Schemas

// URI validation schema
const FileUriSchema = Type.String({
  pattern: '^file://.+',
  description: 'File URI pattern'
})

app.mcpAddResource({
  uriPattern: 'file://documents/*',
  name: 'Document Files',
  description: 'Access document files',
  uriSchema: FileUriSchema
}, async (uri, context) => {
  // uri is validated against the schema
  const content = await readFile(uri)
  return {
    contents: [{ uri, text: content, mimeType: 'text/plain' }]
  }
})

Prompt Argument Schemas

// Prompt with automatic argument generation
const CodeReviewSchema = Type.Object({
  language: Type.Union([
    Type.Literal('javascript'),
    Type.Literal('typescript'),
    Type.Literal('python')
  ], { description: 'Programming language' }),
  complexity: Type.Optional(Type.Union([
    Type.Literal('low'),
    Type.Literal('medium'),
    Type.Literal('high')
  ], { description: 'Code complexity level' }))
})

app.mcpAddPrompt({
  name: 'code-review',
  description: 'Generate code review',
  argumentSchema: CodeReviewSchema
  // arguments array is automatically generated from schema
}, async (name, args, context) => {
  // args is typed as: { language: 'javascript' | 'typescript' | 'python', complexity?: 'low' | 'medium' | 'high' }
  return {
    messages: [{
      role: 'user',
      content: {
        type: 'text',
        text: `Review this ${args.language} code with ${args.complexity || 'medium'} complexity`
      }
    }]
  }
})

Error Handling

TypeBox validation provides structured error messages:

// When validation fails, structured errors are returned:
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "isError": true,
    "content": [{
      "type": "text",
      "text": "Invalid tool arguments: Validation failed with 2 errors:\n/query: Expected string, received number\n/limit: Expected number <= 100, received 150"
    }]
  }
}

Prompts and resources report failures as JSON-RPC errors instead of successful results:

  • prompts/get returns -32602 (Invalid params) when the arguments fail the TypeBox argumentSchema, or, for prompts without a TypeBox schema, when an argument declared with required: true in arguments is missing. The handler is not invoked.
  • prompts/get returns -32603 (Internal error) with the message Prompt execution failed: <error.message> when the prompt handler throws.
  • resources/read returns -32602 when the URI fails the resource's uriSchema, and -32603 with Resource read failed: <error.message> when the resource handler throws.

Handler errors are logged on the request logger; only the error message is sent to the client, never the stack trace.

Backward Compatibility

The plugin maintains backward compatibility with JSON Schema and unvalidated tools:

// JSON Schema (accepted, but NOT validated at runtime by default — see below)
app.mcpAddTool({
  name: 'legacy-tool',
  description: 'Uses JSON Schema',
  inputSchema: {
    type: 'object',
    properties: {
      param: { type: 'string' }
    }
  }
}, async (params) => {
  // params is typed as 'any'
  return { content: [{ type: 'text', text: 'OK' }] }
})

// Unvalidated tool (unsafe)
app.mcpAddTool({
  name: 'unsafe-tool',
  description: 'No validation'
}, async (params) => {
  // params is typed as 'any' - no validation performed
  return { content: [{ type: 'text', text: 'OK' }] }
})

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 (draft 2020-12, matching the plugin's published schema dialect) before the handler runs. TypeBox tools are unaffected — they keep their existing TypeBox validation.

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:

  • Compiled Validators: Schemas are compiled to optimized validation functions
  • Caching: Compiled validators are cached for reuse
  • Minimal Overhead: Less than 1ms validation overhead for typical schemas
  • Memory Efficient: Shared validator instances across requests

Server-Sent Events (SSE) Support

This plugin supports the MCP Streamable HTTP transport specification, enabling both regular JSON responses and Server-Sent Events for streaming communication.

SSE Configuration

await app.register(mcpPlugin, {
  enableSSE: true, // Enable SSE support (default: false)
  // Optionally close streams after a while so clients fall back to polling and
  // resume with Last-Event-ID (SEP-1699). Omit to keep streams open indefinitely.
  sseMaxConnectionMs: 300_000,
  // ... other options
})

When authorization is enabled, the access token is only checked when a GET /mcp stream opens. So that a stream never outlives that token, the server closes it at the token's exp (or after sseMaxConnectionMs, whichever comes first). The client then reconnects with a fresh token and resumes with Last-Event-ID. Tokens without an exp claim are bounded only by sseMaxConnectionMs, so set it if your authorization server issues them or if you want revocation and scope changes to take effect within a bounded time.

Redis Configuration for Horizontal Scaling

The plugin supports Redis-backed session management and message broadcasting for horizontal scaling across multiple server instances.

Why Redis is Critical for Scalability

Without Redis (Memory-only):

  • Each server instance maintains isolated session stores
  • SSE connections are tied to specific server instances
  • No cross-instance message broadcasting
  • Session data is lost when servers restart
  • Load balancers can't route clients to different instances

With Redis (Distributed):

  • Shared Session State: All instances access the same session data from Redis
  • Cross-Instance Broadcasting: Messages sent from any instance reach all connected clients
  • Session Persistence: Sessions survive server restarts, expiring after one idle hour by default (sessionTtlMs)
  • High Availability: Clients can reconnect to any instance and resume from last event
  • True Horizontal Scaling: Add more instances without architectural changes

This transforms the plugin from a single-instance application into a distributed system capable of serving thousands of concurrent SSE connections with real-time global synchronization.

Redis Setup

import Fastify from 'fastify'
import mcpPlugin from '@platformatic/mcp'

const app = Fastify({ logger: true })

await app.register(mcpPlugin, {
  enableSSE: true,
  redis: {
    host: process.env.REDIS_HOST || 'localhost',
    port: parseInt(process.env.REDIS_PORT || '6379'),
    db: parseInt(process.env.REDIS_DB || '0'),
    password: process.env.REDIS_PASSWORD,
    // Any other ioredis RedisOptions are accepted and passed through
    maxRetriesPerRequest: 3,
    retryStrategy: (times) => Math.min(times * 100, 2000)
  },
  serverInfo: {
    name: 'scalable-mcp-server',
    version: '1.0.0'
  }
})

Message Broker Exports

MemoryMessageBroker, RedisMessageBroker, and the MessageBroker interface are exported from the package's public entry point, so you can use them directly without deep-importing into dist/brokers/...:

import { MessageBroker, MemoryMessageBroker, RedisMessageBroker } from '@platformatic/mcp'

const broker: MessageBroker = new MemoryMessageBroker()
// or: new RedisMessageBroker(redis)
  • MessageBroker: the pub/sub interface the plugin uses internally to deliver messages to SSE clients — publish(topic, message), subscribe(topic, handler), unsubscribe(topic), and close().
  • MemoryMessageBroker: in-process implementation backed by MQEmitter. Used automatically when no redis option is passed; messages only reach clients connected to the same server instance.
  • RedisMessageBroker: distributed implementation backed by mqemitter-redis. Used automatically when the redis option is passed; publishes go through Redis so messages reach clients connected to any server instance.

These are the same broker classes the plugin instantiates internally based on the redis option. Exporting them lets you construct one directly for custom pub/sub logic, tests, or scripts that need to publish/subscribe without spinning up the full plugin.

Session Store Exports

MemorySessionStore, RedisSessionStore, and the SessionStore/SessionMetadata types are exported from the package's public entry point, matching the broker and task store exports:

import { SessionStore, MemorySessionStore, RedisSessionStore } from '@platformatic/mcp'

const store: SessionStore = new MemorySessionStore()
// or: new RedisSessionStore({ redis })
  • SessionStore: the interface the plugin uses to persist SSE session metadata, message history, token-to-session mappings, and per-session resource subscriptions (addResourceSubscription / removeResourceSubscription, exposed as resourceSubscriptions on the metadata). The optional touch(sessionId) records activity so the session is not expired while in use.
  • MemorySessionStore: in-process implementation used automatically when no redis option is passed. Takes an optional maxMessages history cap (default 100) and { sessionTtlMs } idle expiry (default 1 hour); call cleanup() periodically to free expired sessions when using it outside the plugin.
  • RedisSessionStore: distributed implementation used automatically when the redis option is passed; sessions and message history live in Redis so any instance can serve any client.

These are the same store classes the plugin instantiates internally based on the redis option. Exporting them lets you construct or inspect one directly in tests and scripts.

Multi-Instance Deployment

With Redis configuration, you can run multiple instances of your MCP server:

# Instance 1
PORT=3000 REDIS_HOST=redis.example.com node server.js

# Instance 2  
PORT=3001 REDIS_HOST=redis.example.com node server.js

# Instance 3
PORT=3002 REDIS_HOST=redis.example.com node server.js

Graceful Shutdown

RedisMessageBroker.close() is bounded: it waits up to closeTimeoutMs (default 2000ms) for mqemitter-redis to close its Redis connections gracefully, then falls back to forcibly disconnecting them so Fastify's onClose hooks - and the process shutdown deadline enforced by most orchestrators - are never blocked by a Redis outage or a stuck reconnect. Pass closeTimeoutMs and onCloseTimeout as a second argument to RedisMessageBroker to customize this behavior, e.g. to log when a shutdown had to fall back to the forced path.

Connection Errors

A dropped Redis connection (for example during a Redis restart or failover) does not crash the process: the plugin logs errors from its Redis client and from the message broker's pub/sub connections through the Fastify logger, and ioredis keeps reconnecting. When constructing RedisMessageBroker yourself, pass an onError callback in its options to observe these errors.

Session Persistence Features

Automatic Session Management:

  • Sessions persist across server restarts
  • Idle session TTL (sessionTtlMs, default 1 hour) with automatic cleanup
  • Message history stored in Redis Streams

Message Replay:

// Client reconnection with Last-Event-ID
const eventSource = new EventSource('/mcp', {
  headers: { 
    'Accept': 'text/event-stream',
    'Last-Event-ID': '1234' // Resume from this event
  }
})

Cross-Instance Broadcasting:

// Any server instance can broadcast to all connected clients
app.mcpBroadcastNotification({
  jsonrpc: '2.0',
  method: 'notifications/message',
  params: { message: 'Global update from instance 2' }
})

// Send to specific session (works across instances)
app.mcpSendToSession('session-xyz', {
  jsonrpc: '2.0',
  method: 'notifications/progress',
  params: { progress: 75 }
})

Implementation Design Decisions

This plugin implements certain behaviors that differ from the strict MCP specification to provide enhanced scalability and resilience in production environments:

Multiple Connections per Session

MCP Specification Intent: The official MCP specification (line 144-145) states that servers "MUST send each of its JSON-RPC messages on only one of the connected streams; that is, it MUST NOT broadcast the same message across multiple streams."

Our Implementation Choice: We allow multiple SSE connections to share the same session ID and broadcast session-specific messages (mcpSendToSession) to all connections within that session.

Why We Made This Choice:

  1. Horizontal Scalability: In distributed deployments with Redis, sessions can span multiple server instances. Broadcasting ensures messages reach all connections regardless of which instance they're connected to.

  2. Connection Resilience: If a client has multiple connections (e.g., main application + background tools), both can receive notifications even if one connection fails.

  3. Tool Integration: External tools can join existing sessions by providing the same mcp-session-id header, enabling seamless integration with ongoing workflows.

  4. Message Persistence: All connections in a session benefit from the same message history and can replay missed events using Last-Event-ID.

Trade-off: This approach may deliver duplicate messages to multiple connections within the same session, but the benefits in production reliability and scalability outweigh this consideration for most use cases.

Dual-Endpoint Architecture

The plugin uses a dual-endpoint architecture that separates regular MCP protocol communication from SSE streaming:

sequenceDiagram
    participant C1 as Client 1
    participant Tool as Tool/Client 2
    participant Server as Fastify MCP Server
    participant Redis as Redis (Optional)
    participant Broker as Message Broker

    Note over C1,Broker: Session Creation and Multiple Connection Support
    
    C1->>Server: POST /mcp (initialize)
    Server->>C1: Response + Session ID: abc123
    
    C1->>Server: GET /mcp (SSE stream)
    Note right of C1: Accept: text/event-stream<br/>mcp-session-id: abc123
    Server-->>C1: SSE connection established
    
    Note over Tool,Broker: Tool Joins SAME Session (Implementation Choice)
    
    Tool->>Server: POST /mcp (any request)
    Note right of Tool: mcp-session-id: abc123<br/>(reuses existing session)
    Server->>Tool: Response (no new session created)
    
    Tool->>Server: GET /mcp (SSE stream)
    Note right of Tool: Accept: text/event-stream<br/>mcp-session-id: abc123
    Server-->>Tool: SSE connection established
    
    rect rgb(255, 240, 240)
        Note over Server: Implementation Detail:<br/>localStreams.get('abc123') = Set{C1, Tool}<br/>Multiple connections share same session
    end
    
    Note over C1,Broker: Session-Specific Broadcasting (Implementation Choice)
    
    rect rgb(240, 248, 255)
        Note over C1,Tool: Our Implementation: mcpSendToSession('abc123')<br/>broadcasts to ALL connections in session abc123<br/>(Differs from MCP spec for scalability)
        Server->>Broker: mcpSendToSession('abc123', message)
        alt Redis Mode (Cross-Instance)
            Broker->>Redis: Publish to mcp/session/abc123/message
            Redis-->>Broker: Distribute to instance with session abc123
        else Memory Mode
            Broker->>Broker: Route to session abc123 locally
        end
        Broker-->>Server: Forward to ALL streams in session abc123
        Server-->>C1: SSE notification (shared session)
        Server-->>Tool: SSE notification (shared session)
    end
    
    Note over C1,Broker: Reconnection Resilience
    
    Tool->>Server: Disconnect (network issue)
    Note over Server: Session abc123 persists with C1 still connected
    
    Server->>Broker: mcpSendToSession('abc123', message)
    Broker-->>Server: Deliver to remaining connections
    Server-->>C1: SSE notification continues
    
    Tool->>Server: GET /mcp (reconnect)
    Note right of Tool: mcp-session-id: abc123<br/>Last-Event-ID: 15
    alt Redis Mode
        Server->>Redis: Get message history for session abc123
        Redis-->>Server: Return events 16-20
    else Memory Mode
        Server->>Server: Get local history for session abc123
    end
    Server-->>Tool: Replay missed events
    Server-->>Tool: Resume receiving notifications
    
    Note over C1,Broker: Cross-Instance Scalability
    
    rect rgb(240, 255, 240)
        Note over Server,Redis: With Redis: Session abc123 can be accessed<br/>from any server instance. Messages route<br/>to the instance hosting the SSE connections.
        Server->>Broker: mcpSendToSession('abc123', message)<br/>(from any instance)
        Broker->>Redis: Publish to mcp/session/abc123/message
        Redis-->>Broker: Route to correct instance
        Broker-->>Server: Deliver to session abc123 connections
        Server-->>C1: SSE notification
        Server-->>Tool: SSE notification
    end

POST /mcp - MCP Protocol Communication

Handles all standard MCP protocol requests and returns JSON responses:

// Regular MCP request (always returns JSON)
const response = await fetch('/mcp', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json'
  },
  body: JSON.stringify({ 
    jsonrpc: '2.0', 
    method: 'tools/call',
    params: { name: 'my-tool', arguments: {} },
    id: 1 
  })
})
const result = await response.json()

GET /mcp - SSE Stream Establishment

Establishes long-lived Server-Sent Events streams for real-time communication:

// Establish SSE stream (separate from protocol requests)
const eventSource = new EventSource('/mcp', {
  headers: { 
    'Accept': 'text/event-stream',
    'mcp-session-id': sessionId  // Use session from POST request
  }
})

eventSource.onmessage = (event) => {
  const message = JSON.parse(event.data)
  console.log('Server notification:', message)
}

Complete Workflow Example

// 1. Create session and make MCP requests via POST
const initResponse = await fetch('/mcp', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json'
  },
  body: JSON.stringify({
    jsonrpc: '2.0',
    method: 'initialize',
    params: {
      protocolVersion: '2025-06-18',
      capabilities: {},
      clientInfo: { name: 'my-client', version: '1.0.0' }
    },
    id: 1
  })
})

const sessionId = initResponse.headers.get('mcp-session-id')

// 2. Establish SSE stream for notifications via GET
const eventSource = new EventSource('/mcp', {
  headers: {
    'Accept': 'text/event-stream',
    'mcp-session-id': sessionId
  }
})

// 3. Continue making MCP requests via POST
const toolResponse = await fetch('/mcp', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
    'mcp-session-id': sessionId
  },
  body: JSON.stringify({
    jsonrpc: '2.0',
    method: 'tools/call',
    params: { name: 'my-tool', arguments: {} },
    id: 2
  })
})

// Server can send notifications via the SSE stream
// while client makes requests via POST

Server-Initiated Notifications

The plugin provides methods to send notifications and messages to connected SSE clients:

import Fastify from 'fastify'
import mcpPlugin from '@platformatic/mcp'

const app = Fastify({ logger: true })

await app.register(mcpPlugin, {
  enableSSE: true,
  // ... other options
})

// Broadcast a notification to all connected SSE clients
app.mcpBroadcastNotification({
  jsonrpc: '2.0',
  method: 'notifications/message',
  params: {
    level: 'info',
    message: 'Server status update'
  }
})

// Send a message to a specific session
const success = app.mcpSendToSession('session-id', {
  jsonrpc: '2.0',
  method: 'notifications/progress',
  params: {
    progressToken: 'task-123',
    progress: 50,
    total: 100
  }
})

// Send a request to a specific session (expecting a response)
app.mcpSendToSession('session-id', {
  jsonrpc: '2.0',
  id: 'req-456',
  method: 'sampling/createMessage',
  params: {
    messages: [
      {
        role: 'user',
        content: { type: 'text', text: 'Hello from server!' }
      }
    ]
  }
})

// Example: Broadcast tool list changes
app.mcpBroadcastNotification({
  jsonrpc: '2.0',
  method: 'notifications/tools/list_changed'
})

// Example: Send resource updates. `notifications/resources/updated` is only
// delivered to sessions that called `resources/subscribe` for this URI (and
// have not unsubscribed); every other broadcast reaches all sessions.
app.mcpBroadcastNotification({
  jsonrpc: '2.0',
  method: 'notifications/resources/updated',
  params: {
    uri: 'file://config.json'
  }
})

Real-time Updates Example

// Set up a timer to send periodic updates
setInterval(() => {
  app.mcpBroadcastNotification({
    jsonrpc: '2.0',
    method: 'notifications/message',
    params: {
      level: 'info',
      message: `Server time: ${new Date().toISOString()}`
    }
  })
}, 30000) // Every 30 seconds

// Send updates when data changes
function onDataChange(newData: any) {
  app.mcpBroadcastNotification({
    jsonrpc: '2.0',
    method: 'notifications/resources/list_changed'
  })
}

Stdio Transport

The plugin includes a built-in stdio transport utility for MCP communication over stdin/stdout, following the MCP stdio transport specification. This enables command-line tools and local applications to communicate with your Fastify MCP server.

Key Features

  • Complete MCP stdio transport implementation following the official specification
  • Fastify integration using the .inject() method for consistency with HTTP routes
  • Comprehensive error handling with proper JSON-RPC error responses
  • Batch request support for processing multiple messages at once
  • Debug logging to stderr without interfering with the stdio protocol

Quick Start

import fastify from 'fastify'
import mcpPlugin, { runStdioServer } from '@platformatic/mcp'

const app = fastify({
  logger: false // Disable HTTP logging to avoid interference with stdio
})

await app.register(mcpPlugin, {
  serverInfo: {
    name: 'my-mcp-server',
    version: '1.0.0'
  },
  capabilities: {
    tools: {},
    resources: {},
    prompts: {}
  }
})

// Register your tools, resources, and prompts
app.mcpAddTool({
  name: 'echo',
  description: 'Echo back the input text',
  inputSchema: {
    type: 'object',
    properties: {
      text: { type: 'string' }
    },
    required: ['text']
  }
}, async (args) => {
  return {
    content: [{
      type: 'text',
      text: `Echo: ${args.text}`
    }]
  }
})

await app.ready()

// Start the stdio transport
await runStdioServer(app, {
  debug: process.env.DEBUG === 'true'
})

Usage Examples

# Initialize the server
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}' | node server.js

# Ping the server
echo '{"jsonrpc":"2.0","id":2,"method":"ping"}' | node server.js

# List available tools
echo '{"jsonrpc":"2.0","id":3,"method":"tools/list"}' | node server.js

# Call a tool
echo '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"echo","arguments":{"text":"Hello, stdio!"}}}' | node server.js

API Reference

runStdioServer(app, options)

Starts a Fastify MCP server in stdio mode.

Parameters:

  • app - Fastify instance with MCP plugin registered
  • options - Optional stdio transport options

Options:

  • debug - Enable debug logging to stderr (default: false)
  • input - Custom input stream (default: process.stdin)
  • output - Custom output stream (default: process.stdout)
  • error - Custom error stream (default: process.stderr)

createStdioTransport(app, options)

Creates a stdio transport instance without starting it.

Parameters:

  • app - Fastify instance with MCP plugin registered
  • options - Optional stdio transport options

Returns: StdioTransport instance with start() and stop() methods

Transport Protocol

The stdio transport follows the MCP stdio transport specification:

  • Messages are exchanged over stdin/stdout
  • Each message is a single line of JSON
  • Messages are delimited by newlines
  • Messages must NOT contain embedded newlines
  • Server logs can be written to stderr
  • Supports both single messages and batch requests
  • The protocol version negotiated by initialize applies to every later message on the stream (a new initialize re-negotiates it)

Error Handling

The stdio transport provides comprehensive error handling:

  • JSON parsing errors return appropriate JSON-RPC error responses
  • Invalid method calls return "Method not found" errors
  • Tool execution errors are captured and returned in the response
  • Connection errors are logged to stderr

Use Cases

The stdio transport is particularly useful for:

  • Command-line tools that need to communicate with MCP servers
  • Local development and testing without HTTP overhead
  • Integration with text editors and IDEs that support stdio protocols
  • Simple client-server communication in controlled environments
  • Batch processing of MCP requests from scripts

OAuth 2.1 Authorization Integration

The plugin includes comprehensive OAuth 2.1 authorization support for secure MCP communication. This enables token-based authentication, session management, and secure multi-user environments.

Key Features

  • Complete OAuth 2.1 Support: Authorization Code Flow with PKCE
  • Session-Based Authorization: Token-to-session mapping with secure context
  • Authorization-Aware SSE: Authorization context bound to each SSE session
  • JWT Token Validation: Support for JWT tokens with JWKS endpoints
  • Token Introspection: RFC 7662 compliant token introspection
  • Dynamic Client Registration: RFC 7591 compliant client registration
  • Horizontal Scaling: Redis-backed authorization context persistence

Quick OAuth Setup

import Fastify from 'fastify'
import mcpPlugin from '@platformatic/mcp'

const app = Fastify({ logger: true })

await app.register(mcpPlugin, {
  serverInfo: {
    name: 'secure-mcp-server',
    version: '1.0.0'
  },
  capabilities: {
    tools: {},
    resources: {},
    prompts: {}
  },
  enableSSE: true, // Required for session-based authorization
  // Enable OAuth 2.1 authorization
  authorization: {
    enabled: true,
    authorizationServers: ['https://auth.example.com'],
    resourceUri: 'https://mcp.example.com',
    // JWT Token Validation
    tokenValidation: {
      jwksUri: 'https://auth.example.com/.well-known/jwks.json',
      validateAudience: true
    }
  },
  // Redis for session persistence (recommended)
  redis: {
    host: process.env.REDIS_HOST || 'localhost',
    port: parseInt(process.env.REDIS_PORT || '6379')
  }
})

await app.listen({ port: 3000 })

Authorization Workflow

1. OAuth Authorization Flow

The MCP client discovers the authorization server through the MCP server's protected-resource metadata. It then registers or identifies itself with that authorization server and performs Authorization Code with PKCE directly:

  1. The MCP client sends its registered redirect_uri to the authorization server.
  2. The authorization server returns an authorization code to the client callback.
  3. The MCP client exchanges the code with its PKCE verifier.
  4. The MCP client sends the resulting bearer token to the MCP server.

Client redirect URIs are not sent to the MCP server. The MCP server does not forward access tokens through browser redirects.

2. Authenticated MCP Requests

// Use access token for MCP requests
const mcpResponse = await fetch('/mcp', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${access_token}`,
    'Content-Type': 'application/json',
    'Accept': 'application/json'
  },
  body: JSON.stringify({
    jsonrpc: '2.0',
    method: 'tools/list',
    id: 1
  })
})

3. Session-Based SSE Connections

// SSE connection with authorization
const eventSource = new EventSource('/mcp', {
  headers: {
    'Authorization': `Bearer ${access_token}`,
    'Accept': 'text/event-stream'
  }
})

eventSource.onmessage = (event) => {
  const message = JSON.parse(event.data)
  console.log('Received:', message)
}

Authorization Configuration

Choosing Which Routes Require a Token

By default the plugin adds the bearer-token check as a preHandler hook on the Fastify instance it is registered on. Because the plugin is not encapsulated, that check also covers every route you register yourself on that instance. The well-known metadata endpoints and the OAuth flow endpoints (/oauth/authorize, /oauth/callback, /oauth/register) are always skipped.

Set protectedRoutes: 'mcp' to protect only the MCP endpoints the plugin registers (POST, GET and DELETE /mcp) and leave your own routes alone:

await app.register(mcpPlugin, {
  authorization: {
    enabled: true,
    authorizationServers: ['https://auth.example.com'],
    resourceUri: 'https://mcp.example.com',
    tokenValidation: {
      jwksUri: 'https://auth.example.com/.well-known/jwks.json'
    },
    protectedRoutes: 'mcp' // default: 'all'
  }
})

// Reachable without a token
app.get('/health', async () => ({ status: 'ok' }))

| Value | Behaviour | |-------|-----------| | 'all' (default) | Every route of the app requires a token, except the well-known and OAuth endpoints. | | 'mcp' | Only the /mcp routes require a token. |

If you keep the default and only need to open a few paths, list them in excludedPaths instead. Each entry is a URL prefix string or a RegExp:

authorization: {
  enabled: true,
  // ...
  excludedPaths: ['/health', /^\/public\//]
}

excludedPaths also applies with protectedRoutes: 'mcp', but there it only matters for the /mcp routes.

JWT Token Validation

authorization: {
  enabled: true,
  tokenValidation: {
    // JWKS endpoint for public key retrieval
    jwksUri: 'https://auth.example.com/.well-known/jwks.json',
    
    // Validate token audience
    validateAudience: true
  }
}

Token Introspection (RFC 7662)

authorization: {
  enabled: true,
  tokenValidation: {
    // Use token introspection instead of JWT
    introspectionEndpoint: 'https://auth.example.com/oauth/introspect',
    clientId: process.env.OAUTH_CLIENT_ID,
    clientSecret: process.env.OAUTH_CLIENT_SECRET
  }
}

Required Scopes and Scope Hierarchies

requiredScopes lists the scopes a token must carry to reach the MCP endpoints. A valid token missing any of them gets a 403 with a WWW-Authenticate: Bearer error="insufficient_scope" challenge.

By default, scopes are matched exactly. If your authorization server issues hierarchical scopes, pass scopeImplies(granted, required) to decide whether a granted scope satisfies a required one. The built-in colonScopeHierarchy helper implements the resource:action convention: files implies files:read, but files:read does not imply files, and filesystem does not imply files:read.

import mcpPlugin, { colonScopeHierarchy } from '@platformatic/mcp'

await app.register(mcpPlugin, {
  authorization: {
    enabled: true,
    authorizationServers: ['https://auth.example.com'],
    resourceUri: 'https://mcp.example.com',
    tokenValidation: { jwksUri: 'https://auth.example.com/.well-known/jwks.json' },
    requiredScopes: ['files:read'],
    // A token granted `files` is accepted
    scopeImplies: colonScopeHierarchy
  }
})

OAuth Client Configuration

This optional configuration is for server-side OAuth client operations. It is not required for MCP clients, which authorize directly with the advertised authorization server.

authorization: {
  enabled: true,
  oauth2Client: {
    clientId: process.env.OAUTH_CLIENT_ID,
    clientSecret: process.env.OAUTH_CLIENT_SECRET,
    
    // Authorization server
    authorizationServer: 'https://auth.example.com',
    
    // Application configuration
    scopes: ['read', 'write', 'admin'],
    
    // Optional: Dynamic client registration
    dynamicRegistration: true
  }
}

Session-Based Authorization

The plugin maps OAuth tokens to MCP sessions for efficient authorization:

// Authorization context automatically added to sessions
interface SessionMetadata {
  id: string
  authorization?: {
    userId: string
    clientId: string
    scopes: string[]
    tokenHash: string // Secure hash of access token
    expiresAt: Date
    // ... additional context
  }
  tokenRefresh?: {
    refreshToken: string
    clientId: string
    authorizationServer: string
    scopes: string[]
  }
}

Token Refresh

Automatic background token refresh is not available yet: there is no authorization.tokenRefresh option and no app.tokenRefreshService decorator. Clients are expected to refresh their own access tokens (for example through POST /oauth/refresh when oauth2Client is configured). End-to-end token refresh is tracked in #146.

Authorization-Aware Tools

Access authorization context in tool handlers:

app.mcpAddTool({
  name: 'user-profile',
  description: 'Get user profile information',
  inputSchema: Type.Object({})
}, async (params, { authContext }) => {
  if (!authContext?.userId) {
    return {
      content: [{ type: 'text', text: 'Authentication required' }],
      isError: true
    }
  }
  
  // Check required scopes
  if (!authContext.scopes?.includes('profile:read')) {
    return {
      content: [{ type: 'text', text: 'Insufficient permissions' }],
      isError: true
    }
  }
  
  // Use user context
  const profile = await getUserProfile(authContext.userId)
  
  return {
    content: [{
      type: 'text',
      text: `Profile: ${JSON.stringify(profile, null, 2)}`
    }]
  }
})

Per-Tool Authorization

Tool-level checks like the one above keep the tool visible to every client and only fail at call time. To gate tools per request — hiding them from tools/list and rejecting tools/call in one place — provide a canAccessTool hook:

await app.register(mcpPlugin, {
  authorization: { /* ... */ },
  canAccessTool: (toolName, context) => {
    if (context.operation === 'list') {
      return canSeeTool(toolName, context.authContext)
    }

    return canExecuteTool(toolName, context.authContext)
  }
})

The hook receives the tool name and a per-request context (authContext, request, sessionId, operation), and may be sync or async. It is consulted at both dispatch points, so list and call can never disagree:

  • tools/list omits every tool the hook denies for that request, calling the hook with operation: 'list'. Per-tool checks run concurrently with a fixed cap (8 at a time), so an async hook backed by a database or HTTP service is not flooded with one lookup per registered tool; the listing keeps registration order.
  • tools/call on a denied tool returns the same -32602 (Invalid params) "Tool 'name' not found" protocol error as an unknown tool, calling the hook with operation: 'call'. The hook runs even for unknown names, so callers cannot probe for tools they are not allowed to see by response shape. Response timing is not guaranteed to be indistinguishable — it depends on what the hook itself does per name. Task-augmented calls (task field) and app.mcpCallTool() also use operation: 'call'.

Visibility and execution policies may intentionally differ — a tool hidden from tools/list can still be callable by a client that already knows its name.

A hook that throws denies access (fail closed) and logs a warning. Only an explicit true grants access. Without the hook, every registered tool stays visible and callable.

Observing Tool Calls

Provide onToolCallComplete to observe every tool call, regardless of transport, in one place:

await app.register(mcpPlugin, {
  onToolCallComplete: async (event) => {
    logger.info({
      tool: event.toolName,
      source: event.source,
      durationMs: event.durationMs,
      ok: event.outcome.ok
    }, 'tool call completed')
  }
})

The hook fires exactly once per tool call, after it settles: for JSON-RPC tools/call, for app.mcpCallTool(), and for a task-augmented call once execution finishes (not when the task is merely accepted). It also fires for access denial, unknown tools, invalid arguments, and task-required outcomes. It does not fire for tools/list, initialization/ping, or malformed requests where no tool name was resolved.

event.source is 'json-rpc', 'in-process', or 'task'. event.outcome is the same McpCallToolOutcome returned by mcpCallTool(). The hook is awaited before the response is sent, so keep it fast; a throwing or rejecting hook is logged and otherwise ignored — it never changes the tool response.

Synchronous sources ('json-rpc' and 'in-process') expose the active request/reply. Task events never do: a task can finish after the original HTTP response has already completed, so request/reply are absent on source: 'task' events. Every event, regardless of source, carries requestId for correlation.

Duration semantics also differ by source: 'json-rpc' and 'in-process' durations include authorization, validation, sanitization, and execution. Task durations measure only actual execution — validation, sanitization, and the tool call — excluding the time the task spent queued.

event.arguments are the raw, unredacted tool arguments. Redact sensitive values yourself before logging or persisting them.

Customizing MCP Route Schemas

Use transformRouteSchema to customize Fastify/OpenAPI schema metadata on MCP transport routes without replacing handlers or route definitions.

import Fastify from 'fastify'
import mcpPlugin from '@platformatic/mcp'

const app = Fastify()

await app.register(mcpPlugin, {
  transformRouteSchema (schema, { routeId }) {
    return {
      ...schema,
      tags: ['MCP'],
      security: [{ oauth2: ['tools:read'] }],
      operationId: routeId.replace('.', '-'),
      'x-roles': ['admin']
    }
  }
})

Route IDs are stable and map to MCP transport routes:

| Route ID | Method | Purpose | |---|---|---| | mcp.post | POST | MCP JSON-RPC messages | | mcp.get | GET | MCP streaming/SSE connection | | mcp.delete | DELETE | MCP session termination |

Behavior notes:

  • The callback runs once per registered MCP transport route during startup.
  • The plugin passes the o