Skip to content
Open
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
39 changes: 33 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1778,7 +1778,7 @@ Duration semantics also differ by source: `'json-rpc'` and `'in-process'` durati

## Customizing MCP Route Schemas

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

```typescript
import Fastify from 'fastify'
Expand All @@ -1799,29 +1799,56 @@ await app.register(mcpPlugin, {
})
```

Route IDs are stable and map to MCP transport routes:
Route IDs are stable and map to the routes registered by the plugin:

| Route ID | Method | Purpose |
|---|---|---|
| `mcp.post` | `POST` | MCP JSON-RPC messages |
| `mcp.get` | `GET` | MCP streaming/SSE connection |
| `mcp.get` | `GET` | MCP streaming/SSE connection (answers 405 when `enableSSE` is off) |
| `mcp.delete` | `DELETE` | MCP session termination |
| `wellKnown.protectedResourceMetadata` | `GET` | `/.well-known/oauth-protected-resource*` (authorization enabled; one call per path) |
| `wellKnown.resourceHealth` | `GET` | `/.well-known/mcp-resource-health` (authorization enabled) |
| `wellKnown.preflight` | `OPTIONS` | CORS preflight for `/.well-known/*` (authorization enabled) |
| `oauth.clientIdMetadataDocument` | `GET` | Client ID Metadata Document (`oauth2Client.clientIdMetadataDocument`) |
| `oauth.authorize`, `oauth.callback`, `oauth.status` | `GET` | OAuth client routes (`oauth2Client` configured) |
| `oauth.refresh`, `oauth.validate`, `oauth.register`, `oauth.logout` | `POST` | OAuth client routes (`oauth2Client` configured) |

Behavior notes:

- The callback runs once per registered MCP transport route during startup.
- The callback runs once per route registered by the plugin during startup.
- The plugin passes the original route schema object to the callback.
- Spread the incoming schema when you want to preserve existing fields.
- The callback is synchronous and must return a schema object.
- Returned metadata applies only to Fastify/OpenAPI schema fields.
- OAuth and well-known routes are not customized by this option in this version.
- The MCP transport routes start from an empty schema. The well-known and OAuth routes pass their `querystring`, `body` and `response` schemas, which Fastify uses for validation and serialization, so spread them instead of replacing them.

Security notes:

- OpenAPI schema metadata does not enforce authentication or authorization.
- For example, `security: [{ oauth2: ['tools:read'] }]` documents expectations but does not install auth checks.
- Enforcement still depends on your configured auth hooks, token validation, and tool access controls.

### Hiding MCP routes from OpenAPI / Swagger

If you generate an OpenAPI document with [`@fastify/swagger`](https://github.com/fastify/fastify-swagger) (for example to build a typed client with `openapi-fetch`), you may not want the MCP endpoints in it. Mark every plugin route with `hide: true`:

```typescript
import Fastify from 'fastify'
import swagger from '@fastify/swagger'
import mcpPlugin from '@platformatic/mcp'

const app = Fastify()

await app.register(swagger, { openapi: { info: { title: 'My API', version: '1.0.0' } } })
await app.register(mcpPlugin, {
transformRouteSchema: (schema) => ({ ...schema, hide: true })
})

await app.ready()
app.swagger() // no /mcp, /.well-known/* or /oauth/* paths
```

To hide them with a tag instead, return `{ ...schema, tags: ['X-HIDDEN'] }`; `X-HIDDEN` is `@fastify/swagger`'s default `hiddenTag`. To hide only some routes, check `routeId` before setting `hide`.

### OAuth Routes

When `oauth2Client` is configured, the plugin registers these legacy server-side OAuth client routes. They are not used by MCP clients to obtain tokens: client redirect URIs must be sent directly to the authorization server. `/oauth/authorize` rejects `redirect_uri`, and `/oauth/callback` never redirects access tokens.
Expand Down
66 changes: 66 additions & 0 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"devDependencies": {
"@fastify/cors": "^11.1.0",
"@fastify/pre-commit": "^2.2.0",
"@fastify/swagger": "^9.9.2",
"@modelcontextprotocol/inspector": "^0.22.0",
"@modelcontextprotocol/sdk": "^1.13.3",
"@opentelemetry/api": "^1.9.0",
Expand All @@ -54,9 +55,9 @@
"undici": "^7.11.0"
},
"dependencies": {
"ajv": "^8.17.1",
"@fastify/jwt": "^10.0.0",
"@fastify/type-provider-typebox": "^6.0.0",
"ajv": "^8.17.1",
"fast-jwt": "^6.0.2",
"fastify-error": "^1.0.0",
"fastify-plugin": "^6.0.0",
Expand Down
5 changes: 4 additions & 1 deletion src/auth/oauth-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { FastifyPluginAsync, FastifyBaseLogger } from 'fastify'
import type { MCPRouteSchemaTransformer } from '../types.ts'
import { routeSchemaOptions } from '../routes/route-schema.ts'
import fp from 'fastify-plugin'
import { createHash, randomBytes } from 'node:crypto'
import { validateTokenResponse, validateIntrospectionResponse, validateClientRegistrationResponse } from './oauth-schemas.ts'
Expand All @@ -19,6 +21,7 @@ export interface OAuthClientConfig {
* Pass `true` for the default path, or an object to override it.
*/
clientIdMetadataDocument?: boolean | { path?: string }
transformRouteSchema?: MCPRouteSchemaTransformer
}

/** Where the client metadata document is served unless overridden */
Expand Down Expand Up @@ -201,7 +204,7 @@ const oauthClientPlugin: FastifyPluginAsync<OAuthClientConfig> = async (fastify,
'clientIdMetadataDocument path is outside /.well-known; add it to authorization.excludedPaths or the authorization server will get 401 when fetching it')
}

fastify.get(path, async (_request, reply) => {
fastify.get(path, routeSchemaOptions(fastify, 'oauth.clientIdMetadataDocument', 'GET', path, undefined, opts.transformRouteSchema), async (_request, reply) => {
return reply.type('application/json').send(document)
})

Expand Down
11 changes: 8 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,20 +226,25 @@ const mcpPlugin = fp(async function (app: FastifyInstance, opts: MCPPluginOption

// Register OAuth client plugin if configured
if (opts.authorization.oauth2Client) {
await app.register(oauthClientPlugin, opts.authorization.oauth2Client)
await app.register(oauthClientPlugin, {
...opts.authorization.oauth2Client,
transformRouteSchema: opts.transformRouteSchema
})
}
}

// Register well-known routes for OAuth metadata
await app.register(wellKnownRoutes, {
authConfig: opts.authorization
authConfig: opts.authorization,
transformRouteSchema: opts.transformRouteSchema
})

// Register OAuth client routes if OAuth client is configured
if (opts.authorization?.enabled && opts.authorization?.oauth2Client) {
await app.register(authRoutesPlugin, {
sessionStore,
dcrHooks: opts.authorization.dcrHooks
dcrHooks: opts.authorization.dcrHooks,
transformRouteSchema: opts.transformRouteSchema
})
}

Expand Down
Loading
Loading