diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index f95cb2ca..8e77c0a5 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -207,6 +207,144 @@ 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("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; @@ -816,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, @@ -864,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, @@ -924,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, @@ -1106,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 d953e1f0..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); } /** @@ -293,28 +298,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 url = new URL("/.well-known/oauth-authorization-server", authorizationServerUrl); - let response: Response; - try { - response = await fetch(url, { - headers: { - "MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION - } - }); - } catch (error) { - // CORS errors come back as TypeError - if (error instanceof TypeError) { - response = await fetch(url); - } else { - throw error; - } - } + const issuer = new URL(authorizationServerUrl); + const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; - if (response.status === 404) { + // 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); + + // 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 { + // If fallback fails, return undefined + return undefined; + } + } else if (response.status === 404) { return undefined; } diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 09d30da2..fea0eec0 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -498,15 +498,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]; diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 50df25b5..ab728c6a 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1203,6 +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 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(), + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + }, + }, + async ({ input }) => ({ + content: [ + { + type: "text", + 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 3d9673da..67da78ff 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) { if (!result.structuredContent) { throw new McpError( ErrorCode.InvalidParams, 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(); /**