Skip to content

Commit 78825be

Browse files
fix(oauth-proxy): handle MCP servers without Protected Resource Metadata (#2124)
* fix(oauth-proxy): handle MCP servers without Protected Resource Metadata Servers like Apify support OAuth but don't implement RFC 9728 Protected Resource Metadata (/.well-known/oauth-protected-resource returns 404). They do expose /.well-known/oauth-authorization-server at the root and return WWW-Authenticate header on 401 responses. This commit adds fallback handling: 1. protectedResourceMetadataHandler: When origin returns 404 for the metadata endpoint, check if it supports OAuth via WWW-Authenticate header and generate synthetic metadata pointing to our proxy. 2. getOriginAuthServer: When Protected Resource Metadata is unavailable or has empty authorization_servers, fall back to the origin's root. 3. /oauth-proxy/:connectionId/* handler: Same fallback to origin root when Protected Resource Metadata doesn't exist. Added tests: - Synthetic metadata generation when origin supports OAuth via WWW-Authenticate - 404 passthrough when origin doesn't support OAuth at all - Fallback to origin root for auth server discovery * Support 406 for fallback too * Fix knip * Cubic review * refactor fn * add more mcps to the e2e test --------- Co-authored-by: viktormarinho <viktormpcs@gmail.com>
1 parent 8eb6535 commit 78825be

4 files changed

Lines changed: 314 additions & 39 deletions

File tree

apps/mesh/src/api/app.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -214,19 +214,28 @@ export function createApp(options: CreateAppOptions = {}) {
214214
return c.json({ error: "Connection not found" }, 404);
215215
}
216216

217-
// Get origin auth server - uses shared function that tries all 3 well-known URL formats
217+
// Get origin auth server - tries Protected Resource Metadata first, then falls back to origin root
218218
const resourceRes = await fetchProtectedResourceMetadata(
219219
connection.connection_url,
220220
);
221-
if (!resourceRes.ok) {
222-
return c.json({ error: "Failed to get resource metadata" }, 502);
221+
222+
let originAuthServer: string | undefined;
223+
const connUrl = new URL(connection.connection_url);
224+
225+
if (resourceRes.ok) {
226+
// Origin has Protected Resource Metadata - use authorization_servers from it
227+
const resourceData = (await resourceRes.json()) as {
228+
authorization_servers?: string[];
229+
};
230+
originAuthServer = resourceData.authorization_servers?.[0];
223231
}
224-
const resourceData = (await resourceRes.json()) as {
225-
authorization_servers?: string[];
226-
};
227-
const originAuthServer = resourceData.authorization_servers?.[0];
232+
233+
// Fall back to origin root if:
234+
// - Origin doesn't have Protected Resource Metadata (like Apify)
235+
// - Or metadata exists but has empty/missing authorization_servers
236+
// Many servers expose /.well-known/oauth-authorization-server at the root even without RFC 9728
228237
if (!originAuthServer) {
229-
return c.json({ error: "No authorization server found" }, 404);
238+
originAuthServer = connUrl.origin;
230239
}
231240

232241
// Get OAuth endpoints from auth server metadata - uses shared function that tries all formats

apps/mesh/src/api/routes/oauth-proxy.e2e.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ const MCP_SERVERS = [
4444
{ url: "https://mcp.vercel.com", name: "Vercel" },
4545
{ url: "https://mcp.prisma.io/sse", name: "Prisma" },
4646
{ url: "https://mcp.supabase.com/mcp", name: "Supabase" },
47+
{ url: "https://api.grain.com/_/mcp", name: "Grain" },
48+
{ url: "https://mcp.apify.com/", name: "Apify" },
4749
];
4850

4951
/** MCP servers that DON'T support OAuth - should return 401 without WWW-Authenticate */

apps/mesh/src/api/routes/oauth-proxy.test.ts

Lines changed: 173 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,117 @@ describe("OAuth Proxy Routes", () => {
271271

272272
expect(res.status).toBe(401);
273273
});
274+
275+
test("generates synthetic metadata when origin has no metadata but supports OAuth via WWW-Authenticate", async () => {
276+
mockConnectionStorage({
277+
connection_url: "https://mcp.example.com",
278+
});
279+
280+
global.fetch = mock((url: string, options?: RequestInit) => {
281+
// First 3 calls: Protected Resource Metadata discovery (returns 404)
282+
if (
283+
(url as string).includes("oauth-protected-resource") &&
284+
options?.method === "GET"
285+
) {
286+
return Promise.resolve(
287+
new Response(JSON.stringify({ error: "Not found" }), {
288+
status: 404,
289+
statusText: "Not Found",
290+
}),
291+
);
292+
}
293+
294+
// 4th call: checkOriginSupportsOAuth - returns 401 with WWW-Authenticate
295+
if (options?.method === "POST") {
296+
return Promise.resolve(
297+
new Response(
298+
JSON.stringify({
299+
error: "invalid_token",
300+
error_description: "Missing or invalid access token",
301+
}),
302+
{
303+
status: 401,
304+
headers: {
305+
"WWW-Authenticate":
306+
'Bearer realm="OAuth", error="invalid_token"',
307+
"Content-Type": "application/json",
308+
},
309+
},
310+
),
311+
);
312+
}
313+
314+
return Promise.resolve(
315+
new Response(JSON.stringify({ error: "Unexpected request" }), {
316+
status: 500,
317+
}),
318+
);
319+
}) as unknown as typeof fetch;
320+
321+
const res = await app.request(
322+
"http://localhost:3000/.well-known/oauth-protected-resource/mcp/conn_123",
323+
);
324+
325+
expect(res.status).toBe(200);
326+
const body = (await res.json()) as {
327+
resource: string;
328+
authorization_servers: string[];
329+
bearer_methods_supported: string[];
330+
scopes_supported: string[];
331+
};
332+
expect(body.resource).toBe("http://localhost:3000/mcp/conn_123");
333+
expect(body.authorization_servers).toEqual([
334+
"http://localhost:3000/oauth-proxy/conn_123",
335+
]);
336+
expect(body.bearer_methods_supported).toEqual(["header"]);
337+
expect(body.scopes_supported).toEqual(["*"]);
338+
});
339+
340+
test("returns 404 when origin has no metadata and does not support OAuth", async () => {
341+
mockConnectionStorage({
342+
connection_url: "https://mcp.example.com",
343+
});
344+
345+
global.fetch = mock((url: string, options?: RequestInit) => {
346+
// Protected Resource Metadata discovery (returns 404)
347+
if (
348+
(url as string).includes("oauth-protected-resource") &&
349+
options?.method === "GET"
350+
) {
351+
return Promise.resolve(
352+
new Response(JSON.stringify({ error: "Not found" }), {
353+
status: 404,
354+
statusText: "Not Found",
355+
}),
356+
);
357+
}
358+
359+
// checkOriginSupportsOAuth - returns 401 WITHOUT WWW-Authenticate (no OAuth support)
360+
if (options?.method === "POST") {
361+
return Promise.resolve(
362+
new Response(JSON.stringify({ error: "Unauthorized" }), {
363+
status: 401,
364+
headers: {
365+
"Content-Type": "application/json",
366+
},
367+
}),
368+
);
369+
}
370+
371+
return Promise.resolve(
372+
new Response(JSON.stringify({ error: "Unexpected request" }), {
373+
status: 500,
374+
}),
375+
);
376+
}) as unknown as typeof fetch;
377+
378+
const res = await app.request(
379+
"http://localhost:3000/.well-known/oauth-protected-resource/mcp/conn_123",
380+
);
381+
382+
// Should pass through the 404 since origin doesn't support OAuth
383+
expect(res.status).toBe(404);
384+
});
274385
});
275386

276387
describe("Authorization Server Metadata Proxy", () => {
@@ -320,23 +431,74 @@ describe("OAuth Proxy Routes", () => {
320431
expect(res.status).toBe(404);
321432
});
322433

323-
test("returns 404 when no auth server in protected resource metadata", async () => {
324-
mockConnectionWithAuthServer(
325-
{ connection_url: "https://origin.example.com/mcp" },
326-
new Response(
327-
JSON.stringify({
328-
resource: "https://origin.example.com/mcp",
329-
authorization_servers: [], // Empty
434+
test("falls back to origin root when protected resource metadata has empty auth servers", async () => {
435+
// When Protected Resource Metadata has empty authorization_servers,
436+
// we should fall back to the origin's root and try to fetch auth server metadata from there
437+
(ContextFactory.create as ReturnType<typeof mock>).mockImplementation(
438+
() =>
439+
Promise.resolve({
440+
storage: {
441+
connections: {
442+
findById: mock(() =>
443+
Promise.resolve({
444+
connection_url: "https://origin.example.com/mcp",
445+
}),
446+
),
447+
},
448+
},
330449
}),
331-
{ status: 200, headers: { "Content-Type": "application/json" } },
332-
),
333450
);
334451

452+
global.fetch = mock((url: string) => {
453+
if ((url as string).includes("oauth-protected-resource")) {
454+
// Return metadata with empty auth servers
455+
return Promise.resolve(
456+
new Response(
457+
JSON.stringify({
458+
resource: "https://origin.example.com/mcp",
459+
authorization_servers: [], // Empty - should trigger fallback
460+
}),
461+
{ status: 200, headers: { "Content-Type": "application/json" } },
462+
),
463+
);
464+
}
465+
// Auth server metadata at origin root
466+
if ((url as string).includes("oauth-authorization-server")) {
467+
return Promise.resolve(
468+
new Response(
469+
JSON.stringify({
470+
issuer: "https://origin.example.com",
471+
authorization_endpoint: "https://origin.example.com/authorize",
472+
token_endpoint: "https://origin.example.com/token",
473+
}),
474+
{ status: 200, headers: { "Content-Type": "application/json" } },
475+
),
476+
);
477+
}
478+
return Promise.resolve(
479+
new Response(JSON.stringify({ error: "Unexpected" }), {
480+
status: 500,
481+
}),
482+
);
483+
}) as unknown as typeof fetch;
484+
335485
const res = await app.request(
336-
"/.well-known/oauth-authorization-server/oauth-proxy/conn_123",
486+
"http://localhost:3000/.well-known/oauth-authorization-server/oauth-proxy/conn_123",
337487
);
338488

339-
expect(res.status).toBe(404);
489+
// Should succeed by falling back to origin root
490+
expect(res.status).toBe(200);
491+
const body = (await res.json()) as {
492+
authorization_endpoint: string;
493+
token_endpoint: string;
494+
};
495+
// URLs should be rewritten to go through our proxy
496+
expect(body.authorization_endpoint).toBe(
497+
"http://localhost:3000/oauth-proxy/conn_123/authorize",
498+
);
499+
expect(body.token_endpoint).toBe(
500+
"http://localhost:3000/oauth-proxy/conn_123/token",
501+
);
340502
});
341503

342504
test("proxies and rewrites authorization server metadata", async () => {

0 commit comments

Comments
 (0)