From cb437dd97a2e83ea171ab1d7c7d74d8a0d428eac Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:09:35 +0800 Subject: [PATCH 1/4] test(core): cover custom OAuth resource indicators --- packages/core/test/mcp-oauth-resource.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/core/test/mcp-oauth-resource.test.ts diff --git a/packages/core/test/mcp-oauth-resource.test.ts b/packages/core/test/mcp-oauth-resource.test.ts new file mode 100644 index 000000000000..ec85b9095da3 --- /dev/null +++ b/packages/core/test/mcp-oauth-resource.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test" +import { auth, type OAuthClientProvider } from "@modelcontextprotocol/client" + +test.each([ + ["pathless metadata", "", "/mcp", ""], + ["explicit root slash", "/", "/mcp", "/"], + ["parent resource", "/parent", "/parent/mcp", "/parent"], + ["provider removes transport query", "/mcp?codemode=false", "/mcp", "/mcp"], + ["provider selects another path", "/mcp", "/mcp", "/canonical"], + ["rejected metadata", "", "/mcp", "/rejected"], +])("honors custom OAuth resource validation: %s", async (name, metadataPath, serverPath, selectedPath) => { + const requests: URLSearchParams[] = [] + const server = Bun.serve({ + port: 0, + async fetch(request) { + requests.push(new URLSearchParams(await request.text())) + return Response.json({ access_token: "access", token_type: "Bearer" }) + }, + }) + const metadataResource = `${server.url.origin}${metadataPath}` + const selectedResource = `${server.url.origin}${selectedPath}` + const redirects: URL[] = [] + const provider: OAuthClientProvider = { + redirectUrl: "http://127.0.0.1/callback", + clientMetadata: { redirect_uris: ["http://127.0.0.1/callback"] }, + clientInformation: async () => ({ client_id: "client" }), + tokens: async () => undefined, + saveTokens: async () => {}, + saveCodeVerifier: async () => {}, + codeVerifier: async () => "verifier", + redirectToAuthorization: (url) => void redirects.push(url), + discoveryState: async () => ({ + authorizationServerUrl: server.url.origin, + authorizationServerMetadata: { + issuer: server.url.origin, + authorization_endpoint: `${server.url.origin}/authorize`, + token_endpoint: `${server.url.origin}/token`, + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }, + resourceMetadata: { resource: metadataResource }, + }), + validateResourceURL: async () => { + if (name === "rejected metadata") throw new Error("Rejected resource") + return new URL(selectedResource) + }, + } + + try { + if (name === "rejected metadata") { + await expect(auth(provider, { serverUrl: `${server.url.origin}${serverPath}` })).rejects.toThrow( + "Rejected resource", + ) + expect(redirects).toHaveLength(0) + expect(requests).toHaveLength(0) + return + } + expect(await auth(provider, { serverUrl: `${server.url.origin}${serverPath}` })).toBe("REDIRECT") + expect( + await auth(provider, { serverUrl: `${server.url.origin}${serverPath}`, authorizationCode: "accepted" }), + ).toBe("AUTHORIZED") + expect(requests).toHaveLength(1) + expect({ + authorization: redirects[0]?.searchParams.get("resource"), + token: requests[0]?.get("resource"), + }).toEqual({ authorization: selectedResource, token: selectedResource }) + } finally { + server.stop(true) + } +}) From 25d60caf3aebc923ae6c644aa61f2c2bc83e8992 Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:11:36 +0800 Subject: [PATCH 2/4] fix(core): preserve pathless MCP OAuth resources --- ...@modelcontextprotocol%2Fclient@2.3.1.patch | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/patches/@modelcontextprotocol%2Fclient@2.3.1.patch b/patches/@modelcontextprotocol%2Fclient@2.3.1.patch index 2bfb4fd66b4b..a94d002da814 100644 --- a/patches/@modelcontextprotocol%2Fclient@2.3.1.patch +++ b/patches/@modelcontextprotocol%2Fclient@2.3.1.patch @@ -1,7 +1,16 @@ diff --git a/dist/index.cjs b/dist/index.cjs -index 71cf630..4c33e3b 100644 +index 71cf630..dcedf2d 100644 --- a/dist/index.cjs +++ b/dist/index.cjs +@@ -725,7 +725,7 @@ + } + if (freshDiscoveryState) await provider.saveDiscoveryState?.(freshDiscoveryState); + const selectedResource = await selectResourceURL(serverUrl, provider, resourceMetadata); +- const resource = selectedResource && resourceMetadata && !provider.validateResourceURL ? resourceMetadata.resource : selectedResource; ++ const resource = selectedResource && resourceMetadata && selectedResource.href === new URL(resourceMetadata.resource).href ? resourceMetadata.resource : selectedResource; + if (resource) await provider.saveResourceUrl?.(resourceIndicatorToString(resource)); + const resolvedScope = determineScope({ + requestedScope: scope, @@ -1038,7 +1038,6 @@ async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, o else { const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname); @@ -19,9 +28,18 @@ index 71cf630..4c33e3b 100644 return { authorizationUrl, diff --git a/dist/index.mjs b/dist/index.mjs -index c1a09a8..f5b0e1d 100644 +index c1a09a8..c13b5b5 100644 --- a/dist/index.mjs +++ b/dist/index.mjs +@@ -722,7 +722,7 @@ + } + if (freshDiscoveryState) await provider.saveDiscoveryState?.(freshDiscoveryState); + const selectedResource = await selectResourceURL(serverUrl, provider, resourceMetadata); +- const resource = selectedResource && resourceMetadata && !provider.validateResourceURL ? resourceMetadata.resource : selectedResource; ++ const resource = selectedResource && resourceMetadata && selectedResource.href === new URL(resourceMetadata.resource).href ? resourceMetadata.resource : selectedResource; + if (resource) await provider.saveResourceUrl?.(resourceIndicatorToString(resource)); + const resolvedScope = determineScope({ + requestedScope: scope, @@ -1035,7 +1035,6 @@ async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, o else { const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname); From 5d2c3fe51079dde1e929a6c0815af30163188cd0 Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:52:31 -0700 Subject: [PATCH 3/4] test(core): cover pathless resources through the real OAuth provider --- packages/core/test/mcp-oauth.test.ts | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/core/test/mcp-oauth.test.ts b/packages/core/test/mcp-oauth.test.ts index c40ed2e176fe..0a130f56c0f5 100644 --- a/packages/core/test/mcp-oauth.test.ts +++ b/packages/core/test/mcp-oauth.test.ts @@ -437,6 +437,40 @@ describe("MCP OAuth", () => { expect(tokenRequests[0]?.get("resource")).toBe(url) }) + test("preserves a pathless resource from metadata", async () => { + const tokenRequests: URLSearchParams[] = [] + const server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/.well-known/oauth-authorization-server") + return Response.json({ + issuer: url.origin, + authorization_endpoint: `${url.origin}/authorize`, + token_endpoint: `${url.origin}/token`, + response_types_supported: ["code"], + }) + if (url.pathname === "/.well-known/oauth-protected-resource/mcp") + return Response.json({ resource: url.origin, authorization_servers: [url.origin] }) + if (request.method === "POST" && url.pathname === "/token") { + tokenRequests.push(new URLSearchParams(await request.text())) + return Response.json({ access_token: "next", token_type: "Bearer" }) + } + return new Response(null, { status: 404 }) + }, + }) + const url = `${server.url.origin}/mcp` + const oauthProvider = await connectProvider( + remote(url), + memoryCredentials([credential({ access: "expired", refresh: "refresh", url })]), + ) + + await auth(oauthProvider, { serverUrl: url }).finally(() => server.stop(true)) + + expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token") + expect(tokenRequests[0]?.get("resource")).toBe(server.url.origin) + }) + test("finds resource metadata through the 401 header when the well-known path is not served", async () => { const server = Bun.serve({ port: 0, From 4ecadd55cc9545f291e1c91ab7a084be622f4635 Mon Sep 17 00:00:00 2001 From: Kayokoi <113896798+wozhenbang2004@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:56:53 -0700 Subject: [PATCH 4/4] test(core): remove the standalone OAuth resource test --- packages/core/test/mcp-oauth-resource.test.ts | 70 ------------------- 1 file changed, 70 deletions(-) delete mode 100644 packages/core/test/mcp-oauth-resource.test.ts diff --git a/packages/core/test/mcp-oauth-resource.test.ts b/packages/core/test/mcp-oauth-resource.test.ts deleted file mode 100644 index ec85b9095da3..000000000000 --- a/packages/core/test/mcp-oauth-resource.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { expect, test } from "bun:test" -import { auth, type OAuthClientProvider } from "@modelcontextprotocol/client" - -test.each([ - ["pathless metadata", "", "/mcp", ""], - ["explicit root slash", "/", "/mcp", "/"], - ["parent resource", "/parent", "/parent/mcp", "/parent"], - ["provider removes transport query", "/mcp?codemode=false", "/mcp", "/mcp"], - ["provider selects another path", "/mcp", "/mcp", "/canonical"], - ["rejected metadata", "", "/mcp", "/rejected"], -])("honors custom OAuth resource validation: %s", async (name, metadataPath, serverPath, selectedPath) => { - const requests: URLSearchParams[] = [] - const server = Bun.serve({ - port: 0, - async fetch(request) { - requests.push(new URLSearchParams(await request.text())) - return Response.json({ access_token: "access", token_type: "Bearer" }) - }, - }) - const metadataResource = `${server.url.origin}${metadataPath}` - const selectedResource = `${server.url.origin}${selectedPath}` - const redirects: URL[] = [] - const provider: OAuthClientProvider = { - redirectUrl: "http://127.0.0.1/callback", - clientMetadata: { redirect_uris: ["http://127.0.0.1/callback"] }, - clientInformation: async () => ({ client_id: "client" }), - tokens: async () => undefined, - saveTokens: async () => {}, - saveCodeVerifier: async () => {}, - codeVerifier: async () => "verifier", - redirectToAuthorization: (url) => void redirects.push(url), - discoveryState: async () => ({ - authorizationServerUrl: server.url.origin, - authorizationServerMetadata: { - issuer: server.url.origin, - authorization_endpoint: `${server.url.origin}/authorize`, - token_endpoint: `${server.url.origin}/token`, - response_types_supported: ["code"], - code_challenge_methods_supported: ["S256"], - }, - resourceMetadata: { resource: metadataResource }, - }), - validateResourceURL: async () => { - if (name === "rejected metadata") throw new Error("Rejected resource") - return new URL(selectedResource) - }, - } - - try { - if (name === "rejected metadata") { - await expect(auth(provider, { serverUrl: `${server.url.origin}${serverPath}` })).rejects.toThrow( - "Rejected resource", - ) - expect(redirects).toHaveLength(0) - expect(requests).toHaveLength(0) - return - } - expect(await auth(provider, { serverUrl: `${server.url.origin}${serverPath}` })).toBe("REDIRECT") - expect( - await auth(provider, { serverUrl: `${server.url.origin}${serverPath}`, authorizationCode: "accepted" }), - ).toBe("AUTHORIZED") - expect(requests).toHaveLength(1) - expect({ - authorization: redirects[0]?.searchParams.get("resource"), - token: requests[0]?.get("resource"), - }).toEqual({ authorization: selectedResource, token: selectedResource }) - } finally { - server.stop(true) - } -})