From 5a8af45c09a9b75da06e372817ed9c9b89b3ee10 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 16 Jun 2025 16:34:46 -0400 Subject: [PATCH 01/10] In src/examples/server/simpleStreamableHttp.ts - In mcpPostHandler, - Get `mcp-session-id` header early so that it can be reported in every incoming request. - Helpful for troubleshooting Inspector's ability to retain the session id for the Proxy <-> Server leg --- src/examples/server/simpleStreamableHttp.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index ebe31920..dfc1f197 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -178,10 +178,10 @@ const getServer = () => { server.registerResource( 'example-file-1', 'file:///example/file1.txt', - { + { title: 'Example File 1', description: 'First example file for ResourceLink demonstration', - mimeType: 'text/plain' + mimeType: 'text/plain' }, async (): Promise => { return { @@ -198,10 +198,10 @@ const getServer = () => { server.registerResource( 'example-file-2', 'file:///example/file2.txt', - { + { title: 'Example File 2', description: 'Second example file for ResourceLink demonstration', - mimeType: 'text/plain' + mimeType: 'text/plain' }, async (): Promise => { return { @@ -338,15 +338,13 @@ const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; // MCP POST endpoint with optional auth const mcpPostHandler = async (req: Request, res: Response) => { - console.log('Received MCP request:', req.body); + const sessionId = req.headers['mcp-session-id'] as string | undefined; + console.log(sessionId? `Received MCP request for session: ${sessionId}`: 'Received MCP request:', req.body); if (useOAuth && req.auth) { console.log('Authenticated user:', req.auth); } try { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined; let transport: StreamableHTTPServerTransport; - if (sessionId && transports[sessionId]) { // Reuse existing transport transport = transports[sessionId]; From e76faf6a18bcd25724649a57b07c410957510192 Mon Sep 17 00:00:00 2001 From: joeyzzeng Date: Wed, 18 Jun 2025 21:34:42 +0800 Subject: [PATCH 02/10] fix: skip validation if tool reports error --- src/server/mcp.test.ts | 66 ++++++++++++++++++++++++++++++++++++++++++ src/server/mcp.ts | 2 +- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 7fb6bd55..242f0529 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1204,6 +1204,72 @@ describe("tool()", () => { ).rejects.toThrow(/Tool test has an output schema but no structured content was provided/); }); + /*** + * Test: Tool with Output Schema Must Provide Structured Content + */ + test("should not throw error when tool with outputSchema returns no structuredContent and isError is true", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + // Register a tool with outputSchema that returns only content without structuredContent + mcpServer.registerTool( + "test", + { + description: "Test tool with output schema but missing structured content", + inputSchema: { + input: z.string(), + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + }, + }, + async ({ input }) => ({ + // Only return content without structuredContent + content: [ + { + type: "text", + text: `Processed: ${input}`, + }, + ], + isError: true, + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool and expect it to not throw an error + await expect( + client.callTool({ + name: "test", + arguments: { + input: "hello", + }, + }), + ).resolves.toStrictEqual({ + content: [ + { + type: "text", + text: `Processed: hello`, + }, + ], + isError: true, + }); + }); + /*** * Test: Schema Validation Failure for Invalid Structured Content */ diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 3d9673da..9440708d 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -200,7 +200,7 @@ export class McpServer { } } - if (tool.outputSchema) { + if (tool.outputSchema && (result.isError !== true)) { if (!result.structuredContent) { throw new McpError( ErrorCode.InvalidParams, From 4f24b8bc6faaea456d92f8296fbc84a560c3c8c6 Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Tue, 24 Jun 2025 04:36:54 +0800 Subject: [PATCH 03/10] Fix `/.well-known/oauth-authorization-server` dropping path --- src/client/auth.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index d953e1f0..35105daa 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -297,7 +297,9 @@ export async function discoverOAuthMetadata( authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, ): Promise { - const url = new URL("/.well-known/oauth-authorization-server", authorizationServerUrl); + const wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; + const url = new URL(wellKnownPath, authorizationServerUrl); + let response: Response; try { response = await fetch(url, { From da6ac79c1e2bcc1979f03ccaaf61094b5c9d4adf Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Tue, 24 Jun 2025 04:41:26 +0800 Subject: [PATCH 04/10] Fix missing issuer --- src/client/auth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/auth.ts b/src/client/auth.ts index 35105daa..33a9a6b9 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -297,6 +297,8 @@ export async function discoverOAuthMetadata( authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, ): Promise { + const issuer = new URL(authorizationServerUrl); + const wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; const url = new URL(wellKnownPath, authorizationServerUrl); From 622070135242f9276b87de59b47a301fa7062cdc Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:13:13 -0400 Subject: [PATCH 05/10] Fix trailing slash --- src/client/auth.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 33a9a6b9..cba14a9c 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -299,8 +299,12 @@ export async function discoverOAuthMetadata( ): Promise { const issuer = new URL(authorizationServerUrl); - const wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; - const url = new URL(wellKnownPath, authorizationServerUrl); + let wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; + if (issuer.pathname.endsWith('/')) { + // Strip trailing slash from pathname + wellKnownPath = wellKnownPath.slice(0, -1); + } + const url = new URL(wellKnownPath, issuer); let response: Response; try { From 1ff08e41d7088a63f208034a0f3bf3acfe5bf03e Mon Sep 17 00:00:00 2001 From: Henry Mao <1828968+calclavia@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:32:08 -0400 Subject: [PATCH 06/10] Add path test --- src/client/auth.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index f95cb2ca..511b351f 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -207,6 +207,24 @@ describe("OAuth Authorization", () => { }); }); + it("returns metadata when discovery succeeds with path", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name"); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url, options] = calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name"); + expect(options.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + it("returns metadata when first fetch fails but second without MCP header succeeds", async () => { // Set up a counter to control behavior let callCount = 0; From 03da7cfc66cf416c214bdf3236c775d7c4794c5a Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 24 Jun 2025 11:38:55 +0100 Subject: [PATCH 07/10] fallback --- src/client/auth.test.ts | 120 ++++++++++++++++++++++++++++++++++++++++ src/client/auth.ts | 88 ++++++++++++++++++++++------- 2 files changed, 187 insertions(+), 21 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 511b351f..b689d188 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -225,6 +225,126 @@ describe("OAuth Authorization", () => { }); }); + it("falls back to root discovery when path-aware discovery returns 404", async () => { + // First call (path-aware) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name"); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + + // First call should be path-aware + const [firstUrl, firstOptions] = calls[0]; + expect(firstUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name"); + expect(firstOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + + // Second call should be root fallback + const [secondUrl, secondOptions] = calls[1]; + expect(secondUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + expect(secondOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + + it("returns undefined when both path-aware and root discovery return 404", async () => { + // First call (path-aware) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) also returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name"); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + }); + + it("does not fallback when the original URL is already at root path", async () => { + // First call (path-aware for root) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/"); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + }); + + it("does not fallback when the original URL has no path", async () => { + // First call (path-aware for no path) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com"); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]; + expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + }); + + it("falls back when path-aware discovery encounters CORS error", async () => { + // First call (path-aware) fails with TypeError (CORS) + mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error"))); + + // Retry path-aware without headers (simulating CORS retry) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthMetadata("https://auth.example.com/deep/path"); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(3); + + // Final call should be root fallback + const [lastUrl, lastOptions] = calls[2]; + expect(lastUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); + expect(lastOptions.headers).toEqual({ + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + it("returns metadata when first fetch fails but second without MCP header succeeds", async () => { // Set up a counter to control behavior let callCount = 0; diff --git a/src/client/auth.ts b/src/client/auth.ts index cba14a9c..e0e93fc0 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -293,36 +293,82 @@ export async function discoverOAuthProtectedResourceMetadata( * If the server returns a 404 for the well-known endpoint, this function will * return `undefined`. Any other errors will be thrown as exceptions. */ +/** + * Helper function to handle fetch with CORS retry logic + */ +async function fetchWithCorsRetry( + url: URL, + headers: Record, +): Promise { + try { + return await fetch(url, { headers }); + } catch (error) { + // CORS errors come back as TypeError, retry without headers + if (error instanceof TypeError) { + return await fetch(url); + } + throw error; + } +} + +/** + * Constructs the well-known path for OAuth metadata discovery + */ +function buildWellKnownPath(pathname: string): string { + let wellKnownPath = `/.well-known/oauth-authorization-server${pathname}`; + if (pathname.endsWith('/')) { + // Strip trailing slash from pathname to avoid double slashes + wellKnownPath = wellKnownPath.slice(0, -1); + } + return wellKnownPath; +} + +/** + * Tries to discover OAuth metadata at a specific URL + */ +async function tryMetadataDiscovery( + url: URL, + protocolVersion: string, +): Promise { + const headers = { + "MCP-Protocol-Version": protocolVersion + }; + return await fetchWithCorsRetry(url, headers); +} + +/** + * Determines if fallback to root discovery should be attempted + */ +function shouldAttemptFallback(response: Response, pathname: string): boolean { + return response.status === 404 && pathname !== '/'; +} + export async function discoverOAuthMetadata( authorizationServerUrl: string | URL, opts?: { protocolVersion?: string }, ): Promise { const issuer = new URL(authorizationServerUrl); + const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; - let wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`; - if (issuer.pathname.endsWith('/')) { - // Strip trailing slash from pathname - wellKnownPath = wellKnownPath.slice(0, -1); - } - const url = new URL(wellKnownPath, issuer); + // Try path-aware discovery first (RFC 8414 compliant) + const wellKnownPath = buildWellKnownPath(issuer.pathname); + const pathAwareUrl = new URL(wellKnownPath, issuer); + let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion); - let response: Response; - try { - response = await fetch(url, { - headers: { - "MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION + // If path-aware discovery fails with 404, try fallback to root discovery + if (shouldAttemptFallback(response, issuer.pathname)) { + try { + const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer); + response = await tryMetadataDiscovery(rootUrl, protocolVersion); + + if (response.status === 404) { + return undefined; } - }); - } catch (error) { - // CORS errors come back as TypeError - if (error instanceof TypeError) { - response = await fetch(url); - } else { - throw error; + } catch { + // If fallback fails, return undefined + return undefined; } - } - - if (response.status === 404) { + } else if (response.status === 404) { return undefined; } From 15a2277c8994a403dfedaf52d27eae73fdc359af Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 24 Jun 2025 14:47:54 +0100 Subject: [PATCH 08/10] refactor resource selection to not include resource if PRM is not present --- src/client/auth.test.ts | 231 ++++++++++++++++++++++++++++++++++++++-- src/client/auth.ts | 27 +++-- 2 files changed, 241 insertions(+), 17 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index b689d188..8e77c0a5 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -954,10 +954,19 @@ describe("OAuth Authorization", () => { }); it("passes resource parameter through authorization flow", async () => { - // Mock successful metadata discovery + // Mock successful metadata discovery - need to include protected resource metadata mockFetch.mockImplementation((url) => { const urlString = url.toString(); - if (urlString.includes("/.well-known/oauth-authorization-server")) { + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://api.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { return Promise.resolve({ ok: true, status: 200, @@ -1002,11 +1011,20 @@ describe("OAuth Authorization", () => { }); it("includes resource in token exchange when authorization code is provided", async () => { - // Mock successful metadata discovery and token exchange + // Mock successful metadata discovery and token exchange - need protected resource metadata mockFetch.mockImplementation((url) => { const urlString = url.toString(); - if (urlString.includes("/.well-known/oauth-authorization-server")) { + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://api.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { return Promise.resolve({ ok: true, status: 200, @@ -1062,11 +1080,20 @@ describe("OAuth Authorization", () => { }); it("includes resource in token refresh", async () => { - // Mock successful metadata discovery and token refresh + // Mock successful metadata discovery and token refresh - need protected resource metadata mockFetch.mockImplementation((url) => { const urlString = url.toString(); - if (urlString.includes("/.well-known/oauth-authorization-server")) { + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://api.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { return Promise.resolve({ ok: true, status: 200, @@ -1244,5 +1271,197 @@ describe("OAuth Authorization", () => { // Should use the PRM's resource value, not the full requested URL expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/"); }); + + it("excludes resource parameter when Protected Resource Metadata is not present", async () => { + // Mock metadata discovery where protected resource metadata is not available (404) + // but authorization server metadata is available + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + // Protected resource metadata not available + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth - should not include resource parameter + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the authorization URL does NOT include the resource parameter + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }) + ); + + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + // Resource parameter should not be present when PRM is not available + expect(authUrl.searchParams.has("resource")).toBe(false); + }); + + it("excludes resource parameter in token exchange when Protected Resource Metadata is not present", async () => { + // Mock metadata discovery - no protected resource metadata, but auth server metadata available + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } else if (urlString.includes("/token")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: "access123", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "refresh123", + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token exchange + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.codeVerifier as jest.Mock).mockResolvedValue("test-verifier"); + (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + + // Call auth with authorization code + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + authorizationCode: "auth-code-123", + }); + + expect(result).toBe("AUTHORIZED"); + + // Find the token exchange call + const tokenCall = mockFetch.mock.calls.find(call => + call[0].toString().includes("/token") + ); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + // Resource parameter should not be present when PRM is not available + expect(body.has("resource")).toBe(false); + expect(body.get("code")).toBe("auth-code-123"); + }); + + it("excludes resource parameter in token refresh when Protected Resource Metadata is not present", async () => { + // Mock metadata discovery - no protected resource metadata, but auth server metadata available + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } else if (urlString.includes("/token")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access123", + token_type: "Bearer", + expires_in: 3600, + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token refresh + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue({ + access_token: "old-access", + refresh_token: "refresh123", + }); + (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + + // Call auth with existing tokens (should trigger refresh) + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("AUTHORIZED"); + + // Find the token refresh call + const tokenCall = mockFetch.mock.calls.find(call => + call[0].toString().includes("/token") + ); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + // Resource parameter should not be present when PRM is not available + expect(body.has("resource")).toBe(false); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("refresh123"); + }); }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index e0e93fc0..37690574 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -198,19 +198,24 @@ export async function auth( } export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { - let resource = resourceUrlFromServerUrl(serverUrl); + const defaultResource = resourceUrlFromServerUrl(serverUrl); + + // If provider has custom validation, delegate to it if (provider.validateResourceURL) { - return await provider.validateResourceURL(resource, resourceMetadata?.resource); - } else if (resourceMetadata) { - if (checkResourceAllowed({ requestedResource: resource, configuredResource: resourceMetadata.resource })) { - // If the resource mentioned in metadata is valid, prefer it since it is what the server is telling us to request. - resource = new URL(resourceMetadata.resource); - } else { - throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource} (or origin)`); - } + return await provider.validateResourceURL(defaultResource, resourceMetadata?.resource); } - return resource; + // Only include resource parameter when Protected Resource Metadata is present + if (!resourceMetadata) { + return undefined; + } + + // Validate that the metadata's resource is compatible with our request + if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) { + throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`); + } + // Prefer the resource from metadata since it's what the server is telling us to request + return new URL(resourceMetadata.resource); } /** @@ -360,7 +365,7 @@ export async function discoverOAuthMetadata( try { const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer); response = await tryMetadataDiscovery(rootUrl, protocolVersion); - + if (response.status === 404) { return undefined; } From c20a47a79f38f617ffd9ef0df1106651891d9ea7 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 24 Jun 2025 17:09:29 +0100 Subject: [PATCH 09/10] small fixes --- src/server/mcp.test.ts | 108 ++++++++++++++++++++--------------------- src/server/mcp.ts | 2 +- 2 files changed, 53 insertions(+), 57 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 242f0529..e09ab511 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1203,72 +1203,68 @@ describe("tool()", () => { }), ).rejects.toThrow(/Tool test has an output schema but no structured content was provided/); }); - - /*** + /*** * Test: Tool with Output Schema Must Provide Structured Content */ - test("should not throw error when tool with outputSchema returns no structuredContent and isError is true", async () => { - const mcpServer = new McpServer({ - name: "test server", - version: "1.0", - }); - - const client = new Client({ - name: "test client", - version: "1.0", - }); - - // Register a tool with outputSchema that returns only content without structuredContent - mcpServer.registerTool( - "test", - { - description: "Test tool with output schema but missing structured content", - inputSchema: { - input: z.string(), - }, - outputSchema: { - processedInput: z.string(), - resultType: z.string(), - }, + test("should skip outputSchema validation when isError is true", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.registerTool( + "test", + { + description: "Test tool with output schema but missing structured content", + inputSchema: { + input: z.string(), }, - async ({ input }) => ({ - // Only return content without structuredContent - content: [ - { - type: "text", - text: `Processed: ${input}`, - }, - ], - isError: true, - }) - ); - - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.server.connect(serverTransport), - ]); - - // Call the tool and expect it to not throw an error - await expect( - client.callTool({ - name: "test", - arguments: { - input: "hello", - }, - }), - ).resolves.toStrictEqual({ + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + }, + }, + async ({ input }) => ({ content: [ { type: "text", - text: `Processed: hello`, + text: `Processed: ${input}`, }, ], isError: true, - }); + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + await expect( + client.callTool({ + name: "test", + arguments: { + input: "hello", + }, + }), + ).resolves.toStrictEqual({ + content: [ + { + type: "text", + text: `Processed: hello`, + }, + ], + isError: true, }); + }); /*** * Test: Schema Validation Failure for Invalid Structured Content diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 9440708d..67da78ff 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -200,7 +200,7 @@ export class McpServer { } } - if (tool.outputSchema && (result.isError !== true)) { + if (tool.outputSchema && !result.isError) { if (!result.structuredContent) { throw new McpError( ErrorCode.InvalidParams, From 05d98bb3a4b8feab427bf71f11c77b2266132a7b Mon Sep 17 00:00:00 2001 From: Chris Dickinson Date: Wed, 25 Jun 2025 03:20:26 -0700 Subject: [PATCH 10/10] feat(shared/auth): support software_statement in OAuthClientMetadata (#696) Per [Section 3.1.1][ref], `software_statement` is an OPTIONAL member of the client creation request, which may contain a JWT encoding claims about client software. [ref]: https://datatracker.ietf.org/doc/html/rfc7591#section-3.1.1 --- src/shared/auth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 65b800e7..b906de3d 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -98,6 +98,7 @@ export const OAuthClientMetadataSchema = z.object({ jwks: z.any().optional(), software_id: z.string().optional(), software_version: z.string().optional(), + software_statement: z.string().optional(), }).strip(); /**