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')) }) })