diff --git a/router/index.ts b/router/index.ts index 5e992c3e..09ac353d 100644 --- a/router/index.ts +++ b/router/index.ts @@ -130,9 +130,18 @@ function isPrimaryFileObserverPath(hostname: string, pathname: string): boolean // 9728 does the same for protected-resource metadata. These canonical URLs // sit outside /cloud, so route them back to the Cloud app that owns the // metadata rather than letting the marketing app return a 404. +// +// https://agentrelay.com/mcp is the short MCP URL for third-party clients. Its +// RFC 9728 document is /.well-known/oauth-protected-resource/mcp. The bare +// root documents are fallbacks for clients that skip path insertion: the +// protected-resource one describes /mcp, and pre-2025-06 MCP clients look +// for authorization-server metadata at the MCP server's origin root. const CLOUD_OAUTH_WELL_KNOWN_REWRITES: ReadonlyArray = [ ["/.well-known/oauth-authorization-server/cloud", "/cloud/.well-known/oauth-authorization-server"], ["/.well-known/openid-configuration/cloud", "/cloud/.well-known/openid-configuration"], + ["/.well-known/oauth-authorization-server", "/cloud/.well-known/oauth-authorization-server"], + ["/.well-known/oauth-protected-resource", "/cloud/.well-known/oauth-protected-resource/mcp"], + ["/.well-known/oauth-protected-resource/mcp", "/cloud/.well-known/oauth-protected-resource/mcp"], ]; const CLOUD_PROTECTED_RESOURCE_PREFIX = "/.well-known/oauth-protected-resource/cloud/"; @@ -146,6 +155,13 @@ export function rewriteCloudOAuthWellKnownPath(pathname: string): string | null return null; } +// The root MCP endpoint is served by the Cloud app at /cloud/mcp. Unlike the +// discovery documents it takes every method (Streamable HTTP POSTs JSON-RPC, +// GETs an SSE stream, DELETEs a session), so it is not limited to GET/HEAD. +export function rewriteCloudMcpPath(pathname: string): string | null { + return pathname === "/mcp" || pathname === "/mcp/" ? "/cloud/mcp" : null; +} + function isCloudPath(pathname: string): boolean { return isPathWithinPrefix(pathname, CLOUD_PATH_PREFIX); } @@ -748,12 +764,14 @@ const router = { return openAiAppsChallenge; } - const oauthWellKnownPath = url.hostname === PRIMARY_HOST - && (request.method === "GET" || request.method === "HEAD") - ? rewriteCloudOAuthWellKnownPath(url.pathname) - : null; - if (oauthWellKnownPath) { - url.pathname = oauthWellKnownPath; + const cloudRewritePath = url.hostname !== PRIMARY_HOST + ? null + : rewriteCloudMcpPath(url.pathname) + ?? (request.method === "GET" || request.method === "HEAD" + ? rewriteCloudOAuthWellKnownPath(url.pathname) + : null); + if (cloudRewritePath) { + url.pathname = cloudRewritePath; request = new Request(url.toString(), request); } diff --git a/router/test/oauth-well-known.test.ts b/router/test/oauth-well-known.test.ts index 7c1b4dff..50fb72fb 100644 --- a/router/test/oauth-well-known.test.ts +++ b/router/test/oauth-well-known.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import worker, { rewriteCloudOAuthWellKnownPath } from "../index.js"; +import worker, { rewriteCloudMcpPath, rewriteCloudOAuthWellKnownPath } from "../index.js"; function buildEnv(cloudWebWorker: { fetch: ReturnType }) { return { @@ -24,13 +24,31 @@ describe("cloud OAuth well-known rewrites", () => { .toBe("/cloud/.well-known/oauth-protected-resource/cloud/api/v1/mcp/relay-events"); }); + it("maps the root /mcp discovery documents onto cloud-web", () => { + expect(rewriteCloudOAuthWellKnownPath("/.well-known/oauth-protected-resource/mcp")) + .toBe("/cloud/.well-known/oauth-protected-resource/mcp"); + // Fallbacks for clients that skip RFC 8414 / RFC 9728 path insertion. + expect(rewriteCloudOAuthWellKnownPath("/.well-known/oauth-protected-resource")) + .toBe("/cloud/.well-known/oauth-protected-resource/mcp"); + expect(rewriteCloudOAuthWellKnownPath("/.well-known/oauth-authorization-server")) + .toBe("/cloud/.well-known/oauth-authorization-server"); + }); + + it("maps the root /mcp endpoint, with or without a trailing slash", () => { + expect(rewriteCloudMcpPath("/mcp")).toBe("/cloud/mcp"); + expect(rewriteCloudMcpPath("/mcp/")).toBe("/cloud/mcp"); + for (const path of ["/mcp/x", "/mcpx", "/cloud/mcp", "/"]) { + expect(rewriteCloudMcpPath(path)).toBeNull(); + } + }); + it("leaves every other path alone", () => { for (const path of [ - "/.well-known/oauth-authorization-server", "/.well-known/oauth-authorization-server/other", - "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp/x", "/.well-known/oauth-protected-resource/cloudy/x", "/cloud/.well-known/openid-configuration", + "/mcp", "/docs", ]) { expect(rewriteCloudOAuthWellKnownPath(path)).toBeNull(); @@ -55,4 +73,69 @@ describe("cloud OAuth well-known rewrites", () => { "https://agentrelay.com/cloud/.well-known/oauth-authorization-server?client=grok", ); }); + + it.each([ + "/.well-known/oauth-protected-resource/mcp", + "/.well-known/oauth-protected-resource", + ])("forwards %s to the /mcp protected-resource document", async (path) => { + const cloudWebWorker = { + fetch: vi.fn(async (_request: Request) => new Response("{}", { status: 200 })), + }; + + const response = await worker.fetch( + new Request(`https://agentrelay.com${path}`), + buildEnv(cloudWebWorker), + ctx, + ); + + expect(response.status).toBe(200); + const forwarded = cloudWebWorker.fetch.mock.calls[0]?.[0] as Request; + expect(forwarded.url).toBe("https://agentrelay.com/cloud/.well-known/oauth-protected-resource/mcp"); + }); + + it.each(["/mcp", "/mcp/"])("forwards a %s POST to cloud-web with its body and headers", async (path) => { + const cloudWebWorker = { + fetch: vi.fn(async (_request: Request) => new Response("{}", { status: 401 })), + }; + + const response = await worker.fetch( + new Request(`https://agentrelay.com${path}`, { + method: "POST", + headers: { authorization: "Bearer cld_at_x", "content-type": "application/json" }, + body: '{"jsonrpc":"2.0","id":1,"method":"initialize"}', + }), + buildEnv(cloudWebWorker), + ctx, + ); + + expect(response.status).toBe(401); + expect(cloudWebWorker.fetch).toHaveBeenCalledOnce(); + const forwarded = cloudWebWorker.fetch.mock.calls[0]?.[0] as Request; + expect(forwarded.url).toBe("https://agentrelay.com/cloud/mcp"); + expect(forwarded.method).toBe("POST"); + expect(forwarded.headers.get("authorization")).toBe("Bearer cld_at_x"); + expect(await forwarded.text()).toBe('{"jsonrpc":"2.0","id":1,"method":"initialize"}'); + }); + + it("does not rewrite discovery documents for non-GET methods", async () => { + const cloudWebWorker = { + fetch: vi.fn(async (_request: Request) => new Response("{}", { status: 200 })), + }; + + const upstreamFetch = vi.fn(async (_request: Request) => new Response("Not Found", { status: 404 })); + vi.stubGlobal("fetch", upstreamFetch); + try { + await worker.fetch( + new Request("https://agentrelay.com/.well-known/oauth-authorization-server", { method: "POST" }), + buildEnv(cloudWebWorker), + ctx, + ); + } finally { + vi.unstubAllGlobals(); + } + + const forwardedUrls = [...cloudWebWorker.fetch.mock.calls, ...upstreamFetch.mock.calls] + .map(([request]) => new URL((request as Request).url).pathname); + expect(forwardedUrls).not.toContain("/cloud/.well-known/oauth-authorization-server"); + }); });