Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ CONTEXT7_API_URL=https://context7.com/api

# MCP HTTP server
RESOURCE_URL=
OAUTH_AUTH_SERVER_URL=
OAUTH_JWKS_URL=
EMA_ISSUER=
AUTH_SERVER_URL=
OPENAI_APPS_CHALLENGE_TOKEN=
CLIENT_IP_ENCRYPTION_KEY=
Expand Down
10 changes: 7 additions & 3 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import { randomUUID } from "node:crypto";
import {
SERVER_VERSION,
RESOURCE_URL,
AUTH_SERVER_URL,
OAUTH_AUTH_SERVER_URL,
EMA_ISSUER,
OPENAI_APPS_CHALLENGE_TOKEN,
} from "./lib/constants.js";
import { maybeElicitAuthSignIn } from "./lib/auth/auth-prompt.js";
Expand Down Expand Up @@ -473,7 +474,10 @@ async function main() {
(_req: express.Request, res: express.Response) => {
res.json({
resource: RESOURCE_URL,
authorization_servers: [AUTH_SERVER_URL],
// Each entry is an independent authorization server. Clerk handles
// regular authorization-code flows; Context7 handles only the
// enterprise-managed id-jag exchange.
authorization_servers: Array.from(new Set([OAUTH_AUTH_SERVER_URL, EMA_ISSUER])),
scopes_supported: ["profile", "email"],
bearer_methods_supported: ["header"],
});
Expand All @@ -483,7 +487,7 @@ async function main() {
app.get(
"/.well-known/oauth-authorization-server",
async (_req: express.Request, res: express.Response) => {
const authServerUrl = AUTH_SERVER_URL;
const authServerUrl = OAUTH_AUTH_SERVER_URL;

try {
const response = await fetch(`${authServerUrl}/.well-known/oauth-authorization-server`);
Expand Down
19 changes: 16 additions & 3 deletions packages/mcp/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,27 @@ export const SERVER_VERSION: string = pkg.version;

const CONTEXT7_BASE_URL = "https://context7.com";
const MCP_RESOURCE_URL = "https://mcp.context7.com";
const DEFAULT_OAUTH_AUTH_SERVER_URL = "https://clerk.context7.com";

export const CLERK_DOMAIN = "clerk.context7.com";
export const CONTEXT7_API_BASE_URL = process.env.CONTEXT7_API_URL || `${CONTEXT7_BASE_URL}/api`;
export const RESOURCE_URL = process.env.RESOURCE_URL || MCP_RESOURCE_URL;
export const AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || CONTEXT7_BASE_URL;

// Clerk owns the interactive OAuth flow and is the issuer returned in the
// authorization response. Advertising Clerk directly keeps RFC 8414 discovery
// and RFC 9207 response-issuer validation on the same authorization-server
// identity.
export const OAUTH_AUTH_SERVER_URL = (
process.env.OAUTH_AUTH_SERVER_URL || DEFAULT_OAUTH_AUTH_SERVER_URL
).replace(/\/+$/, "");
export const OAUTH_JWKS_URL =
process.env.OAUTH_JWKS_URL || `${OAUTH_AUTH_SERVER_URL}/.well-known/jwks.json`;

// Enterprise-Managed Auth (id-jag): access tokens minted by the Context7
// authorization server, validated against its public JWKS.
export const EMA_ISSUER = AUTH_SERVER_URL;
// AUTH_SERVER_URL remains a backwards-compatible alias for local EMA setups;
// it does not move interactive user OAuth. Local end-to-end OAuth environments
// must set OAUTH_AUTH_SERVER_URL separately when Clerk is not the intended issuer.
export const EMA_ISSUER =
process.env.EMA_ISSUER || process.env.AUTH_SERVER_URL || CONTEXT7_BASE_URL;
export const EMA_JWKS_URL = process.env.EMA_JWKS_URL || `${CONTEXT7_API_BASE_URL}/oauth/ema-jwks`;
export const OPENAI_APPS_CHALLENGE_TOKEN = process.env.OPENAI_APPS_CHALLENGE_TOKEN;
8 changes: 4 additions & 4 deletions packages/mcp/src/lib/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import * as jose from "jose";
import {
CLERK_DOMAIN,
CONTEXT7_API_BASE_URL,
EMA_ISSUER,
EMA_JWKS_URL,
OAUTH_AUTH_SERVER_URL,
OAUTH_JWKS_URL,
RESOURCE_URL,
} from "./constants.js";

const CLERK_ISSUER = `https://${CLERK_DOMAIN}`;
const clerkJwks = jose.createRemoteJWKSet(new URL(`https://${CLERK_DOMAIN}/.well-known/jwks.json`));
const oauthJwks = jose.createRemoteJWKSet(new URL(OAUTH_JWKS_URL));

const emaJwks = jose.createRemoteJWKSet(new URL(EMA_JWKS_URL));

Expand Down Expand Up @@ -103,7 +103,7 @@ export async function validateJWT(token: string): Promise<JWTValidationResult> {
return { valid: true };
}

await jose.jwtVerify(token, clerkJwks, { issuer: CLERK_ISSUER });
await jose.jwtVerify(token, oauthJwks, { issuer: OAUTH_AUTH_SERVER_URL });
return { valid: true };
} catch (error) {
if (error instanceof jose.errors.JWTExpired) {
Expand Down
13 changes: 13 additions & 0 deletions packages/mcp/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,19 @@ async function connect(transportKind: "http" | "stdio", era: "modern" | "legacy"
return client;
}

describe("OAuth discovery", () => {
test("advertises Clerk for user OAuth and Context7 for enterprise auth", async () => {
const metadataUrl = new URL("/.well-known/oauth-protected-resource", httpUrl);
const response = await fetch(metadataUrl);

expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
resource: "https://mcp.context7.com",
authorization_servers: ["https://clerk.context7.com", "https://context7.com"],
});
});
});

describe.each([
["http", "modern"],
["http", "legacy"],
Expand Down
44 changes: 44 additions & 0 deletions packages/mcp/test/jwt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ vi.mock("jose", async () => {
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";
const originalOAuthAuthServerUrl = process.env.OAUTH_AUTH_SERVER_URL;
const originalOAuthJwksUrl = process.env.OAUTH_JWKS_URL;

async function loadModule() {
vi.resetModules();
Expand Down Expand Up @@ -42,6 +44,16 @@ beforeEach(() => {
});

afterEach(() => {
if (originalOAuthAuthServerUrl === undefined) {
delete process.env.OAUTH_AUTH_SERVER_URL;
} else {
process.env.OAUTH_AUTH_SERVER_URL = originalOAuthAuthServerUrl;
}
if (originalOAuthJwksUrl === undefined) {
delete process.env.OAUTH_JWKS_URL;
} else {
process.env.OAUTH_JWKS_URL = originalOAuthJwksUrl;
}
vi.unstubAllGlobals();
vi.clearAllMocks();
});
Expand Down Expand Up @@ -190,4 +202,36 @@ describe("validateJWT - Clerk path", () => {
expect(result.valid).toBe(false);
expect(result.error).toBe("Token expired");
});

test("uses the configured OAuth issuer and its JWKS for verification", async () => {
process.env.OAUTH_AUTH_SERVER_URL = "https://supreme-foal-19.clerk.accounts.dev/";
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: {},
protectedHeader: { alg: "RS256" },
} as unknown as Awaited<ReturnType<typeof jose.jwtVerify>>);

const { validateJWT } = await loadModule();
const result = await validateJWT(
makeEntraToken({ iss: "https://supreme-foal-19.clerk.accounts.dev" })
);

expect(result.valid).toBe(true);
expect(jose.createRemoteJWKSet).toHaveBeenCalledWith(
new URL("https://supreme-foal-19.clerk.accounts.dev/.well-known/jwks.json")
);
expect(jose.jwtVerify).toHaveBeenCalledWith(expect.any(String), "fake-jwks", {
issuer: "https://supreme-foal-19.clerk.accounts.dev",
});
});

test("allows an explicit JWKS URL without changing the OAuth issuer", async () => {
process.env.OAUTH_AUTH_SERVER_URL = "https://oauth.example.com";
process.env.OAUTH_JWKS_URL = "https://keys.example.com/oauth/jwks.json";

await loadModule();

expect(jose.createRemoteJWKSet).toHaveBeenCalledWith(
new URL("https://keys.example.com/oauth/jwks.json")
);
});
});
Loading