From 741781ff0c5a6432f8d0c7eaf03526e62a85656c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Mon, 18 May 2026 18:47:55 +0300 Subject: [PATCH 1/3] feat(mcp): multi-tenant Entra ID validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect inbound Entra v2 tokens by issuer pattern, fetch per-teamspace configuration (tenantId, audience, requiredScope) from the Context7 app, and verify the token against the matching tenant's JWKS. The MCP server only validates — user resolution happens in the app middleware against the entra_user_mappings table. Per-tenant JWKS cache and a 5-minute in-memory config cache keyed by JWT audience reduce overhead under load. --- .changeset/mcp-multi-tenant-entra.md | 5 + packages/mcp/src/lib/jwt.ts | 80 +++++++++++-- packages/mcp/test/jwt.test.ts | 166 +++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 9 deletions(-) create mode 100644 .changeset/mcp-multi-tenant-entra.md create mode 100644 packages/mcp/test/jwt.test.ts diff --git a/.changeset/mcp-multi-tenant-entra.md b/.changeset/mcp-multi-tenant-entra.md new file mode 100644 index 00000000..1e9d45cc --- /dev/null +++ b/.changeset/mcp-multi-tenant-entra.md @@ -0,0 +1,5 @@ +--- +"@upstash/context7-mcp": minor +--- + +Add multi-tenant Microsoft Entra ID validation for MCP tokens. The server now detects inbound Entra v2 tokens by issuer pattern, fetches per-teamspace configuration (`tenantId`, `audience`, `requiredScope`) from the Context7 app, and verifies the token against the matching tenant's JWKS. Returns the resolved user identity (`teamspaceId`, `oid`, `email`, `name`) on success so per-user request context can be set up downstream. Per-tenant JWKS cache and a 5-minute in-memory config cache keyed by JWT audience reduce overhead under load. diff --git a/packages/mcp/src/lib/jwt.ts b/packages/mcp/src/lib/jwt.ts index beacb113..ca31ec8c 100644 --- a/packages/mcp/src/lib/jwt.ts +++ b/packages/mcp/src/lib/jwt.ts @@ -1,10 +1,51 @@ import * as jose from "jose"; -import { CLERK_DOMAIN } from "./constants.js"; +import { CLERK_DOMAIN, CONTEXT7_API_BASE_URL } from "./constants.js"; -const JWKS_URL = `https://${CLERK_DOMAIN}/.well-known/jwks.json`; -const ISSUER = `https://${CLERK_DOMAIN}`; +const CLERK_ISSUER = `https://${CLERK_DOMAIN}`; +const clerkJwks = jose.createRemoteJWKSet( + new URL(`https://${CLERK_DOMAIN}/.well-known/jwks.json`) +); -const jwks = jose.createRemoteJWKSet(new URL(JWKS_URL)); +const ENTRA_V2_ISSUER_RE = /^https:\/\/login\.microsoftonline\.com\/[0-9a-f-]{36}\/v2\.0$/; + +const jwksByTenant = new Map>(); +function entraJwks(tenantId: string) { + let jwks = jwksByTenant.get(tenantId); + if (!jwks) { + jwks = jose.createRemoteJWKSet( + new URL(`https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`) + ); + jwksByTenant.set(tenantId, jwks); + } + return jwks; +} + +interface EntraConfig { + teamspaceId: string; + tenantId: string; + requiredScope: string | null; +} + +const CONFIG_TTL_MS = 5 * 60 * 1000; +const configByAudience = new Map(); + +async function fetchEntraConfig(audience: string): Promise { + const now = Date.now(); + const cached = configByAudience.get(audience); + if (cached && cached.expiresAt > now) return cached.value; + + let value: EntraConfig | null = null; + try { + const res = await fetch( + `${CONTEXT7_API_BASE_URL}/v2/entra/config/${encodeURIComponent(audience)}` + ); + if (res.ok) value = (await res.json()) as EntraConfig; + } catch { + // network error: fall through and cache null briefly + } + configByAudience.set(audience, { value, expiresAt: now + CONFIG_TTL_MS }); + return value; +} export interface JWTValidationResult { valid: boolean; @@ -12,16 +53,37 @@ export interface JWTValidationResult { } export function isJWT(token: string): boolean { - const parts = token.split("."); - return parts.length === 3; + return token.split(".").length === 3; } export async function validateJWT(token: string): Promise { try { - await jose.jwtVerify(token, jwks, { - issuer: ISSUER, - }); + const decoded = jose.decodeJwt(token); + const iss = typeof decoded.iss === "string" ? decoded.iss : ""; + + if (ENTRA_V2_ISSUER_RE.test(iss)) { + const audience = typeof decoded.aud === "string" ? decoded.aud : ""; + if (!audience) return { valid: false, error: "Missing audience" }; + + const config = await fetchEntraConfig(audience); + if (!config) return { valid: false, error: "Unknown audience" }; + + const { payload } = await jose.jwtVerify(token, entraJwks(config.tenantId), { + issuer: `https://login.microsoftonline.com/${config.tenantId}/v2.0`, + audience, + }); + + if (config.requiredScope) { + const scopes = String(payload.scp ?? "").split(" "); + if (!scopes.includes(config.requiredScope)) { + return { valid: false, error: "Missing required scope" }; + } + } + + return { valid: true }; + } + await jose.jwtVerify(token, clerkJwks, { issuer: CLERK_ISSUER }); return { valid: true }; } catch (error) { if (error instanceof jose.errors.JWTExpired) { diff --git a/packages/mcp/test/jwt.test.ts b/packages/mcp/test/jwt.test.ts new file mode 100644 index 00000000..999db921 --- /dev/null +++ b/packages/mcp/test/jwt.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import * as jose from "jose"; + +vi.mock("jose", async () => { + const actual = await vi.importActual("jose"); + return { + ...actual, + createRemoteJWKSet: vi.fn(() => "fake-jwks" as unknown as ReturnType), + jwtVerify: vi.fn(), + }; +}); + +const TENANT_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; +const ENTRA_ISSUER = `https://login.microsoftonline.com/${TENANT_ID}/v2.0`; +const AUDIENCE = "6ff6a635-03d9-472d-a7f1-dc98a4e5fde2"; + +async function loadModule() { + vi.resetModules(); + return import("../src/lib/jwt.js"); +} + +function makeFetchResponse(init: Partial & { jsonData?: unknown }): Response { + const { jsonData, ...rest } = init; + return { + ok: true, + status: 200, + json: async () => jsonData, + ...rest, + } as Response; +} + +function makeEntraToken(payload: jose.JWTPayload): string { + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +} + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("isJWT", () => { + test("returns true for 3-part dotted strings", async () => { + const { isJWT } = await loadModule(); + expect(isJWT("a.b.c")).toBe(true); + }); + + test("returns false for non-JWT strings", async () => { + const { isJWT } = await loadModule(); + expect(isJWT("not-a-jwt")).toBe(false); + expect(isJWT("only.two")).toBe(false); + expect(isJWT("a.b.c.d")).toBe(false); + }); +}); + +describe("validateJWT - Entra path", () => { + test("validates token after fetching tenant config", async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce( + makeFetchResponse({ + jsonData: { teamspaceId: "team-1", tenantId: TENANT_ID, requiredScope: "mcp.access" }, + }) + ); + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: { oid: "user-oid", scp: "mcp.access" }, + protectedHeader: { alg: "RS256" }, + } as unknown as Awaited>); + + const { validateJWT } = await loadModule(); + const result = await validateJWT(makeEntraToken({ iss: ENTRA_ISSUER, aud: AUDIENCE })); + + expect(result.valid).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toContain(`/v2/entra/config/${AUDIENCE}`); + }); + + test("returns 'Unknown audience' when config endpoint returns 404", async () => { + vi.mocked(fetch).mockResolvedValue(makeFetchResponse({ ok: false, status: 404 })); + + const { validateJWT } = await loadModule(); + const result = await validateJWT(makeEntraToken({ iss: ENTRA_ISSUER, aud: "unknown-aud" })); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Unknown audience"); + expect(jose.jwtVerify).not.toHaveBeenCalled(); + }); + + test("returns 'Missing required scope' when scp claim lacks the configured scope", async () => { + vi.mocked(fetch).mockResolvedValue( + makeFetchResponse({ + jsonData: { teamspaceId: "team-1", tenantId: TENANT_ID, requiredScope: "mcp.access" }, + }) + ); + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: { scp: "other.scope" }, + protectedHeader: { alg: "RS256" }, + } as unknown as Awaited>); + + const { validateJWT } = await loadModule(); + const result = await validateJWT(makeEntraToken({ iss: ENTRA_ISSUER, aud: AUDIENCE })); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Missing required scope"); + }); + + test("returns 'Missing audience' for Entra issuer with no aud claim", async () => { + const { validateJWT } = await loadModule(); + const result = await validateJWT(makeEntraToken({ iss: ENTRA_ISSUER })); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Missing audience"); + expect(fetch).not.toHaveBeenCalled(); + }); + + test("caches config across repeated audiences", async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue( + makeFetchResponse({ + jsonData: { teamspaceId: "team-1", tenantId: TENANT_ID, requiredScope: null }, + }) + ); + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: { oid: "u" }, + protectedHeader: { alg: "RS256" }, + } as unknown as Awaited>); + + const { validateJWT } = await loadModule(); + const token = makeEntraToken({ iss: ENTRA_ISSUER, aud: AUDIENCE }); + await validateJWT(token); + await validateJWT(token); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("validateJWT - Clerk path", () => { + test("verifies against Clerk JWKS for non-Entra issuers", async () => { + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: {}, + protectedHeader: { alg: "RS256" }, + } as unknown as Awaited>); + + const { validateJWT } = await loadModule(); + const result = await validateJWT(makeEntraToken({ iss: "https://clerk.context7.com" })); + + expect(result.valid).toBe(true); + expect(fetch).not.toHaveBeenCalled(); + }); + + test("returns 'Token expired' when jwtVerify throws JWTExpired", async () => { + vi.mocked(jose.jwtVerify).mockRejectedValue( + new jose.errors.JWTExpired("expired", { payload: {}, protectedHeader: { alg: "RS256" } }) + ); + + const { validateJWT } = await loadModule(); + const result = await validateJWT(makeEntraToken({ iss: "https://clerk.context7.com" })); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Token expired"); + }); +}); From b1af7d9f04ce3727d41f1b5098d11342e63b699f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Thu, 21 May 2026 15:17:34 +0300 Subject: [PATCH 2/3] fix(mcp): prettier formatting + refresh changeset Resolves prettier/eslint errors blocking the test workflow, and refreshes the changeset to match the actual MCP-side behavior (validate only; user resolution lives in the app middleware). --- .changeset/mcp-multi-tenant-entra.md | 2 +- packages/mcp/src/lib/jwt.ts | 4 +--- packages/mcp/test/jwt.test.ts | 4 +++- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/mcp-multi-tenant-entra.md b/.changeset/mcp-multi-tenant-entra.md index 1e9d45cc..3b984c12 100644 --- a/.changeset/mcp-multi-tenant-entra.md +++ b/.changeset/mcp-multi-tenant-entra.md @@ -2,4 +2,4 @@ "@upstash/context7-mcp": minor --- -Add multi-tenant Microsoft Entra ID validation for MCP tokens. The server now detects inbound Entra v2 tokens by issuer pattern, fetches per-teamspace configuration (`tenantId`, `audience`, `requiredScope`) from the Context7 app, and verifies the token against the matching tenant's JWKS. Returns the resolved user identity (`teamspaceId`, `oid`, `email`, `name`) on success so per-user request context can be set up downstream. Per-tenant JWKS cache and a 5-minute in-memory config cache keyed by JWT audience reduce overhead under load. +Add multi-tenant Microsoft Entra ID validation for MCP tokens. The server now detects inbound Entra v2 tokens by issuer pattern, fetches per-teamspace configuration (`tenantId`, `audience`, `requiredScope`) from the Context7 app, and verifies the token against the matching tenant's JWKS, enforcing the required scope claim when configured. User resolution happens downstream in the Context7 app against a pre-provisioned user mapping table — the MCP server only validates. Per-tenant JWKS cache and a 5-minute in-memory config cache keyed by JWT audience reduce overhead under load. diff --git a/packages/mcp/src/lib/jwt.ts b/packages/mcp/src/lib/jwt.ts index ca31ec8c..b8730806 100644 --- a/packages/mcp/src/lib/jwt.ts +++ b/packages/mcp/src/lib/jwt.ts @@ -2,9 +2,7 @@ import * as jose from "jose"; import { CLERK_DOMAIN, CONTEXT7_API_BASE_URL } from "./constants.js"; const CLERK_ISSUER = `https://${CLERK_DOMAIN}`; -const clerkJwks = jose.createRemoteJWKSet( - new URL(`https://${CLERK_DOMAIN}/.well-known/jwks.json`) -); +const clerkJwks = jose.createRemoteJWKSet(new URL(`https://${CLERK_DOMAIN}/.well-known/jwks.json`)); const ENTRA_V2_ISSUER_RE = /^https:\/\/login\.microsoftonline\.com\/[0-9a-f-]{36}\/v2\.0$/; diff --git a/packages/mcp/test/jwt.test.ts b/packages/mcp/test/jwt.test.ts index 999db921..aeb4c757 100644 --- a/packages/mcp/test/jwt.test.ts +++ b/packages/mcp/test/jwt.test.ts @@ -5,7 +5,9 @@ vi.mock("jose", async () => { const actual = await vi.importActual("jose"); return { ...actual, - createRemoteJWKSet: vi.fn(() => "fake-jwks" as unknown as ReturnType), + createRemoteJWKSet: vi.fn( + () => "fake-jwks" as unknown as ReturnType + ), jwtVerify: vi.fn(), }; }); From 5ca27e7d1a16318570d3dbf663dd48a6c5d57210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Tue, 26 May 2026 11:53:19 +0300 Subject: [PATCH 3/3] fix(mcp): only negative-cache 404 responses, not transient errors Address review: caching null under the same 5-minute TTL on every failure mode meant one transient blip (5xx, network error, JSON parse error) locked every Entra token for that audience out of the server for 5 minutes with a misleading "Unknown audience" message. Now only the authoritative 404 ("audience not configured") is cached as null. 5xx, network errors, and parse errors fall through without caching so the next request retries. --- packages/mcp/src/lib/jwt.ts | 19 ++++++++++++++----- packages/mcp/test/jwt.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/mcp/src/lib/jwt.ts b/packages/mcp/src/lib/jwt.ts index b8730806..2695c461 100644 --- a/packages/mcp/src/lib/jwt.ts +++ b/packages/mcp/src/lib/jwt.ts @@ -32,17 +32,26 @@ async function fetchEntraConfig(audience: string): Promise { const cached = configByAudience.get(audience); if (cached && cached.expiresAt > now) return cached.value; - let value: EntraConfig | null = null; try { const res = await fetch( `${CONTEXT7_API_BASE_URL}/v2/entra/config/${encodeURIComponent(audience)}` ); - if (res.ok) value = (await res.json()) as EntraConfig; + if (res.ok) { + const value = (await res.json()) as EntraConfig; + configByAudience.set(audience, { value, expiresAt: now + CONFIG_TTL_MS }); + return value; + } + if (res.status === 404) { + // Authoritative "not configured" response — safe to cache the miss so we + // don't hammer the app on every token verification. + configByAudience.set(audience, { value: null, expiresAt: now + CONFIG_TTL_MS }); + return null; + } } catch { - // network error: fall through and cache null briefly + // Network or JSON parse error: transient. Fall through without caching so + // the next request retries. } - configByAudience.set(audience, { value, expiresAt: now + CONFIG_TTL_MS }); - return value; + return null; } export interface JWTValidationResult { diff --git a/packages/mcp/test/jwt.test.ts b/packages/mcp/test/jwt.test.ts index aeb4c757..4abd8cc0 100644 --- a/packages/mcp/test/jwt.test.ts +++ b/packages/mcp/test/jwt.test.ts @@ -138,6 +138,31 @@ describe("validateJWT - Entra path", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + + test("does not cache transient 500 errors — next request retries", async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce(makeFetchResponse({ ok: false, status: 500 })); + fetchMock.mockResolvedValueOnce( + makeFetchResponse({ + jsonData: { teamspaceId: "team-1", tenantId: TENANT_ID, requiredScope: null }, + }) + ); + vi.mocked(jose.jwtVerify).mockResolvedValue({ + payload: { oid: "u" }, + protectedHeader: { alg: "RS256" }, + } as unknown as Awaited>); + + const { validateJWT } = await loadModule(); + const token = makeEntraToken({ iss: ENTRA_ISSUER, aud: AUDIENCE }); + + const first = await validateJWT(token); + expect(first.valid).toBe(false); + expect(first.error).toBe("Unknown audience"); + + const second = await validateJWT(token); + expect(second.valid).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); }); describe("validateJWT - Clerk path", () => {