Skip to content

Commit 7b0aeeb

Browse files
ctx7-2660: use shared MCP endpoint for EMA
1 parent 9b60d7b commit 7b0aeeb

7 files changed

Lines changed: 40 additions & 135 deletions

File tree

.changeset/ctx7-2660-claude-ema.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

docs/enterprise/enterprise-managed-auth/okta.mdx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -112,18 +112,20 @@ Use your **org** authorization server issuer (`https://<your-org>.okta.com`), no
112112

113113
Enterprise-Managed Auth is wired on the **client** side by your organization, not per developer. The Okta sign-in isn't triggered by a server URL someone pastes. It happens because Claude is configured to use your enterprise IdP for managed connectors.
114114

115-
Your admin connects the Okta org to Claude and enables the dedicated Context7 EMA endpoint as a managed connector:
115+
Your admin connects the Okta org to Claude and enables the same Context7 endpoint used by the
116+
Claude Code marketplace plugin as a managed connector:
116117

117118
```text
118-
https://mcp.context7.com/mcp/ema
119+
https://mcp.context7.com/mcp?client=claude-code-plugin
119120
```
120121

121-
This URL is intentionally different from the authless `/mcp` endpoint and the interactive OAuth
122-
`/mcp/oauth` endpoint. It returns an authentication challenge whose protected-resource metadata is
123-
bound to the full `/mcp/ema` URL and points Claude to Context7's JWT bearer token exchange.
122+
The `client=claude-code-plugin` query identifies marketplace traffic and makes authentication
123+
mandatory. It does not select EMA by itself. Claude selects managed authorization when your Claude
124+
organization enables it for Context7; otherwise Claude Code uses the normal interactive OAuth flow.
125+
Both flows use the same MCP resource and protected-resource metadata.
124126

125127
After the connector is enabled, Claude obtains an ID-JAG from Okta and exchanges it with Context7
126-
for a path-bound access token. There is no token to paste and no per-user OAuth consent screen.
128+
for an access token. There is no token to paste and no per-user OAuth consent screen.
127129

128130
See Claude's
129131
[Enterprise-Managed Auth for connectors](https://claude.com/docs/connectors/building/enterprise-managed-auth)
@@ -140,9 +142,10 @@ endpoints (`https://<your-org>.okta.com/oauth2/v1/...`).
140142

141143
### Claude opens an interactive OAuth screen
142144

143-
Confirm that the managed connector URL ends in `/mcp/ema`. The `/mcp/oauth` endpoint is for
144-
interactive OAuth and advertises a different authorization server. Also confirm that your Claude
145-
organization has Enterprise-Managed Auth enabled for the connector.
145+
Confirm that the managed connector URL exactly matches the URL above and that your Claude
146+
organization has managed authorization enabled for Context7. The query parameter only identifies
147+
the marketplace plugin; Claude's organization setting determines whether it uses EMA or interactive
148+
OAuth.
146149

147150
### A valid Okta user can't be provisioned
148151

packages/mcp/src/index.ts

Lines changed: 8 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,14 @@ import {
1111
extractClientInfoFromUserAgent,
1212
envelopeClientInfo,
1313
} from "./lib/utils.js";
14-
import { isJWT, validateEmaJWT, validateJWT } from "./lib/jwt.js";
14+
import { isJWT, validateJWT } from "./lib/jwt.js";
1515
import express from "express";
1616
import { Command } from "commander";
1717
import { AsyncLocalStorage } from "async_hooks";
1818
import { randomUUID } from "node:crypto";
1919
import {
2020
SERVER_VERSION,
2121
RESOURCE_URL,
22-
EMA_RESOURCE_METADATA_URL,
23-
EMA_RESOURCE_URL,
2422
OAUTH_AUTH_SERVER_URL,
2523
EMA_ISSUER,
2624
OPENAI_APPS_CHALLENGE_TOKEN,
@@ -37,7 +35,7 @@ function getPluginFromRequest(req: express.Request): typeof CLAUDE_CODE_PLUGIN |
3735
}
3836

3937
function requiresAuthentication(req: express.Request, plugin?: typeof CLAUDE_CODE_PLUGIN): boolean {
40-
return req.path === "/mcp/oauth" || req.path === "/mcp/ema" || Boolean(plugin);
38+
return req.path === "/mcp/oauth" || Boolean(plugin);
4139
}
4240

4341
// Parse CLI arguments using commander
@@ -405,17 +403,17 @@ async function main() {
405403
try {
406404
const plugin = getPluginFromRequest(req);
407405
const apiKey = extractApiKey(req);
408-
const isEmaRequest = req.path === "/mcp/ema";
409-
const resourceMetadataUrl = isEmaRequest
410-
? EMA_RESOURCE_METADATA_URL
411-
: `${new URL(RESOURCE_URL).origin}/.well-known/oauth-protected-resource`;
406+
const baseUrl = new URL(RESOURCE_URL).origin;
412407

413408
// OAuth discovery info header, used by MCP clients to discover the authorization server
414409
// TODO: @modelcontextprotocol/server now ships canonical OAuth helpers
415410
// (bearerAuthChallengeResponse, buildOAuthProtectedResourceMetadata,
416411
// oauthMetadataResponse) — replace this hand-rolled header and the
417412
// /.well-known/oauth-protected-resource route with them.
418-
res.set("WWW-Authenticate", `Bearer resource_metadata="${resourceMetadataUrl}"`);
413+
res.set(
414+
"WWW-Authenticate",
415+
`Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`
416+
);
419417

420418
if (requiresAuthentication(req, plugin)) {
421419
if (!apiKey) {
@@ -429,18 +427,8 @@ async function main() {
429427
});
430428
}
431429

432-
if (isEmaRequest && !isJWT(apiKey)) {
433-
return res.status(401).json({
434-
jsonrpc: "2.0",
435-
error: { code: -32001, message: "EMA access token required" },
436-
id: null,
437-
});
438-
}
439-
440430
if (isJWT(apiKey)) {
441-
const validationResult = isEmaRequest
442-
? await validateEmaJWT(apiKey)
443-
: await validateJWT(apiKey);
431+
const validationResult = await validateJWT(apiKey);
444432
if (!validationResult.valid) {
445433
return res.status(401).json({
446434
jsonrpc: "2.0",
@@ -486,13 +474,6 @@ async function main() {
486474
await handleMcpRequest(req, res);
487475
});
488476

489-
// Enterprise-Managed Auth endpoint. It has separate protected-resource
490-
// metadata so clients discover Context7's id-jag exchange instead of the
491-
// Clerk authorization server used by interactive OAuth.
492-
app.all("/mcp/ema", async (req, res) => {
493-
await handleMcpRequest(req, res);
494-
});
495-
496477
app.get("/ping", (_req: express.Request, res: express.Response) => {
497478
res.json({ status: "ok", message: "pong" });
498479
});
@@ -514,18 +495,6 @@ async function main() {
514495
}
515496
);
516497

517-
app.get(
518-
"/.well-known/oauth-protected-resource/mcp/ema",
519-
(_req: express.Request, res: express.Response) => {
520-
res.json({
521-
resource: EMA_RESOURCE_URL,
522-
authorization_servers: [EMA_ISSUER],
523-
scopes_supported: ["profile", "email"],
524-
bearer_methods_supported: ["header"],
525-
});
526-
}
527-
);
528-
529498
app.get(
530499
"/.well-known/oauth-authorization-server",
531500
async (_req: express.Request, res: express.Response) => {

packages/mcp/src/lib/constants.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ const DEFAULT_OAUTH_AUTH_SERVER_URL = "https://clerk.context7.com";
1313

1414
export const CONTEXT7_API_BASE_URL = process.env.CONTEXT7_API_URL || `${CONTEXT7_BASE_URL}/api`;
1515
export const RESOURCE_URL = process.env.RESOURCE_URL || MCP_RESOURCE_URL;
16-
export const EMA_RESOURCE_URL = `${MCP_RESOURCE_URL}/mcp/ema`;
17-
export const EMA_RESOURCE_METADATA_URL = `${MCP_RESOURCE_URL}/.well-known/oauth-protected-resource/mcp/ema`;
1816

1917
// Clerk owns the interactive OAuth flow and is the issuer returned in the
2018
// authorization response. Advertising Clerk directly keeps RFC 8414 discovery

packages/mcp/src/lib/jwt.ts

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import {
33
CONTEXT7_API_BASE_URL,
44
EMA_ISSUER,
55
EMA_JWKS_URL,
6-
EMA_RESOURCE_URL,
76
OAUTH_AUTH_SERVER_URL,
87
OAUTH_JWKS_URL,
8+
RESOURCE_URL,
99
} from "./constants.js";
1010

1111
const oauthJwks = jose.createRemoteJWKSet(new URL(OAUTH_JWKS_URL));
@@ -71,24 +71,6 @@ export function isJWT(token: string): boolean {
7171
return token.split(".").length === 3;
7272
}
7373

74-
export async function validateEmaJWT(token: string): Promise<JWTValidationResult> {
75-
try {
76-
await jose.jwtVerify(token, emaJwks, { issuer: EMA_ISSUER, audience: EMA_RESOURCE_URL });
77-
return { valid: true };
78-
} catch (error) {
79-
if (error instanceof jose.errors.JWTExpired) {
80-
return { valid: false, error: "Token expired" };
81-
}
82-
if (error instanceof jose.errors.JWTClaimValidationFailed) {
83-
return { valid: false, error: "Invalid token claims" };
84-
}
85-
if (error instanceof jose.errors.JWSSignatureVerificationFailed) {
86-
return { valid: false, error: "Invalid signature" };
87-
}
88-
return { valid: false, error: "Invalid token" };
89-
}
90-
}
91-
9274
export async function validateJWT(token: string): Promise<JWTValidationResult> {
9375
try {
9476
const decoded = jose.decodeJwt(token);
@@ -117,7 +99,7 @@ export async function validateJWT(token: string): Promise<JWTValidationResult> {
11799
}
118100

119101
if (iss === EMA_ISSUER) {
120-
await jose.jwtVerify(token, emaJwks, { issuer: EMA_ISSUER, audience: EMA_RESOURCE_URL });
102+
await jose.jwtVerify(token, emaJwks, { issuer: EMA_ISSUER, audience: RESOURCE_URL });
121103
return { valid: true };
122104
}
123105

packages/mcp/test/integration.test.ts

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -160,48 +160,6 @@ describe("OAuth discovery", () => {
160160
authorization_servers: ["https://clerk.context7.com", "https://context7.com"],
161161
});
162162
});
163-
164-
test("advertises only the EMA issuer for the path-bound EMA resource", async () => {
165-
const metadataUrl = new URL("/.well-known/oauth-protected-resource/mcp/ema", httpUrl);
166-
const response = await fetch(metadataUrl);
167-
168-
expect(response.status).toBe(200);
169-
expect(await response.json()).toEqual({
170-
resource: "https://mcp.context7.com/mcp/ema",
171-
authorization_servers: ["https://context7.com"],
172-
scopes_supported: ["profile", "email"],
173-
bearer_methods_supported: ["header"],
174-
});
175-
});
176-
177-
test("challenges unauthenticated EMA requests with the path-bound metadata URL", async () => {
178-
const response = await fetch(new URL("/mcp/ema", httpUrl), {
179-
method: "POST",
180-
headers: { "content-type": "application/json" },
181-
body: "{}",
182-
});
183-
184-
expect(response.status).toBe(401);
185-
expect(response.headers.get("www-authenticate")).toBe(
186-
'Bearer resource_metadata="https://mcp.context7.com/.well-known/oauth-protected-resource/mcp/ema"'
187-
);
188-
});
189-
190-
test("rejects opaque bearer credentials on the EMA endpoint", async () => {
191-
const response = await fetch(new URL("/mcp/ema", httpUrl), {
192-
method: "POST",
193-
headers: {
194-
authorization: "Bearer not-an-ema-token",
195-
"content-type": "application/json",
196-
},
197-
body: "{}",
198-
});
199-
200-
expect(response.status).toBe(401);
201-
expect(await response.json()).toMatchObject({
202-
error: { message: "EMA access token required" },
203-
});
204-
});
205163
});
206164

207165
describe("HTTP API key headers", () => {

packages/mcp/test/jwt.test.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -58,24 +58,6 @@ afterEach(() => {
5858
vi.clearAllMocks();
5959
});
6060

61-
describe("validateJWT - EMA path", () => {
62-
test("requires the EMA issuer and path-bound resource", async () => {
63-
vi.mocked(jose.jwtVerify).mockResolvedValue({
64-
payload: {},
65-
protectedHeader: { alg: "RS256" },
66-
} as unknown as Awaited<ReturnType<typeof jose.jwtVerify>>);
67-
68-
const { validateEmaJWT } = await loadModule();
69-
const result = await validateEmaJWT(makeEntraToken({ iss: "https://clerk.context7.com" }));
70-
71-
expect(result.valid).toBe(true);
72-
expect(jose.jwtVerify).toHaveBeenCalledWith(expect.any(String), "fake-jwks", {
73-
issuer: "https://context7.com",
74-
audience: "https://mcp.context7.com/mcp/ema",
75-
});
76-
});
77-
});
78-
7961
describe("isJWT", () => {
8062
test("returns true for 3-part dotted strings", async () => {
8163
const { isJWT } = await loadModule();
@@ -195,6 +177,24 @@ describe("validateJWT - Entra path", () => {
195177
});
196178
});
197179

180+
describe("validateJWT - EMA path", () => {
181+
test("verifies Context7-issued tokens for the shared MCP resource", async () => {
182+
vi.mocked(jose.jwtVerify).mockResolvedValue({
183+
payload: {},
184+
protectedHeader: { alg: "RS256" },
185+
} as unknown as Awaited<ReturnType<typeof jose.jwtVerify>>);
186+
187+
const { validateJWT } = await loadModule();
188+
const result = await validateJWT(makeEntraToken({ iss: "https://context7.com" }));
189+
190+
expect(result.valid).toBe(true);
191+
expect(jose.jwtVerify).toHaveBeenCalledWith(expect.any(String), "fake-jwks", {
192+
issuer: "https://context7.com",
193+
audience: "https://mcp.context7.com",
194+
});
195+
});
196+
});
197+
198198
describe("validateJWT - Clerk path", () => {
199199
test("verifies against Clerk JWKS for non-Entra issuers", async () => {
200200
vi.mocked(jose.jwtVerify).mockResolvedValue({

0 commit comments

Comments
 (0)