From e498a192cbd28457a73241288b02adbbfe4d08ce Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 15 Oct 2026 11:28:47 +0200 Subject: [PATCH] fix(routes): pass every plugin route through transformRouteSchema transformRouteSchema already lets users hide the MCP transport routes from @fastify/swagger with `hide: true` or a hidden tag, but several routes the plugin registers bypassed it and still leaked into the generated OpenAPI document (and into types generated from it): - GET /mcp when enableSSE is off (the 405 route) - /.well-known/oauth-protected-resource*, /.well-known/mcp-resource-health and the OPTIONS /.well-known/* preflight route when authorization is on - the /oauth/* client routes and the Client ID Metadata Document route when oauth2Client is configured All of them now go through the transformer with their own route IDs. The helpers moved from routes/mcp.ts to routes/route-schema.ts so the well-known, OAuth route and OAuth client plugins can share them. @fastify/swagger is added as a devDependency to test the generated spec. Fixes #72 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UF4S3w7iA83V9DVsasLJzM --- README.md | 39 +++++- package-lock.json | 66 ++++++++++ package.json | 3 +- src/auth/oauth-client.ts | 5 +- src/index.ts | 11 +- src/routes/auth-routes.ts | 105 ++++++++-------- src/routes/mcp.ts | 51 ++------ src/routes/route-schema.ts | 60 +++++++++ src/routes/well-known.ts | 72 ++++++----- src/types.ts | 17 ++- test/openapi-hide-routes.test.ts | 154 ++++++++++++++++++++++++ test/route-schema-customization.test.ts | 33 +++-- 12 files changed, 461 insertions(+), 155 deletions(-) create mode 100644 src/routes/route-schema.ts create mode 100644 test/openapi-hide-routes.test.ts diff --git a/README.md b/README.md index bfd15b35..09a576ec 100644 --- a/README.md +++ b/README.md @@ -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' @@ -1799,22 +1799,27 @@ 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: @@ -1822,6 +1827,28 @@ Security notes: - 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. diff --git a/package-lock.json b/package-lock.json index 84bfdc3f..8875d27c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,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", @@ -469,6 +470,30 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/swagger": { + "version": "9.9.2", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.9.2.tgz", + "integrity": "sha512-ayxItVgf/1axBvw33lqsA5ns02YMpT1r59G/HCJraRNWWBzvLgN+oiCBPhuzTiN67TvctlCYkG0+40WLKYuusQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^6.0.0", + "json-schema-resolver": "^3.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.2" + } + }, "node_modules/@fastify/type-provider-typebox": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@fastify/type-provider-typebox/-/type-provider-typebox-6.1.0.tgz", @@ -6310,6 +6335,24 @@ "dequal": "^2.0.3" } }, + "node_modules/json-schema-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz", + "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fast-uri": "^3.0.5", + "rfdc": "^1.1.4" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -7162,6 +7205,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "dev": true, + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -9800,6 +9850,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 6240197c..05eeb5df 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/auth/oauth-client.ts b/src/auth/oauth-client.ts index e2e106f1..412118f1 100644 --- a/src/auth/oauth-client.ts +++ b/src/auth/oauth-client.ts @@ -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' @@ -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 */ @@ -201,7 +204,7 @@ const oauthClientPlugin: FastifyPluginAsync = 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) }) diff --git a/src/index.ts b/src/index.ts index a57f9b31..7341af1a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 }) } diff --git a/src/routes/auth-routes.ts b/src/routes/auth-routes.ts index 26c7a475..06d9e6af 100644 --- a/src/routes/auth-routes.ts +++ b/src/routes/auth-routes.ts @@ -1,10 +1,12 @@ -import type { FastifyInstance, FastifyPluginAsync } from 'fastify' +import type { FastifyInstance, FastifyPluginAsync, FastifySchema, HTTPMethods } from 'fastify' import fp from 'fastify-plugin' import { Type } from '@sinclair/typebox' import type { PKCEChallenge } from '../auth/oauth-client.ts' import { BEARER_SCHEME } from '../auth/prehandler.ts' import type { SessionStore } from '../stores/session-store.ts' import type { DCRHooks, DCRRequest, DCRResponse } from '../types/auth-types.ts' +import type { MCPRouteId, MCPRouteSchemaTransformer } from '../types.ts' +import { routeSchemaOptions } from './route-schema.ts' export interface AuthSession { state: string @@ -28,6 +30,7 @@ export interface AuthRoutesOptions { sessionStore: SessionStore /** DCR hooks for custom request/response processing */ dcrHooks?: DCRHooks + transformRouteSchema?: MCPRouteSchemaTransformer } // TypeBox schemas for validation @@ -103,12 +106,14 @@ const LogoutResponse = Type.Object({ const authRoutesPlugin: FastifyPluginAsync = async (fastify: FastifyInstance, opts) => { const { sessionStore } = opts + function routeOptions (routeId: MCPRouteId, method: HTTPMethods, url: string, schema: FastifySchema) { + return routeSchemaOptions(fastify, routeId, method, url, schema, opts.transformRouteSchema) + } + // Initiate OAuth authorization flow - fastify.get('/oauth/authorize', { - schema: { - querystring: AuthorizeQuerystring - } - }, async (request, reply) => { + fastify.get('/oauth/authorize', routeOptions('oauth.authorize', 'GET', '/oauth/authorize', { + querystring: AuthorizeQuerystring + }), async (request, reply) => { try { const { resource, redirect_uri: redirectUri } = request.query as { resource?: string; redirect_uri?: string } @@ -161,16 +166,14 @@ const authRoutesPlugin: FastifyPluginAsync = async (fastify: }) // Handle OAuth callback - fastify.get('/oauth/callback', { - schema: { - querystring: CallbackQuerystring, - response: { - 200: TokenResponse, - 400: ErrorResponse, - 500: ErrorResponse - } + fastify.get('/oauth/callback', routeOptions('oauth.callback', 'GET', '/oauth/callback', { + querystring: CallbackQuerystring, + response: { + 200: TokenResponse, + 400: ErrorResponse, + 500: ErrorResponse } - }, async (request, reply) => { + }), async (request, reply) => { reply.header('Cache-Control', 'no-store') reply.header('Pragma', 'no-cache') @@ -240,15 +243,13 @@ const authRoutesPlugin: FastifyPluginAsync = async (fastify: }) // Token refresh endpoint - fastify.post('/oauth/refresh', { - schema: { - body: TokenRefreshBodySchema, - response: { - 200: TokenResponse, - 400: ErrorResponse - } + fastify.post('/oauth/refresh', routeOptions('oauth.refresh', 'POST', '/oauth/refresh', { + body: TokenRefreshBodySchema, + response: { + 200: TokenResponse, + 400: ErrorResponse } - }, async (request, reply) => { + }), async (request, reply) => { reply.header('Cache-Control', 'no-store') reply.header('Pragma', 'no-cache') @@ -275,16 +276,14 @@ const authRoutesPlugin: FastifyPluginAsync = async (fastify: }) // Token validation endpoint - fastify.post('/oauth/validate', { - schema: { - body: TokenValidateBody, - response: { - 200: TokenValidationResponse, - 400: ErrorResponse, - 500: ErrorResponse - } + fastify.post('/oauth/validate', routeOptions('oauth.validate', 'POST', '/oauth/validate', { + body: TokenValidateBody, + response: { + 200: TokenValidationResponse, + 400: ErrorResponse, + 500: ErrorResponse } - }, async (request, reply) => { + }), async (request, reply) => { try { const { token } = request.body as { token: string } @@ -303,13 +302,11 @@ const authRoutesPlugin: FastifyPluginAsync = async (fastify: }) // Get authorization status - fastify.get('/oauth/status', { - schema: { - response: { - 200: AuthStatusResponse - } + fastify.get('/oauth/status', routeOptions('oauth.status', 'GET', '/oauth/status', { + response: { + 200: AuthStatusResponse } - }, async (request, reply) => { + }), async (request, reply) => { try { const authHeader = request.headers.authorization const match = authHeader && BEARER_SCHEME.exec(authHeader) @@ -343,17 +340,15 @@ const authRoutesPlugin: FastifyPluginAsync = async (fastify: // Clients should use the authorization server's DCR endpoint directly. // (Using oauth-client.dynamicClientRegistration here would cause an infinite loop // if OIDC discovery points back to this server.) - fastify.post('/oauth/register', { - schema: { - body: DynamicRegistrationRequest, - response: { - 200: DynamicRegistrationResponse, - 400: ErrorResponse, - 501: ErrorResponse, - 502: ErrorResponse - } + fastify.post('/oauth/register', routeOptions('oauth.register', 'POST', '/oauth/register', { + body: DynamicRegistrationRequest, + response: { + 200: DynamicRegistrationResponse, + 400: ErrorResponse, + 501: ErrorResponse, + 502: ErrorResponse } - }, async (request, reply) => { + }), async (request, reply) => { const { dcrHooks } = opts // DCR proxy requires hooks configuration @@ -435,15 +430,13 @@ const authRoutesPlugin: FastifyPluginAsync = async (fastify: }) // Logout endpoint (revokes tokens) - fastify.post('/oauth/logout', { - schema: { - response: { - 200: LogoutResponse, - 400: ErrorResponse, - 500: ErrorResponse - } + fastify.post('/oauth/logout', routeOptions('oauth.logout', 'POST', '/oauth/logout', { + response: { + 200: LogoutResponse, + 400: ErrorResponse, + 500: ErrorResponse } - }, async (request, reply) => { + }), async (request, reply) => { try { const authHeader = request.headers.authorization const match = authHeader && BEARER_SCHEME.exec(authHeader) diff --git a/src/routes/mcp.ts b/src/routes/mcp.ts index efdd63ba..1d59162d 100644 --- a/src/routes/mcp.ts +++ b/src/routes/mcp.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto' -import type { FastifyRequest, FastifyReply, FastifyPluginAsync, FastifySchema, FastifyError, FastifyInstance, preHandlerHookHandler } from 'fastify' +import type { FastifyRequest, FastifyReply, FastifyPluginAsync, FastifyError, FastifyInstance, preHandlerHookHandler } from 'fastify' import fp from 'fastify-plugin' import type { JSONRPCMessage } from '../schema.ts' import { JSONRPC_VERSION, INTERNAL_ERROR, PARSE_ERROR, INVALID_REQUEST, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from '../schema.ts' @@ -9,9 +9,7 @@ import type { MCPTool, MCPResource, MCPPrompt, - ResourceHandlers, - MCPRouteSchemaContext, - MCPRouteSchemaTransformer + ResourceHandlers } from '../types.ts' import type { SessionStore, SessionMetadata } from '../stores/session-store.ts' import { DEFAULT_SESSION_TTL_MS } from '../stores/memory-session-store.ts' @@ -21,6 +19,7 @@ import type { AuthorizationContext } from '../types/auth-types.ts' import type { JsonSchemaValidator } from '../validation/json-schema-validator.ts' import { processMessage, checkRequestId } from '../handlers.ts' import { isStdioRequest } from '../stdio-marker.ts' +import { resolveMcpRouteSchema, resolveRouteUrl, routeSchemaOptions } from './route-schema.ts' const MAX_TIMER_DELAY_MS = 2 ** 31 - 1 @@ -44,43 +43,6 @@ interface MCPPubSubRoutesOptions { authPreHandler?: preHandlerHookHandler } -function resolveRouteUrl (prefix: string | undefined, url: string): string { - if (!prefix || prefix === '/') { - return url - } - - const normalizedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix - return `${normalizedPrefix}${url}` -} - -function isThenable (value: unknown): value is PromiseLike { - return ( - typeof value === 'object' && - value !== null && - 'then' in value && - typeof value.then === 'function' - ) -} - -function resolveMcpRouteSchema ( - defaultSchema: FastifySchema | undefined, - context: MCPRouteSchemaContext, - transform?: MCPRouteSchemaTransformer -): FastifySchema | undefined { - if (!transform) { - return defaultSchema - } - - const schema = defaultSchema ?? {} - const transformed = transform(schema, context) - - if (typeof transformed !== 'object' || transformed === null || Array.isArray(transformed) || isThenable(transformed)) { - throw new TypeError(`transformRouteSchema must return a synchronous Fastify schema object for ${context.routeId}`) - } - - return transformed -} - // Body-parser failures happen before the handler runs, so without this they // would surface as Fastify's default error body instead of a JSON-RPC error. const BODY_PARSER_ERRORS: Record = { @@ -508,7 +470,12 @@ const mcpPubSubRoutesPlugin: FastifyPluginAsync = async // GET endpoint for server-initiated communication via SSE if (!enableSSE) { - app.get('/mcp', { onRequest: mcpOnRequest, preHandler: mcpPreHandler }, async (_request: FastifyRequest, reply: FastifyReply) => { + const routeOptions = { + onRequest: mcpOnRequest, + preHandler: mcpPreHandler, + ...routeSchemaOptions(app, 'mcp.get', 'GET', '/mcp', undefined, opts.transformRouteSchema) + } + app.get('/mcp', routeOptions, async (_request: FastifyRequest, reply: FastifyReply) => { reply.header('Allow', 'POST, DELETE').type('application/json').code(405).send({ error: 'Method Not Allowed: SSE not enabled' }) }) } diff --git a/src/routes/route-schema.ts b/src/routes/route-schema.ts new file mode 100644 index 00000000..bf327f97 --- /dev/null +++ b/src/routes/route-schema.ts @@ -0,0 +1,60 @@ +import type { FastifyInstance, FastifySchema, HTTPMethods } from 'fastify' +import type { MCPRouteId, MCPRouteSchemaContext, MCPRouteSchemaTransformer } from '../types.ts' + +export function resolveRouteUrl (prefix: string | undefined, url: string): string { + if (!prefix || prefix === '/') { + return url + } + + const normalizedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix + return `${normalizedPrefix}${url}` +} + +function isThenable (value: unknown): value is PromiseLike { + return ( + typeof value === 'object' && + value !== null && + 'then' in value && + typeof value.then === 'function' + ) +} + +export function resolveMcpRouteSchema ( + defaultSchema: FastifySchema | undefined, + context: MCPRouteSchemaContext, + transform?: MCPRouteSchemaTransformer +): FastifySchema | undefined { + if (!transform) { + return defaultSchema + } + + const schema = defaultSchema ?? {} + const transformed = transform(schema, context) + + if (typeof transformed !== 'object' || transformed === null || Array.isArray(transformed) || isThenable(transformed)) { + throw new TypeError(`transformRouteSchema must return a synchronous Fastify schema object for ${context.routeId}`) + } + + return transformed +} + +/** + * Returns route options carrying the transformed schema for a route the + * plugin registers on `app`, or no `schema` key at all when there is neither + * a default schema nor a transformer. + */ +export function routeSchemaOptions ( + app: FastifyInstance, + routeId: MCPRouteId, + method: HTTPMethods, + url: string, + defaultSchema: FastifySchema | undefined, + transform?: MCPRouteSchemaTransformer +): { schema?: FastifySchema } { + const schema = resolveMcpRouteSchema(defaultSchema, { + routeId, + method, + url: resolveRouteUrl(app.prefix, url) + }, transform) + return schema === undefined ? {} : { schema } +} diff --git a/src/routes/well-known.ts b/src/routes/well-known.ts index 24fa25f8..5ed9769c 100644 --- a/src/routes/well-known.ts +++ b/src/routes/well-known.ts @@ -1,10 +1,13 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' import fp from 'fastify-plugin' import type { AuthorizationConfig, ProtectedResourceMetadata } from '../types/auth-types.ts' +import type { MCPRouteSchemaTransformer } from '../types.ts' +import { routeSchemaOptions } from './route-schema.ts' import { getResourceMetadataPath, PROTECTED_RESOURCE_METADATA_PATH } from '../auth/resource-metadata.ts' interface WellKnownRoutesOptions { authConfig?: AuthorizationConfig + transformRouteSchema?: MCPRouteSchemaTransformer } const CORS_ALLOWED_METHODS = 'GET, HEAD, OPTIONS' @@ -30,12 +33,16 @@ const wellKnownRoutesPlugin = fp(async function (fastify: FastifyInstance, opts: // @fastify/cors is not used here: it decorates the request and adds a // global OPTIONS * route, both of which clash with an app-level // @fastify/cors registration. - await fastify.register(async (app) => wellKnownRoutes(app, authConfig)) + await fastify.register(async (app) => wellKnownRoutes(app, authConfig, opts.transformRouteSchema)) }, { name: 'well-known-routes' }) -async function wellKnownRoutes (app: FastifyInstance, authConfig: AuthorizationConfig & { enabled: true }) { +async function wellKnownRoutes ( + app: FastifyInstance, + authConfig: AuthorizationConfig & { enabled: true }, + transformRouteSchema?: MCPRouteSchemaTransformer +) { // Discovery endpoints are public, so allow cross-origin requests from any origin app.addHook('onRequest', async (request, reply) => { const origin = request.headers.origin @@ -54,34 +61,33 @@ async function wellKnownRoutes (app: FastifyInstance, authConfig: AuthorizationC }) // Lets the router accept preflight requests; they are answered in the hook above - app.options('/.well-known/*', async (_request, reply) => { + app.options('/.well-known/*', routeSchemaOptions(app, 'wellKnown.preflight', 'OPTIONS', '/.well-known/*', undefined, transformRouteSchema), async (_request, reply) => { return reply.callNotFound() }) const protectedResourceSchema = { - schema: { - response: { - 200: { - type: 'object', - properties: { - resource: { type: 'string' }, - authorization_servers: { - type: 'array', - items: { type: 'string' } - }, - scopes_supported: { - type: 'array', - items: { type: 'string' } - } + response: { + 200: { + type: 'object', + properties: { + resource: { type: 'string' }, + authorization_servers: { + type: 'array', + items: { type: 'string' } }, - required: ['resource', 'authorization_servers'] - } + scopes_supported: { + type: 'array', + items: { type: 'string' } + } + }, + required: ['resource', 'authorization_servers'] } } } function serveProtectedResourceMetadata (path: string, resource: string) { - app.get(path, protectedResourceSchema, async (_request: FastifyRequest, reply: FastifyReply) => { + const options = routeSchemaOptions(app, 'wellKnown.protectedResourceMetadata', 'GET', path, protectedResourceSchema, transformRouteSchema) + app.get(path, options, async (_request: FastifyRequest, reply: FastifyReply) => { const metadata: ProtectedResourceMetadata = { resource, authorization_servers: authConfig.authorizationServers @@ -115,21 +121,21 @@ async function wellKnownRoutes (app: FastifyInstance, authConfig: AuthorizationC } // Health check endpoint that can be used to verify the resource server is operational - app.get('/.well-known/mcp-resource-health', { - schema: { - response: { - 200: { - type: 'object', - properties: { - status: { type: 'string' }, - resource: { type: 'string' }, - timestamp: { type: 'string' } - }, - required: ['status', 'resource', 'timestamp'] - } + const healthPath = '/.well-known/mcp-resource-health' + const healthSchema = { + response: { + 200: { + type: 'object', + properties: { + status: { type: 'string' }, + resource: { type: 'string' }, + timestamp: { type: 'string' } + }, + required: ['status', 'resource', 'timestamp'] } } - }, async (_request: FastifyRequest, reply: FastifyReply) => { + } + app.get(healthPath, routeSchemaOptions(app, 'wellKnown.resourceHealth', 'GET', healthPath, healthSchema, transformRouteSchema), async (_request: FastifyRequest, reply: FastifyReply) => { reply.header('Content-Type', 'application/json') return { diff --git a/src/types.ts b/src/types.ts index 35d21dbd..3c9c8a29 100644 --- a/src/types.ts +++ b/src/types.ts @@ -223,6 +223,17 @@ export type MCPRouteId = | 'mcp.post' | 'mcp.get' | 'mcp.delete' + | 'wellKnown.protectedResourceMetadata' + | 'wellKnown.resourceHealth' + | 'wellKnown.preflight' + | 'oauth.authorize' + | 'oauth.callback' + | 'oauth.refresh' + | 'oauth.validate' + | 'oauth.status' + | 'oauth.register' + | 'oauth.logout' + | 'oauth.clientIdMetadataDocument' export interface MCPRouteSchemaContext { routeId: MCPRouteId @@ -390,8 +401,10 @@ export interface MCPPluginOptions { context: MCPClientResponseContext ) => void | Promise /** - * Customize Fastify/OpenAPI schema metadata for MCP transport routes. - * This callback runs once per registered route during startup. + * Customize Fastify/OpenAPI schema metadata for every route this plugin + * registers: the MCP transport routes and, when authorization is enabled, + * the `/.well-known/*` and `/oauth/*` routes. Runs once per registered + * route during startup. */ transformRouteSchema?: MCPRouteSchemaTransformer /** diff --git a/test/openapi-hide-routes.test.ts b/test/openapi-hide-routes.test.ts new file mode 100644 index 00000000..6a4d27e7 --- /dev/null +++ b/test/openapi-hide-routes.test.ts @@ -0,0 +1,154 @@ +import { describe, test } from 'node:test' +import type { TestContext } from 'node:test' +import Fastify from 'fastify' +import swagger from '@fastify/swagger' +import mcpPlugin from '../src/index.ts' +import type { MCPRouteSchemaContext } from '../src/types.ts' +import { createTestAuthConfig, setupMockAgent } from './auth-test-utils.ts' + +// Regression tests for https://github.com/platformatic/mcp/issues/72: +// every route the plugin registers must go through transformRouteSchema so +// it can be hidden from a generated OpenAPI document. + +async function buildApp (t: TestContext, mcpOptions: Parameters[1]) { + const app = Fastify() + t.after(() => app.close()) + + await app.register(swagger, { openapi: { info: { title: 'api', version: '1.0.0' } } }) + app.get('/users', async () => []) + await app.register(mcpPlugin, mcpOptions) + await app.ready() + + return app +} + +function documentedPaths (app: Awaited>): string[] { + return Object.keys(app.swagger().paths ?? {}).sort() +} + +const oauthDiscovery = { + 'https://auth.example.com/.well-known/oauth-authorization-server': { + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token', + introspection_endpoint: 'https://auth.example.com/oauth/introspect', + registration_endpoint: 'https://auth.example.com/oauth/register' + } +} + +describe('Hiding MCP routes from OpenAPI', () => { + test('MCP routes are documented by default', async (t: TestContext) => { + const app = await buildApp(t, {}) + + t.assert.deepStrictEqual(documentedPaths(app), ['/mcp', '/users']) + }) + + test('schema.hide removes every MCP route when SSE is disabled', async (t: TestContext) => { + const app = await buildApp(t, { + transformRouteSchema: (schema) => ({ ...schema, hide: true }) + }) + + t.assert.deepStrictEqual(documentedPaths(app), ['/users']) + }) + + test('schema.hide removes every MCP route when SSE is enabled', async (t: TestContext) => { + const app = await buildApp(t, { + enableSSE: true, + transformRouteSchema: (schema) => ({ ...schema, hide: true }) + }) + + t.assert.deepStrictEqual(documentedPaths(app), ['/users']) + }) + + test('hiddenTag removes every MCP route', async (t: TestContext) => { + const app = await buildApp(t, { + transformRouteSchema: (schema) => ({ ...schema, tags: ['X-HIDDEN'] }) + }) + + t.assert.deepStrictEqual(documentedPaths(app), ['/users']) + }) + + test('schema.hide removes the well-known routes registered for authorization', async (t: TestContext) => { + const contexts: MCPRouteSchemaContext[] = [] + const app = await buildApp(t, { + authorization: createTestAuthConfig({ resourceUri: 'https://mcp.example.com/api' }), + transformRouteSchema: (schema, context) => { + contexts.push(context) + return { ...schema, hide: true } + } + }) + + t.assert.deepStrictEqual(documentedPaths(app), ['/users']) + t.assert.deepStrictEqual(contexts.map(c => `${c.routeId} ${c.method} ${c.url}`).sort(), [ + 'mcp.delete DELETE /mcp', + 'mcp.get GET /mcp', + 'mcp.post POST /mcp', + 'wellKnown.preflight OPTIONS /.well-known/*', + 'wellKnown.protectedResourceMetadata GET /.well-known/oauth-protected-resource', + 'wellKnown.protectedResourceMetadata GET /.well-known/oauth-protected-resource/api', + 'wellKnown.protectedResourceMetadata GET /.well-known/oauth-protected-resource/mcp', + 'wellKnown.resourceHealth GET /.well-known/mcp-resource-health' + ]) + }) + + test('schema.hide removes the OAuth client routes', async (t: TestContext) => { + const restoreMock = setupMockAgent(oauthDiscovery) + t.after(restoreMock) + + const contexts: MCPRouteSchemaContext[] = [] + const app = await buildApp(t, { + authorization: createTestAuthConfig({ + oauth2Client: { + clientId: 'test-client', + authorizationServer: 'https://auth.example.com', + resourceUri: 'https://mcp.example.com', + clientIdMetadataDocument: true + } + }), + transformRouteSchema: (schema, context) => { + contexts.push(context) + return { ...schema, hide: true } + } + }) + + t.assert.deepStrictEqual(documentedPaths(app), ['/users']) + const oauthRoutes = contexts + .filter(c => c.routeId.startsWith('oauth.')) + .map(c => `${c.routeId} ${c.method} ${c.url}`) + .sort() + t.assert.deepStrictEqual(oauthRoutes, [ + 'oauth.authorize GET /oauth/authorize', + 'oauth.callback GET /oauth/callback', + 'oauth.clientIdMetadataDocument GET /.well-known/oauth-client', + 'oauth.logout POST /oauth/logout', + 'oauth.refresh POST /oauth/refresh', + 'oauth.register POST /oauth/register', + 'oauth.status GET /oauth/status', + 'oauth.validate POST /oauth/validate' + ]) + }) + + test('route schemas are preserved and prefixes are reported', async (t: TestContext) => { + const app = Fastify() + t.after(() => app.close()) + + const contexts: MCPRouteSchemaContext[] = [] + await app.register(swagger, { openapi: {} }) + await app.register(async (instance) => { + await instance.register(mcpPlugin, { + authorization: createTestAuthConfig(), + transformRouteSchema: (schema, context) => { + contexts.push(context) + return { ...schema, tags: ['MCP'] } + } + }) + }, { prefix: '/v1' }) + await app.ready() + + const spec = app.swagger() + const health = spec.paths?.['/v1/.well-known/mcp-resource-health']?.get + t.assert.deepStrictEqual(health?.tags, ['MCP']) + t.assert.ok(health?.responses?.['200']) + t.assert.ok(contexts.length > 0) + t.assert.ok(contexts.every(c => c.url.startsWith('/v1/'))) + }) +}) diff --git a/test/route-schema-customization.test.ts b/test/route-schema-customization.test.ts index f9c94cc1..8cb03cc0 100644 --- a/test/route-schema-customization.test.ts +++ b/test/route-schema-customization.test.ts @@ -53,7 +53,7 @@ describe('MCP route schema customization', () => { t.assert.strictEqual(getRoute?.schema, undefined) }) - test('transformer is called for POST and DELETE when SSE is disabled', async (t: TestContext) => { + test('transformer is called for POST, the 405 GET and DELETE when SSE is disabled', async (t: TestContext) => { const app = Fastify() t.after(() => app.close()) @@ -71,13 +71,16 @@ describe('MCP route schema customization', () => { }) await app.ready() - t.assert.strictEqual(contexts.length, 2) + t.assert.strictEqual(contexts.length, 3) t.assert.strictEqual(contexts[0].routeId, 'mcp.post') t.assert.strictEqual(contexts[0].method, 'POST') t.assert.strictEqual(contexts[0].url, '/mcp') - t.assert.strictEqual(contexts[1].routeId, 'mcp.delete') - t.assert.strictEqual(contexts[1].method, 'DELETE') + t.assert.strictEqual(contexts[1].routeId, 'mcp.get') + t.assert.strictEqual(contexts[1].method, 'GET') t.assert.strictEqual(contexts[1].url, '/mcp') + t.assert.strictEqual(contexts[2].routeId, 'mcp.delete') + t.assert.strictEqual(contexts[2].method, 'DELETE') + t.assert.strictEqual(contexts[2].url, '/mcp') }) test('transformer is called for POST, GET and DELETE when SSE is enabled', async (t: TestContext) => { @@ -125,9 +128,8 @@ describe('MCP route schema customization', () => { await app.ready() - t.assert.strictEqual(contexts.length, 2) - t.assert.strictEqual(contexts[0].url, '/v1/mcp') - t.assert.strictEqual(contexts[1].url, '/v1/mcp') + t.assert.strictEqual(contexts.length, 3) + t.assert.ok(contexts.every(c => c.url === '/v1/mcp')) }) test('returned schema is used for route registration and can include OpenAPI metadata', async (t: TestContext) => { @@ -200,7 +202,7 @@ describe('MCP route schema customization', () => { }) await app.ready() - t.assert.strictEqual(calls, 2) + t.assert.strictEqual(calls, 3) const request = { jsonrpc: JSONRPC_VERSION, @@ -213,7 +215,7 @@ describe('MCP route schema customization', () => { t.assert.strictEqual(first.statusCode, 200) t.assert.strictEqual(second.statusCode, 200) - t.assert.strictEqual(calls, 2) + t.assert.strictEqual(calls, 3) }) test('thrown transformer error rejects plugin registration', async (t: TestContext) => { @@ -307,7 +309,7 @@ describe('MCP route schema customization', () => { t.assert.deepStrictEqual(bPost?.schema?.tags, ['instance-b']) }) - test('transformer does not run for unrelated or OAuth routes', async (t: TestContext) => { + test('transformer runs for well-known routes but not for unrelated routes', async (t: TestContext) => { const app = Fastify() t.after(() => app.close()) @@ -331,6 +333,15 @@ describe('MCP route schema customization', () => { }) await app.ready() - t.assert.deepStrictEqual(contexts.map(c => c.routeId), ['mcp.post', 'mcp.delete']) + t.assert.deepStrictEqual(contexts.map(c => c.routeId), [ + 'wellKnown.preflight', + 'wellKnown.protectedResourceMetadata', + 'wellKnown.protectedResourceMetadata', + 'wellKnown.resourceHealth', + 'mcp.post', + 'mcp.get', + 'mcp.delete' + ]) + t.assert.ok(contexts.every(c => c.url !== '/health')) }) })