diff --git a/.changeset/yellow-chairs-study.md b/.changeset/yellow-chairs-study.md new file mode 100644 index 00000000..abf60976 --- /dev/null +++ b/.changeset/yellow-chairs-study.md @@ -0,0 +1,10 @@ +--- +"@upstash/context7-mcp": minor +--- + +Add OAuth 2.0 authentication support for MCP server + +- Add new `/mcp/oauth` endpoint requiring JWT authentication +- Implement JWT validation against authorization server JWKS +- Add OAuth Protected Resource Metadata endpoint (RFC 9728) at `/.well-known/oauth-protected-resource` +- Include `WWW-Authenticate` header for OAuth discovery diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 8425abb8..6c7eb714 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -48,6 +48,7 @@ "@types/express": "^5.0.4", "commander": "^14.0.0", "express": "^5.1.0", + "jose": "^6.1.3", "undici": "^6.6.3", "zod": "^3.24.2" }, diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 3ad7d122..a32dcc36 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { searchLibraries, fetchLibraryDocumentation } from "./lib/api.js"; import { formatSearchResults } from "./lib/utils.js"; import { SearchResponse, DOCUMENTATION_MODES } from "./lib/types.js"; +import { isJWT, validateJWT } from "./lib/jwt.js"; import express from "express"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { Command } from "commander"; @@ -317,10 +318,50 @@ async function main() { ); }; - app.all("/mcp", async (req: express.Request, res: express.Response) => { + // Shared MCP request handler + const handleMcpRequest = async ( + req: express.Request, + res: express.Response, + requireAuth: boolean + ) => { try { const clientIp = getClientIp(req); const apiKey = extractApiKey(req); + const resourceUrl = process.env.RESOURCE_URL || `http://localhost:${actualPort}`; + const baseUrl = new URL(resourceUrl).origin; + + // OAuth discovery info header, used by MCP clients to discover the authorization server + res.set( + "WWW-Authenticate", + `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` + ); + + if (requireAuth) { + if (!apiKey) { + return res.status(401).json({ + jsonrpc: "2.0", + error: { + code: -32001, + message: "Authentication required. Please authenticate to use this MCP server.", + }, + id: null, + }); + } + + if (isJWT(apiKey)) { + const validationResult = await validateJWT(apiKey); + if (!validationResult.valid) { + return res.status(401).json({ + jsonrpc: "2.0", + error: { + code: -32001, + message: validationResult.error || "Invalid token. Please re-authenticate.", + }, + id: null, + }); + } + } + } const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, @@ -345,12 +386,35 @@ async function main() { }); } } - }); + }; + + // Anonymous access endpoint - no authentication required + app.all("/mcp", (req, res) => handleMcpRequest(req, res, false)); + + // OAuth-protected endpoint - requires authentication + app.all("/mcp/oauth", (req, res) => handleMcpRequest(req, res, true)); app.get("/ping", (_req: express.Request, res: express.Response) => { res.json({ status: "ok", message: "pong" }); }); + // OAuth 2.0 Protected Resource Metadata (RFC 9728) + // Used by MCP clients to discover the authorization server + app.get( + "/.well-known/oauth-protected-resource", + (_req: express.Request, res: express.Response) => { + const authServerUrl = process.env.AUTH_SERVER_URL || "https://context7.com"; + const resourceUrl = process.env.RESOURCE_URL || "https://mcp.context7.com"; + + res.json({ + resource: resourceUrl, + authorization_servers: [authServerUrl], + scopes_supported: ["mcp:read", "mcp:write"], + bearer_methods_supported: ["header"], + }); + } + ); + // Catch-all 404 handler - must be after all other routes app.use((_req: express.Request, res: express.Response) => { res.status(404).json({ diff --git a/packages/mcp/src/lib/jwt.ts b/packages/mcp/src/lib/jwt.ts new file mode 100644 index 00000000..f11ed452 --- /dev/null +++ b/packages/mcp/src/lib/jwt.ts @@ -0,0 +1,69 @@ +import * as jose from "jose"; + +const AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || "https://context7.com"; +const JWKS_URL = `${AUTH_SERVER_URL}/.well-known/jwks.json`; + +const RESOURCE_URL = (process.env.RESOURCE_URL || "https://mcp.context7.com").replace(/\/$/, ""); + +// we cache the jwks to avoid fetching it on every request +let jwksCache: jose.JWTVerifyGetKey | null = null; +let jwksCacheTime = 0; +const JWKS_CACHE_TTL = 60 * 60 * 1000; // 1 hour + +async function getJWKS(): Promise { + const now = Date.now(); + if (jwksCache && now - jwksCacheTime < JWKS_CACHE_TTL) { + return jwksCache; + } + + jwksCache = jose.createRemoteJWKSet(new URL(JWKS_URL)); + jwksCacheTime = now; + return jwksCache; +} + +export interface JWTValidationResult { + valid: boolean; + userId?: string; + clientId?: string; + scope?: string; + error?: string; +} + +export function isJWT(token: string): boolean { + const parts = token.split("."); + return parts.length === 3; +} + +export async function validateJWT(token: string): Promise { + if (!isJWT(token)) { + return { valid: false, error: "Not a valid JWT format" }; + } + + try { + const jwks = await getJWKS(); + const { payload } = await jose.jwtVerify(token, jwks, { + issuer: AUTH_SERVER_URL, + audience: RESOURCE_URL, + }); + + const result = { + valid: true, + userId: payload.sub, + clientId: payload.client_id as string, + scope: payload.scope as string, + }; + + return result; + } catch (error) { + if (error instanceof jose.errors.JWTExpired) { + return { valid: false, error: "Token expired" }; + } + if (error instanceof jose.errors.JWTClaimValidationFailed) { + return { valid: false, error: "Invalid token claims" }; + } + if (error instanceof jose.errors.JWSSignatureVerificationFailed) { + return { valid: false, error: "Invalid signature" }; + } + return { valid: false, error: "Invalid token" }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58cde648..03b7b4ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: express: specifier: ^5.1.0 version: 5.1.0 + jose: + specifier: ^6.1.3 + version: 6.1.3 undici: specifier: ^6.6.3 version: 6.22.0 @@ -1359,6 +1362,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -3279,6 +3285,8 @@ snapshots: isexe@2.0.0: {} + jose@6.1.3: {} + joycon@3.1.1: {} js-yaml@3.14.2: