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
10 changes: 10 additions & 0 deletions .changeset/yellow-chairs-study.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
68 changes: 66 additions & 2 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand Down
69 changes: 69 additions & 0 deletions packages/mcp/src/lib/jwt.ts
Original file line number Diff line number Diff line change
@@ -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<jose.JWTVerifyGetKey> {
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<JWTValidationResult> {
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" };
}
}
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

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