diff --git a/src/services/mcp/McpOAuthClientProvider.ts b/src/services/mcp/McpOAuthClientProvider.ts index 1882f6d690..ee3277be4c 100644 --- a/src/services/mcp/McpOAuthClientProvider.ts +++ b/src/services/mcp/McpOAuthClientProvider.ts @@ -243,11 +243,12 @@ export class McpOAuthClientProvider implements OAuthClientProvider { private async _doStartCallbackServer(): Promise { this._closed = false - const { port: configuredPort, host } = getCallbackAddress(this._configuredRedirectUri) + const { port: configuredPort, host, path } = getCallbackAddress(this._configuredRedirectUri) const { server, port, result, cancel } = await startCallbackServer( configuredPort ?? this._port, this._state, host, + path, ) this._server = server this._port = port @@ -670,8 +671,12 @@ function resolveResourceIndicator(configured: McpOAuthResource | undefined, disc return discovered ?? null } -function getCallbackAddress(redirectUri?: string): { port?: number; host: "localhost" | "127.0.0.1" } { - if (!redirectUri) return { host: "127.0.0.1" } +function getCallbackAddress(redirectUri?: string): { + port?: number + host: "localhost" | "127.0.0.1" + path: string +} { + if (!redirectUri) return { host: "127.0.0.1", path: "/callback" } const url = new URL(redirectUri) if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") { throw new Error("MCP OAuth redirectUri must use a loopback host") @@ -679,8 +684,9 @@ function getCallbackAddress(redirectUri?: string): { port?: number; host: "local if (url.protocol !== "http:") { throw new Error("MCP OAuth redirectUri must use HTTP on a loopback host") } - if (url.pathname !== "/callback") { - throw new Error("MCP OAuth redirectUri path must be /callback") + // Authorization servers with pre-registered clients may require a specific path. + if (url.pathname === "/") { + throw new Error("MCP OAuth redirectUri must specify a callback path") } if (url.username || url.password || url.search || url.hash) { throw new Error("MCP OAuth redirectUri must not contain credentials, a query, or a fragment") @@ -689,5 +695,5 @@ function getCallbackAddress(redirectUri?: string): { port?: number; host: "local if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error("MCP OAuth redirectUri must specify a valid port") } - return { port, host: url.hostname } + return { port, host: url.hostname, path: url.pathname } } diff --git a/src/services/mcp/__tests__/McpOAuthClientProvider.spec.ts b/src/services/mcp/__tests__/McpOAuthClientProvider.spec.ts index d677e88ac1..43e9e1ad18 100644 --- a/src/services/mcp/__tests__/McpOAuthClientProvider.spec.ts +++ b/src/services/mcp/__tests__/McpOAuthClientProvider.spec.ts @@ -231,16 +231,39 @@ describe("McpOAuthClientProvider", () => { await provider.openBrowser() const openedUri = vi.mocked(vscode.env.openExternal).mock.calls[0][0].toString() expect(new URL(openedUri).searchParams.get("scope")).toBe("openid profile offline_access") - expect(startCallbackServer).toHaveBeenCalledWith(33418, expect.any(String), callbackHost) + expect(startCallbackServer).toHaveBeenCalledWith(33418, expect.any(String), callbackHost, "/callback") await provider.close() }, ) + it("should listen on the configured callback path for a static client", async () => { + setupCallbackServerMock() + const redirectUri = "http://127.0.0.1:19876/mcp/oauth/callback" + const provider = await McpOAuthClientProvider.create( + "https://example.com/mcp", + createMockSecretStorage(), + undefined, + { clientId: "registered-client", redirectUri }, + ) + + expect(provider.redirectUrl).toBe(redirectUri) + expect(await provider.clientInformation()).toMatchObject({ redirect_uris: [redirectUri] }) + + await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize")) + expect(startCallbackServer).toHaveBeenCalledWith( + 19876, + expect.any(String), + "127.0.0.1", + "/mcp/oauth/callback", + ) + await provider.close() + }) + it.each([ ["https://example.com:33418/callback", "must use a loopback host"], ["http://example.com:33418/callback", "must use a loopback host"], ["https://localhost:33418/callback", "must use HTTP on a loopback host"], - ["http://localhost:33418/wrong", "path must be /callback"], + ["http://localhost:33418/", "must specify a callback path"], ["http://localhost:33418/callback?x=1", "must not contain credentials, a query, or a fragment"], ["http://localhost:33418/callback#fragment", "must not contain credentials, a query, or a fragment"], ["http://u:p@localhost:33418/callback", "must not contain credentials, a query, or a fragment"], @@ -563,7 +586,7 @@ describe("McpOAuthClientProvider", () => { const secretStorage = createMockSecretStorage() const serverUrl = "https://example.com/mcp" const clientId = "registered-client" - const discoveredResource = "https://discovered.example.com/" + const discoveredResource = "https://example.com/mcp" await secretStorage.saveOAuthData( serverUrl, { @@ -614,7 +637,7 @@ describe("McpOAuthClientProvider", () => { }, clientId, ) - mockOAuthDiscovery("https://discovered.example.com/") + mockOAuthDiscovery("https://example.com/mcp") const provider = await McpOAuthClientProvider.create(serverUrl, secretStorage, undefined, { clientId, resource: explicitResource, @@ -1398,7 +1421,7 @@ describe("McpOAuthClientProvider", () => { // reads redirect_uris, so the advertised URI carries the real port and never :0. expect(startCallbackServer).toHaveBeenCalledTimes(1) // Port 0 asks the OS for an ephemeral port; the mock binds it to 12345. - expect(startCallbackServer).toHaveBeenCalledWith(0, expect.any(String), "127.0.0.1") + expect(startCallbackServer).toHaveBeenCalledWith(0, expect.any(String), "127.0.0.1", "/callback") expect(provider.redirectUrl).toBe("http://127.0.0.1:12345/callback") expect(provider.redirectUrl).not.toContain(":0/") expect(provider.clientMetadata.redirect_uris).toEqual(["http://127.0.0.1:12345/callback"]) diff --git a/src/services/mcp/utils/__tests__/callbackServer.spec.ts b/src/services/mcp/utils/__tests__/callbackServer.spec.ts index 1a3e6a5be6..6196e5e3a2 100644 --- a/src/services/mcp/utils/__tests__/callbackServer.spec.ts +++ b/src/services/mcp/utils/__tests__/callbackServer.spec.ts @@ -84,6 +84,23 @@ describe("startCallbackServer", () => { await cancellation }) + it("should resolve only on the configured callback path", async () => { + const mockServer = createMockServer() + + vi.mocked(http.createServer).mockReturnValue(mockServer as unknown as http.Server) + + const { result } = await startCallbackServer(19876, undefined, "127.0.0.1", "/mcp/oauth/callback") + const requestCall = mockServer.on.mock.calls.find((call) => call[0] === "request") + const requestHandler = requestCall ? requestCall[1] : vi.fn() + + const defaultPathRes = createMockResponse() + requestHandler({ url: "/callback?code=wrong-path", method: "GET" }, defaultPathRes) + expect(defaultPathRes.writeHead).toHaveBeenCalledWith(404) + + requestHandler({ url: "/mcp/oauth/callback?code=test-code", method: "GET" }, createMockResponse()) + expect((await result).code).toBe("test-code") + }) + it("should reject invalid state", async () => { const mockServer = createMockServer() diff --git a/src/services/mcp/utils/__tests__/oauth.spec.ts b/src/services/mcp/utils/__tests__/oauth.spec.ts index b276f60fd4..7f422410f5 100644 --- a/src/services/mcp/utils/__tests__/oauth.spec.ts +++ b/src/services/mcp/utils/__tests__/oauth.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterAll } from "vitest" +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest" // Mock the SDK's discoverOAuthProtectedResourceMetadata vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ @@ -12,11 +12,23 @@ const mockFetch = vi.fn() const originalFetch = global.fetch global.fetch = mockFetch +const unauthorized = (wwwAuthenticate?: string) => + new Response(null, { + status: 401, + headers: wwwAuthenticate ? { "WWW-Authenticate": wwwAuthenticate } : {}, + }) + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + describe("fetchOAuthAuthServerMetadata", () => { beforeEach(() => { vi.clearAllMocks() }) + afterEach(() => { + vi.restoreAllMocks() + }) + afterAll(() => { global.fetch = originalFetch }) @@ -33,9 +45,229 @@ describe("fetchOAuthAuthServerMetadata", () => { it("returns null when discoverOAuthProtectedResourceMetadata throws", async () => { ;(discoverOAuthProtectedResourceMetadata as any).mockRejectedValue(new Error("network error")) + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })) + + const result = await fetchOAuthAuthServerMetadata("https://example.com/mcp") + expect(result).toBeNull() + expect(discoverOAuthProtectedResourceMetadata).toHaveBeenCalledTimes(1) + }) + + it.each([ + 'Bearer resource_metadata="https://example.com/core/.well-known/oauth-protected-resource/mcp"', + 'Bearer RESOURCE_METADATA="https://example.com/core/.well-known/oauth-protected-resource/mcp"', + 'bearer error="invalid_token", resource_metadata = "https://example.com/core/.well-known/oauth-protected-resource/mcp"', + 'Basic realm="mcp", Bearer resource_metadata="https://example.com/core/.well-known/oauth-protected-resource/mcp"', + 'Bearer error_description="bad \\"token\\", retry", resource_metadata="https://example.com/core/.well-known/oauth-protected-resource/mcp"', + 'Bearer resource_metadata="not a url", Bearer resource_metadata="https://example.com/core/.well-known/oauth-protected-resource/mcp"', + ])("falls back to the resource_metadata URL from the 401 header: %s", async (wwwAuthenticate) => { + vi.mocked(discoverOAuthProtectedResourceMetadata) + .mockRejectedValueOnce( + new Error("Resource server does not implement OAuth 2.0 Protected Resource Metadata."), + ) + .mockResolvedValueOnce({ + resource: "https://example.com/core/mcp", + authorization_servers: ["https://auth.example.com"], + }) + const meta = { issuer: "https://auth.example.com" } + mockFetch + .mockResolvedValueOnce(unauthorized(wwwAuthenticate)) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(meta) }) + + const result = await fetchOAuthAuthServerMetadata("https://example.com/core/mcp") + + const [probeUrl, probeInit] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(probeUrl).toBe("https://example.com/core/mcp") + expect(probeInit).toMatchObject({ + method: "POST", + redirect: "manual", + headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, + }) + expect(JSON.parse(probeInit.body as string)).toMatchObject({ jsonrpc: "2.0", method: "initialize" }) + expect(discoverOAuthProtectedResourceMetadata).toHaveBeenLastCalledWith( + "https://example.com/core/mcp", + { resourceMetadataUrl: new URL("https://example.com/core/.well-known/oauth-protected-resource/mcp") }, + expect.any(Function), + ) + expect(result).toEqual({ authServerMeta: meta, resourceIndicator: "https://example.com/core/mcp" }) + }) + + it.each([ + ["no WWW-Authenticate header", undefined], + ["no resource_metadata parameter", 'Bearer realm="mcp"'], + ["a non-Bearer challenge", 'Basic resource_metadata="https://example.com/metadata"'], + [ + "resource_metadata only in a later non-Bearer challenge", + 'Bearer realm="mcp", Basic resource_metadata="https://other.example/metadata"', + ], + ["a header that backtracks a naive parser", ",bearer\t!=" + "\t,!=".repeat(10_000)], + ["an invalid URL", 'Bearer resource_metadata="not a url"'], + ["a URL on another origin", 'Bearer resource_metadata="https://other.example.com/metadata"'], + ["a loopback URL", 'Bearer resource_metadata="http://127.0.0.1:8080/admin"'], + ])("returns null when the 401 response has %s", async (_case, wwwAuthenticate) => { + vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValueOnce(new Error("not found")) + mockFetch.mockResolvedValueOnce(unauthorized(wwwAuthenticate)) + + const result = await fetchOAuthAuthServerMetadata("https://example.com/mcp") + + expect(result).toBeNull() + expect(discoverOAuthProtectedResourceMetadata).toHaveBeenCalledTimes(1) + }) + + it("gives the fallback its own timeout budget after a slow well-known lookup", async () => { + vi.useFakeTimers() + try { + vi.mocked(discoverOAuthProtectedResourceMetadata) + .mockImplementationOnce(() => delay(4_500).then(() => Promise.reject(new Error("not found")))) + .mockImplementationOnce(() => + delay(1_000).then(() => ({ + resource: "https://example.com/mcp", + authorization_servers: ["https://auth.example.com"], + })), + ) + const meta = { issuer: "https://auth.example.com" } + mockFetch + .mockResolvedValueOnce(unauthorized('Bearer resource_metadata="https://example.com/metadata"')) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(meta) }) + + const result = fetchOAuthAuthServerMetadata("https://example.com/mcp") + await vi.advanceTimersByTimeAsync(6_000) + + await expect(result).resolves.toEqual({ + authServerMeta: meta, + resourceIndicator: "https://example.com/mcp", + }) + } finally { + vi.useRealTimers() + } + }) + + it("returns null when the probe request fails", async () => { + vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValueOnce(new Error("not found")) + mockFetch.mockRejectedValueOnce(new Error("connection refused")) + + const result = await fetchOAuthAuthServerMetadata("https://example.com/mcp") + + expect(result).toBeNull() + expect(discoverOAuthProtectedResourceMetadata).toHaveBeenCalledTimes(1) + }) + + it("returns null when the fallback lookup fails", async () => { + vi.mocked(discoverOAuthProtectedResourceMetadata) + .mockRejectedValueOnce(new Error("not found")) + .mockRejectedValueOnce(new Error("invalid metadata")) + mockFetch.mockResolvedValueOnce(unauthorized('Bearer resource_metadata="https://example.com/metadata"')) + + const result = await fetchOAuthAuthServerMetadata("https://example.com/mcp") + + expect(result).toBeNull() + expect(discoverOAuthProtectedResourceMetadata).toHaveBeenCalledTimes(2) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it("returns null when the probe is redirected", async () => { + vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValueOnce(new Error("not found")) + mockFetch.mockResolvedValueOnce( + new Response(null, { status: 307, headers: { Location: "http://127.0.0.1:8080/mcp" } }), + ) const result = await fetchOAuthAuthServerMetadata("https://example.com/mcp") + expect(result).toBeNull() + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(discoverOAuthProtectedResourceMetadata).toHaveBeenCalledTimes(1) + }) + + /** Makes the next SDK lookup fetch through its hook and never settle; returns the request init. */ + const hangNextLookup = () => { + const request: { init?: RequestInit } = {} + vi.mocked(discoverOAuthProtectedResourceMetadata).mockImplementationOnce(async (url, _opts, fetchFn) => { + await fetchFn!(url) + throw new Error("unreachable") + }) + mockFetch.mockImplementationOnce((_url: string, init: RequestInit) => { + request.init = init + return new Promise(() => {}) + }) + return request + } + + it("aborts a timed-out well-known lookup and does not start the fallback", async () => { + vi.useFakeTimers() + try { + const lookup = hangNextLookup() + + const result = fetchOAuthAuthServerMetadata("https://example.com/mcp") + await vi.advanceTimersByTimeAsync(5_000) + + await expect(result).resolves.toBeNull() + expect(lookup.init?.signal?.aborted).toBe(true) + expect(lookup.init?.redirect).toBeUndefined() + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(discoverOAuthProtectedResourceMetadata).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it("aborts a timed-out fallback lookup", async () => { + vi.useFakeTimers() + try { + vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValueOnce(new Error("not found")) + mockFetch.mockResolvedValueOnce(unauthorized('Bearer resource_metadata="https://example.com/metadata"')) + const lookup = hangNextLookup() + + const result = fetchOAuthAuthServerMetadata("https://example.com/mcp") + await vi.advanceTimersByTimeAsync(5_000) + + await expect(result).resolves.toBeNull() + expect(lookup.init?.signal?.aborted).toBe(true) + expect(lookup.init?.redirect).toBe("manual") + expect(discoverOAuthProtectedResourceMetadata).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + ["the server URL with a trailing slash", "https://example.com/mcp/", "https://example.com/mcp/"], + ["the server URL with multiple trailing slashes", "https://example.com/mcp//", "https://example.com/mcp//"], + ["a same-origin ancestor", "https://example.com/", "https://example.com/"], + ["a child path", "https://example.com/mcp/child", null], + ["another origin", "https://other.example.com/mcp", null], + ["an invalid URL", "not a url", null], + ])( + "keeps OAuth when the metadata resource is %s and sends only a matching indicator", + async (_case, resource, expected) => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.mocked(discoverOAuthProtectedResourceMetadata).mockResolvedValueOnce({ + resource, + authorization_servers: ["https://auth.example.com"], + }) + const meta = { issuer: "https://auth.example.com" } + mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(meta) }) + + const result = await fetchOAuthAuthServerMetadata("https://example.com/mcp") + + expect(result).toEqual({ authServerMeta: meta, resourceIndicator: expected }) + }, + ) + + it("keeps OAuth but drops a mismatched resource indicator from fallback metadata", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.mocked(discoverOAuthProtectedResourceMetadata) + .mockRejectedValueOnce(new Error("not found")) + .mockResolvedValueOnce({ + resource: "https://example.com/other", + authorization_servers: ["https://auth.example.com"], + }) + const meta = { issuer: "https://auth.example.com" } + mockFetch + .mockResolvedValueOnce(unauthorized('Bearer resource_metadata="https://example.com/metadata"')) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(meta) }) + + const result = await fetchOAuthAuthServerMetadata("https://example.com/mcp") + + expect(result).toEqual({ authServerMeta: meta, resourceIndicator: null }) }) it("constructs the RFC 8414 discovery URL correctly for an issuer with a path", async () => { diff --git a/src/services/mcp/utils/callbackServer.ts b/src/services/mcp/utils/callbackServer.ts index 34c2f3a91d..22e98d337e 100644 --- a/src/services/mcp/utils/callbackServer.ts +++ b/src/services/mcp/utils/callbackServer.ts @@ -19,12 +19,14 @@ type CallbackHost = "localhost" | "127.0.0.1" * @param port Optional port to use (defaults to random available port) * @param expectedState Optional expected state for CSRF protection * @param host Loopback host used by the OAuth redirect URI + * @param callbackPath Path of the OAuth redirect URI that receives the authorization code * @returns Promise<{server: http.Server, port: number, result: Promise}> */ export function startCallbackServer( port?: number, expectedState?: string, host: CallbackHost = "127.0.0.1", + callbackPath = "/callback", ): Promise<{ server: http.Server port: number @@ -87,7 +89,7 @@ export function startCallbackServer( const url = new URL(req.url || "", `http://localhost:${actualPort}`) const pathname = url.pathname - if (pathname === "/callback") { + if (pathname === callbackPath) { resolved = true clearTimeout(timeout) diff --git a/src/services/mcp/utils/oauth.ts b/src/services/mcp/utils/oauth.ts index e205626c00..3feb9290e4 100644 --- a/src/services/mcp/utils/oauth.ts +++ b/src/services/mcp/utils/oauth.ts @@ -1,4 +1,6 @@ import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/client/auth.js" +import { checkResourceAllowed } from "@modelcontextprotocol/sdk/shared/auth-utils.js" +import type { OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js" /** * Result of a successful OAuth discovery for an MCP server. @@ -34,7 +36,9 @@ export interface OAuthDiscoveryResult { * * Performs two discovery steps: * 1. RFC 9728 – fetches the Protected Resource Metadata to find the issuer URL - * and the RFC 8707 resource indicator. + * and the RFC 8707 resource indicator. When the well-known URLs fail, falls + * back to the `resource_metadata` URL from the server's 401 `WWW-Authenticate` + * header (RFC 9728 §5.1), for servers that host the metadata elsewhere. * 2. RFC 8414 §3.1 – constructs the well-known discovery URL by inserting * `/.well-known/oauth-authorization-server` *between* the host and the issuer * path (not appended after the path). @@ -50,13 +54,7 @@ export async function fetchOAuthAuthServerMetadata(serverUrl: string): Promise((_, reject) => - setTimeout(() => reject(new Error("OAuth discovery timeout")), DISCOVERY_TIMEOUT_MS), - ), - ]) + const resourceMeta = await discoverProtectedResourceMetadata(serverUrl) const authServers = resourceMeta.authorization_servers if (!authServers?.length) return null @@ -64,7 +62,9 @@ export async function fetchOAuthAuthServerMetadata(serverUrl: string): Promise { + try { + return await withDiscoveryTimeout((fetchFn) => + discoverOAuthProtectedResourceMetadata(serverUrl, undefined, fetchFn), + ) + } catch (wellKnownError) { + // A lookup that timed out may still be running, so do not start another. + if (wellKnownError instanceof DiscoveryTimeoutError) throw wellKnownError + const resourceMetadataUrl = await probeResourceMetadataUrl(serverUrl) + // Fetch only metadata on the server's own origin, so a 401 header cannot + // direct the client to another host, such as a loopback or private address. + if (resourceMetadataUrl?.origin !== new URL(serverUrl).origin) throw wellKnownError + // A redirect could still lead off-origin, so the metadata fetch does not follow one. + return withDiscoveryTimeout( + (fetchFn) => discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn), + { redirect: "manual" }, + ) + } +} + +/** + * RFC 9728 §3.3: the metadata must describe the resource it was fetched for. + * Like the SDK, accepts the server URL or a same-origin ancestor of it. A + * trailing slash on the published resource is ignored, so `https://x/mcp/` + * matches `https://x/mcp`. A mismatched resource is not sent as the RFC 8707 + * resource indicator, but the server still uses OAuth. + */ +function resourceMatches(serverUrl: string, resource: string): boolean { + try { + const configuredResource = new URL(resource) + configuredResource.pathname = configuredResource.pathname.replace(/\/+$/, "") + if (checkResourceAllowed({ requestedResource: serverUrl, configuredResource })) return true + } catch { + // An invalid resource URL is treated as a mismatch. + } + console.warn(`[MCP OAuth] Ignoring resource indicator ${resource}: it does not match ${serverUrl}`) + return false +} + +class DiscoveryTimeoutError extends Error { + constructor() { + super("OAuth discovery timeout") + } +} + +/** + * Gives each lookup its own timeout, so a slow well-known lookup cannot consume + * the fallback's budget. The timeout aborts the lookup's requests through the + * SDK fetch hook. + */ +async function withDiscoveryTimeout( + lookup: (fetchFn: typeof fetch) => Promise, + requestInit?: RequestInit, +): Promise { + const controller = new AbortController() + let timer: ReturnType | undefined + try { + return await Promise.race([ + lookup((input, init) => fetch(input, { ...init, ...requestInit, signal: controller.signal })), + new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort() + reject(new DiscoveryTimeoutError()) + }, DISCOVERY_TIMEOUT_MS) + }), + ]) + } finally { + clearTimeout(timer) + } +} + +/** + * Sends an unauthenticated MCP `initialize` request and returns the + * `resource_metadata` URL from the 401 `WWW-Authenticate` header, if any. + */ +async function probeResourceMetadataUrl(serverUrl: string): Promise { + const response = await fetch(serverUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 0, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "oauth-discovery", version: "1.0.0" }, + }, + }), + // The 401 must come from the configured server itself, not a redirect target. + redirect: "manual", + signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS), + }) + await response.body?.cancel().catch(() => {}) + return response.status === 401 ? extractResourceMetadataUrl(response.headers.get("WWW-Authenticate")) : undefined +} + +/** An auth-param, optionally preceded by the auth-scheme that starts its challenge. */ +const AUTH_PARAM = /^\s*(?:([^\s="]+)\s+)?([^\s="]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s"]*))\s*$/ + +/** + * Reads the `resource_metadata` parameter of a Bearer challenge. Unlike the SDK + * helper, matches names case-insensitively (RFC 9110 §11.2), finds the Bearer + * challenge after other challenges, and ignores parameters of other challenges. + */ +function extractResourceMetadataUrl(header: string | null): URL | undefined { + if (!header) return undefined + let scheme: string | undefined + for (const item of splitAuthItems(header)) { + const param = AUTH_PARAM.exec(item) + const itemScheme = param ? param[1] : item.trim().split(/\s+/)[0] + if (itemScheme) scheme = itemScheme.toLowerCase() + if (scheme === "bearer" && param?.[2].toLowerCase() === "resource_metadata") { + const value = param[3]?.replace(/\\(.)/g, "$1") ?? param[4] + if (URL.canParse(value)) return new URL(value) + } + } + return undefined +} + +/** + * Splits a WWW-Authenticate value on commas outside quoted strings. The commas + * separate challenges and their auth-params. A loop keeps this linear in the + * header length and handles escapes inside quoted strings (RFC 9110 §5.6.4). + */ +function splitAuthItems(header: string): string[] { + const items: string[] = [] + let start = 0 + let quoted = false + for (let i = 0; i < header.length; i++) { + const char = header[i] + if (quoted && char === "\\") i++ + else if (char === '"') quoted = !quoted + else if (char === "," && !quoted) { + items.push(header.slice(start, i)) + start = i + 1 + } + } + items.push(header.slice(start)) + return items +}