diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..e69de29b diff --git a/README.md b/README.md index 272eeb12..aa8f9304 100644 --- a/README.md +++ b/README.md @@ -849,6 +849,109 @@ const transport = new StdioServerTransport(); await server.connect(transport); ``` +### Eliciting User Input + +MCP servers can request additional information from users through the elicitation feature. This is useful for interactive workflows where the server needs user input or confirmation: + +```typescript +// Server-side: Restaurant booking tool that asks for alternatives +server.tool( + "book-restaurant", + { + restaurant: z.string(), + date: z.string(), + partySize: z.number() + }, + async ({ restaurant, date, partySize }) => { + // Check availability + const available = await checkAvailability(restaurant, date, partySize); + + if (!available) { + // Ask user if they want to try alternative dates + const result = await server.server.elicitInput({ + message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, + requestedSchema: { + type: "object", + properties: { + checkAlternatives: { + type: "boolean", + title: "Check alternative dates", + description: "Would you like me to check other dates?" + }, + flexibleDates: { + type: "string", + title: "Date flexibility", + description: "How flexible are your dates?", + enum: ["next_day", "same_week", "next_week"], + enumNames: ["Next day", "Same week", "Next week"] + } + }, + required: ["checkAlternatives"] + } + }); + + if (result.action === "accept" && result.content?.checkAlternatives) { + const alternatives = await findAlternatives( + restaurant, + date, + partySize, + result.content.flexibleDates as string + ); + return { + content: [{ + type: "text", + text: `Found these alternatives: ${alternatives.join(", ")}` + }] + }; + } + + return { + content: [{ + type: "text", + text: "No booking made. Original date not available." + }] + }; + } + + // Book the table + await makeBooking(restaurant, date, partySize); + return { + content: [{ + type: "text", + text: `Booked table for ${partySize} at ${restaurant} on ${date}` + }] + }; + } +); +``` + +Client-side: Handle elicitation requests + +```typescript +// This is a placeholder - implement based on your UI framework +async function getInputFromUser(message: string, schema: any): Promise<{ + action: "accept" | "reject" | "cancel"; + data?: Record; +}> { + // This should be implemented depending on the app + throw new Error("getInputFromUser must be implemented for your platform"); +} + +client.setRequestHandler(ElicitRequestSchema, async (request) => { + const userResponse = await getInputFromUser( + request.params.message, + request.params.requestedSchema + ); + + return { + action: userResponse.action, + content: userResponse.action === "accept" ? userResponse.data : undefined + }; +}); +``` + +**Note**: Elicitation requires client support. Clients must declare the `elicitation` capability during initialization. + ### Writing MCP Clients The SDK provides a high-level client interface: diff --git a/eslint.config.mjs b/eslint.config.mjs index 515114cf..d792f015 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,5 +15,12 @@ export default tseslint.config( { "argsIgnorePattern": "^_" } ] } + }, + { + files: ["src/client/**/*.ts", "src/server/**/*.ts"], + ignores: ["**/*.test.ts"], + rules: { + "no-console": "error" + } } ); diff --git a/package-lock.json b/package-lock.json index 1a9a8f45..9dd8236b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,11 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.12.0", + "version": "1.13.1", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/package.json b/package.json index 6b184f31..0439e680 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.12.3", + "version": "1.13.1", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -36,8 +36,11 @@ ], "scripts": { "build": "npm run build:esm && npm run build:cjs", - "build:esm": "tsc -p tsconfig.prod.json && echo '{\"type\": \"module\"}' > dist/esm/package.json", - "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json", + "build:esm": "mkdir -p dist/esm && echo '{\"type\": \"module\"}' > dist/esm/package.json && tsc -p tsconfig.prod.json", + "build:esm:w": "npm run build:esm -- -w", + "build:cjs": "mkdir -p dist/cjs && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json && tsc -p tsconfig.cjs.json", + "build:cjs:w": "npm run build:cjs -- -w", + "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", "prepack": "npm run build:esm && npm run build:cjs", "lint": "eslint src/", "test": "jest", diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 1b9fb071..b689d188 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1,3 +1,4 @@ +import { LATEST_PROTOCOL_VERSION } from '../types.js'; import { discoverOAuthMetadata, startAuthorization, @@ -202,7 +203,145 @@ describe("OAuth Authorization", () => { const [url, options] = calls[0]; expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); expect(options.headers).toEqual({ - "MCP-Protocol-Version": "2025-03-26" + "MCP-Protocol-Version": LATEST_PROTOCOL_VERSION + }); + }); + + 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 }); }); @@ -324,6 +463,7 @@ describe("OAuth Authorization", () => { metadata: undefined, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", + resource: new URL("https://api.example.com/mcp-server"), } ); @@ -338,6 +478,7 @@ describe("OAuth Authorization", () => { expect(authorizationUrl.searchParams.get("redirect_uri")).toBe( "http://localhost:3000/callback" ); + expect(authorizationUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); expect(codeVerifier).toBe("test_verifier"); }); @@ -465,6 +606,7 @@ describe("OAuth Authorization", () => { authorizationCode: "code123", codeVerifier: "verifier123", redirectUri: "http://localhost:3000/callback", + resource: new URL("https://api.example.com/mcp-server"), }); expect(tokens).toEqual(validTokens); @@ -487,6 +629,7 @@ describe("OAuth Authorization", () => { expect(body.get("client_id")).toBe("client123"); expect(body.get("client_secret")).toBe("secret123"); expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback"); + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); }); it("validates token response schema", async () => { @@ -554,6 +697,7 @@ describe("OAuth Authorization", () => { const tokens = await refreshAuthorization("https://auth.example.com", { clientInformation: validClientInfo, refreshToken: "refresh123", + resource: new URL("https://api.example.com/mcp-server"), }); expect(tokens).toEqual(validTokensWithNewRefreshToken); @@ -574,6 +718,7 @@ describe("OAuth Authorization", () => { expect(body.get("refresh_token")).toBe("refresh123"); expect(body.get("client_id")).toBe("client123"); expect(body.get("client_secret")).toBe("secret123"); + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); }); it("exchanges refresh token for new tokens and keep existing refresh token if none is returned", async () => { @@ -807,5 +952,297 @@ describe("OAuth Authorization", () => { "https://resource.example.com/.well-known/oauth-authorization-server" ); }); + + it("passes resource parameter through authorization flow", async () => { + // Mock successful metadata discovery + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + 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 for authorization flow + (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 without authorization code (should trigger redirect) + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the authorization URL includes 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]; + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("includes resource in token exchange when authorization code is provided", async () => { + // Mock successful metadata discovery and token exchange + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + 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; + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); + expect(body.get("code")).toBe("auth-code-123"); + }); + + it("includes resource in token refresh", async () => { + // Mock successful metadata discovery and token refresh + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + 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; + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("refresh123"); + }); + + it("skips default PRM resource validation when custom validateResourceURL is provided", async () => { + const mockValidateResourceURL = jest.fn().mockResolvedValue(undefined); + const providerWithCustomValidation = { + ...mockProvider, + validateResourceURL: mockValidateResourceURL, + }; + + // Mock protected resource metadata with mismatched resource URL + // This would normally throw an error in default validation, but should be skipped + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://different-resource.example.com/mcp-server", // Mismatched resource + authorization_servers: ["https://auth.example.com"], + }), + }); + } 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 + (providerWithCustomValidation.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (providerWithCustomValidation.tokens as jest.Mock).mockResolvedValue(undefined); + (providerWithCustomValidation.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (providerWithCustomValidation.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth - should succeed despite resource mismatch because custom validation overrides default + const result = await auth(providerWithCustomValidation, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("REDIRECT"); + + // Verify custom validation method was called + expect(mockValidateResourceURL).toHaveBeenCalledWith( + new URL("https://api.example.com/mcp-server"), + "https://different-resource.example.com/mcp-server" + ); + }); + + it("uses prefix of server URL from PRM resource as resource parameter", async () => { + // Mock successful metadata discovery with resource URL that is a prefix of requested URL + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + // Resource is a prefix of the requested server URL + resource: "https://api.example.com/", + authorization_servers: ["https://auth.example.com"], + }), + }); + } 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 with a URL that has the resource as prefix + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server/endpoint", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the authorization URL includes the resource parameter from PRM + 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]; + // Should use the PRM's resource value, not the full requested URL + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/"); + }); }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 7a91eb25..e0e93fc0 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -2,6 +2,7 @@ import pkceChallenge from "pkce-challenge"; import { LATEST_PROTOCOL_VERSION } from "../types.js"; import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js"; import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js"; +import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js"; /** * Implements an end-to-end OAuth client to be used with one MCP server. @@ -71,6 +72,15 @@ export interface OAuthClientProvider { * the authorization result. */ codeVerifier(): string | Promise; + + /** + * If defined, overrides the selection and validation of the + * RFC 8707 Resource Indicator. If left undefined, default + * validation behavior will be used. + * + * Implementations must verify the returned resource matches the MCP server. + */ + validateResourceURL?(serverUrl: string | URL, resource?: string): Promise; } export type AuthResult = "AUTHORIZED" | "REDIRECT"; @@ -99,18 +109,19 @@ export async function auth( scope?: string; resourceMetadataUrl?: URL }): Promise { + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl = serverUrl; try { - const resourceMetadata = await discoverOAuthProtectedResourceMetadata( - resourceMetadataUrl || serverUrl); - + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {resourceMetadataUrl}); if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } - } catch (error) { - console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) + } catch { + // Ignore errors and fall back to /.well-known/oauth-authorization-server } + const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + const metadata = await discoverOAuthMetadata(authorizationServerUrl); // Handle client registration if needed @@ -142,6 +153,7 @@ export async function auth( authorizationCode, codeVerifier, redirectUri: provider.redirectUrl, + resource, }); await provider.saveTokens(tokens); @@ -158,12 +170,13 @@ export async function auth( metadata, clientInformation, refreshToken: tokens.refresh_token, + resource, }); await provider.saveTokens(newTokens); return "AUTHORIZED"; - } catch (error) { - console.error("Could not refresh OAuth tokens:", error); + } catch { + // Could not refresh OAuth tokens } } @@ -176,6 +189,7 @@ export async function auth( state, redirectUrl: provider.redirectUrl, scope: scope || provider.clientMetadata.scope, + resource, }); await provider.saveCodeVerifier(codeVerifier); @@ -183,6 +197,22 @@ export async function auth( return "REDIRECT"; } +export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { + let resource = resourceUrlFromServerUrl(serverUrl); + 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 resource; +} + /** * Extract resource_metadata from response header. */ @@ -195,7 +225,6 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined { const [type, scheme] = authenticateHeader.split(' '); if (type.toLowerCase() !== 'bearer' || !scheme) { - console.log("Invalid WWW-Authenticate header format, expected 'Bearer'"); return undefined; } const regex = /resource_metadata="([^"]*)"/; @@ -208,7 +237,6 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined { try { return new URL(match[1]); } catch { - console.log("Invalid resource metadata url: ", match[1]); return undefined; } } @@ -265,28 +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 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; } @@ -310,12 +392,14 @@ export async function startAuthorization( redirectUrl, scope, state, + resource, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; redirectUrl: string | URL; scope?: string; state?: string; + resource?: URL; }, ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { const responseType = "code"; @@ -365,6 +449,10 @@ export async function startAuthorization( authorizationUrl.searchParams.set("scope", scope); } + if (resource) { + authorizationUrl.searchParams.set("resource", resource.href); + } + return { authorizationUrl, codeVerifier }; } @@ -379,12 +467,14 @@ export async function exchangeAuthorization( authorizationCode, codeVerifier, redirectUri, + resource, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; authorizationCode: string; codeVerifier: string; redirectUri: string | URL; + resource?: URL; }, ): Promise { const grantType = "authorization_code"; @@ -418,6 +508,10 @@ export async function exchangeAuthorization( params.set("client_secret", clientInformation.client_secret); } + if (resource) { + params.set("resource", resource.href); + } + const response = await fetch(tokenUrl, { method: "POST", headers: { @@ -442,10 +536,12 @@ export async function refreshAuthorization( metadata, clientInformation, refreshToken, + resource, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; refreshToken: string; + resource?: URL; }, ): Promise { const grantType = "refresh_token"; @@ -477,6 +573,10 @@ export async function refreshAuthorization( params.set("client_secret", clientInformation.client_secret); } + if (resource) { + params.set("resource", resource.href); + } + const response = await fetch(tokenUrl, { method: "POST", headers: { diff --git a/src/client/index.test.ts b/src/client/index.test.ts index f80459f1..02d6781c 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -14,6 +14,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema, CreateMessageRequestSchema, + ElicitRequestSchema, ListRootsRequestSchema, ErrorCode, } from "../types.js"; @@ -604,6 +605,43 @@ test("should only allow setRequestHandler for declared capabilities", () => { }).toThrow("Client does not support roots capability"); }); +test("should allow setRequestHandler for declared elicitation capability", () => { + const client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + // This should work because elicitation is a declared capability + expect(() => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: "accept", + content: { + username: "test-user", + confirmed: true, + }, + })); + }).not.toThrow(); + + // This should throw because sampling is not a declared capability + expect(() => { + client.setRequestHandler(CreateMessageRequestSchema, () => ({ + model: "test-model", + role: "assistant", + content: { + type: "text", + text: "Test response", + }, + })); + }).toThrow("Client does not support sampling capability"); +}); + /*** * Test: Type Checking * Test that custom request/notification/result schemas can be used with the Client class. diff --git a/src/client/index.ts b/src/client/index.ts index a8fbdcee..3e8d8ec8 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -307,6 +307,14 @@ export class Client< } break; + case "elicitation/create": + if (!this._capabilities.elicitation) { + throw new Error( + `Client does not support elicitation capability (required for ${method})`, + ); + } + break; + case "roots/list": if (!this._capabilities.roots) { throw new Error( @@ -478,8 +486,8 @@ export class Client< try { const validator = this._ajv.compile(tool.outputSchema); this._cachedToolOutputValidators.set(tool.name, validator); - } catch (error) { - console.warn(`Failed to compile output schema for tool ${tool.name}: ${error}`); + } catch { + // Ignore schema compilation errors } } } diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 714e1fdd..3cb4e8a3 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -398,7 +398,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; @@ -450,7 +450,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; @@ -601,7 +601,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; @@ -723,7 +723,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; @@ -851,7 +851,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; diff --git a/src/client/sse.ts b/src/client/sse.ts index 5aa99abb..2546d508 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -117,23 +117,35 @@ export class SSEClientTransport implements Transport { } private _startOrAuth(): Promise { + const fetchImpl = (this?._eventSourceInit?.fetch || fetch) as typeof fetch return new Promise((resolve, reject) => { this._eventSource = new EventSource( this._url.href, - this._eventSourceInit ?? { - fetch: (url, init) => this._commonHeaders().then((headers) => fetch(url, { - ...init, - headers: { - ...headers, - Accept: "text/event-stream" + { + ...this._eventSourceInit, + fetch: async (url, init) => { + const headers = await this._commonHeaders() + const response = await fetchImpl(url, { + ...init, + headers: new Headers({ + ...headers, + Accept: "text/event-stream" + }) + }) + + if (response.status === 401 && response.headers.has('www-authenticate')) { + this._resourceMetadataUrl = extractResourceMetadataUrl(response); } - })), + + return response + }, }, ); this._abortController = new AbortController(); this._eventSource.onerror = (event) => { if (event.code === 401 && this._authProvider) { + this._authThenStart().then(resolve, reject); return; } diff --git a/src/examples/README.md b/src/examples/README.md index 68e1ece2..ac92e8de 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -76,6 +76,9 @@ npx tsx src/examples/server/simpleStreamableHttp.ts # To add a demo of authentication to this example, use: npx tsx src/examples/server/simpleStreamableHttp.ts --oauth + +# To mitigate impersonation risks, enable strict Resource Identifier verification: +npx tsx src/examples/server/simpleStreamableHttp.ts --oauth --oauth-strict ``` ##### JSON Response Mode Server diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 0799e061..02db131e 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -14,11 +14,13 @@ import { ListResourcesResultSchema, LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, + ElicitRequestSchema, + ResourceLink, ReadResourceRequest, ReadResourceResultSchema, - ResourceLink, } from '../../types.js'; import { getDisplayName } from '../../shared/metadataUtils.js'; +import Ajv from "ajv"; // Create readline interface for user input const readline = createInterface({ @@ -58,6 +60,7 @@ function printHelp(): void { console.log(' call-tool [args] - Call a tool with optional JSON arguments'); console.log(' greet [name] - Call the greet tool'); console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); + console.log(' collect-info [type] - Test elicitation with collect-user-info tool (contact/preferences/feedback)'); console.log(' start-notifications [interval] [count] - Start periodic notifications'); console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability'); console.log(' list-prompts - List available prompts'); @@ -120,6 +123,10 @@ function commandLoop(): void { await callMultiGreetTool(args[1] || 'MCP User'); break; + case 'collect-info': + await callCollectInfoTool(args[1] || 'contact'); + break; + case 'start-notifications': { const interval = args[1] ? parseInt(args[1], 10) : 2000; const count = args[2] ? parseInt(args[2], 10) : 10; @@ -204,15 +211,212 @@ async function connect(url?: string): Promise { console.log(`Connecting to ${serverUrl}...`); try { - // Create a new client + // Create a new client with elicitation capability client = new Client({ name: 'example-client', version: '1.0.0' + }, { + capabilities: { + elicitation: {}, + }, }); client.onerror = (error) => { console.error('\x1b[31mClient error:', error, '\x1b[0m'); } + // Set up elicitation request handler with proper validation + client.setRequestHandler(ElicitRequestSchema, async (request) => { + console.log('\nšŸ”” Elicitation Request Received:'); + console.log(`Message: ${request.params.message}`); + console.log('Requested Schema:'); + console.log(JSON.stringify(request.params.requestedSchema, null, 2)); + + const schema = request.params.requestedSchema; + const properties = schema.properties; + const required = schema.required || []; + + // Set up AJV validator for the requested schema + const ajv = new Ajv(); + const validate = ajv.compile(schema); + + let attempts = 0; + const maxAttempts = 3; + + while (attempts < maxAttempts) { + attempts++; + console.log(`\nPlease provide the following information (attempt ${attempts}/${maxAttempts}):`); + + const content: Record = {}; + let inputCancelled = false; + + // Collect input for each field + for (const [fieldName, fieldSchema] of Object.entries(properties)) { + const field = fieldSchema as { + type?: string; + title?: string; + description?: string; + default?: unknown; + enum?: string[]; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + format?: string; + }; + + const isRequired = required.includes(fieldName); + let prompt = `${field.title || fieldName}`; + + // Add helpful information to the prompt + if (field.description) { + prompt += ` (${field.description})`; + } + if (field.enum) { + prompt += ` [options: ${field.enum.join(', ')}]`; + } + if (field.type === 'number' || field.type === 'integer') { + if (field.minimum !== undefined && field.maximum !== undefined) { + prompt += ` [${field.minimum}-${field.maximum}]`; + } else if (field.minimum !== undefined) { + prompt += ` [min: ${field.minimum}]`; + } else if (field.maximum !== undefined) { + prompt += ` [max: ${field.maximum}]`; + } + } + if (field.type === 'string' && field.format) { + prompt += ` [format: ${field.format}]`; + } + if (isRequired) { + prompt += ' *required*'; + } + if (field.default !== undefined) { + prompt += ` [default: ${field.default}]`; + } + + prompt += ': '; + + const answer = await new Promise((resolve) => { + readline.question(prompt, (input) => { + resolve(input.trim()); + }); + }); + + // Check for cancellation + if (answer.toLowerCase() === 'cancel' || answer.toLowerCase() === 'c') { + inputCancelled = true; + break; + } + + // Parse and validate the input + try { + if (answer === '' && field.default !== undefined) { + content[fieldName] = field.default; + } else if (answer === '' && !isRequired) { + // Skip optional empty fields + continue; + } else if (answer === '') { + throw new Error(`${fieldName} is required`); + } else { + // Parse the value based on type + let parsedValue: unknown; + + if (field.type === 'boolean') { + parsedValue = answer.toLowerCase() === 'true' || answer.toLowerCase() === 'yes' || answer === '1'; + } else if (field.type === 'number') { + parsedValue = parseFloat(answer); + if (isNaN(parsedValue as number)) { + throw new Error(`${fieldName} must be a valid number`); + } + } else if (field.type === 'integer') { + parsedValue = parseInt(answer, 10); + if (isNaN(parsedValue as number)) { + throw new Error(`${fieldName} must be a valid integer`); + } + } else if (field.enum) { + if (!field.enum.includes(answer)) { + throw new Error(`${fieldName} must be one of: ${field.enum.join(', ')}`); + } + parsedValue = answer; + } else { + parsedValue = answer; + } + + content[fieldName] = parsedValue; + } + } catch (error) { + console.log(`āŒ Error: ${error}`); + // Continue to next attempt + break; + } + } + + if (inputCancelled) { + return { action: 'cancel' }; + } + + // If we didn't complete all fields due to an error, try again + if (Object.keys(content).length !== Object.keys(properties).filter(name => + required.includes(name) || content[name] !== undefined + ).length) { + if (attempts < maxAttempts) { + console.log('Please try again...'); + continue; + } else { + console.log('Maximum attempts reached. Declining request.'); + return { action: 'reject' }; + } + } + + // Validate the complete object against the schema + const isValid = validate(content); + + if (!isValid) { + console.log('āŒ Validation errors:'); + validate.errors?.forEach(error => { + console.log(` - ${error.dataPath || 'root'}: ${error.message}`); + }); + + if (attempts < maxAttempts) { + console.log('Please correct the errors and try again...'); + continue; + } else { + console.log('Maximum attempts reached. Declining request.'); + return { action: 'reject' }; + } + } + + // Show the collected data and ask for confirmation + console.log('\nāœ… Collected data:'); + console.log(JSON.stringify(content, null, 2)); + + const confirmAnswer = await new Promise((resolve) => { + readline.question('\nSubmit this information? (yes/no/cancel): ', (input) => { + resolve(input.trim().toLowerCase()); + }); + }); + + + if (confirmAnswer === 'yes' || confirmAnswer === 'y') { + return { + action: 'accept', + content, + }; + } else if (confirmAnswer === 'cancel' || confirmAnswer === 'c') { + return { action: 'cancel' }; + } else if (confirmAnswer === 'no' || confirmAnswer === 'n') { + if (attempts < maxAttempts) { + console.log('Please re-enter the information...'); + continue; + } else { + return { action: 'reject' }; + } + } + } + + console.log('Maximum attempts reached. Declining request.'); + return { action: 'reject' }; + }); + transport = new StreamableHTTPClientTransport( new URL(serverUrl), { @@ -358,7 +562,7 @@ async function callTool(name: string, args: Record): Promise { if (item.type === 'text') { console.log(` ${item.text}`); @@ -383,7 +587,7 @@ async function callTool(name: string, args: Record): Promise 0) { console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource ' to read their content.`); @@ -403,6 +607,11 @@ async function callMultiGreetTool(name: string): Promise { await callTool('multi-greet', { name }); } +async function callCollectInfoTool(infoType: string): Promise { + console.log(`Testing elicitation with collect-user-info tool (${infoType})...`); + await callTool('collect-user-info', { infoType }); +} + async function startNotifications(interval: number, count: number): Promise { console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`); await callTool('start-notification-stream', { interval, count }); @@ -546,7 +755,7 @@ async function readResource(uri: string): Promise { if (content.mimeType) { console.log(` Type: ${content.mimeType}`); } - + if ('text' in content && typeof content.text === 'string') { console.log(' Content:'); console.log(' ---'); diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 024208d6..274a504a 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -1,10 +1,11 @@ import { randomUUID } from 'node:crypto'; import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js'; import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js'; -import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from 'src/shared/auth.js'; +import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../shared/auth.js'; import express, { Request, Response } from "express"; -import { AuthInfo } from 'src/server/auth/types.js'; -import { createOAuthMetadata, mcpAuthRouter } from 'src/server/auth/router.js'; +import { AuthInfo } from '../../server/auth/types.js'; +import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js'; +import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js'; export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { @@ -35,6 +36,8 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { client: OAuthClientInformationFull}>(); private tokens = new Map(); + constructor(private validateResource?: (resource?: URL) => boolean) {} + async authorize( client: OAuthClientInformationFull, params: AuthorizationParams, @@ -89,6 +92,10 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`); } + if (this.validateResource && !this.validateResource(codeData.params.resource)) { + throw new Error(`Invalid resource: ${codeData.params.resource}`); + } + this.codes.delete(authorizationCode); const token = randomUUID(); @@ -97,7 +104,8 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { clientId: client.client_id, scopes: codeData.params.scopes || [], expiresAt: Date.now() + 3600000, // 1 hour - type: 'access' + resource: codeData.params.resource, + type: 'access', }; this.tokens.set(token, tokenData); @@ -113,7 +121,8 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { async exchangeRefreshToken( _client: OAuthClientInformationFull, _refreshToken: string, - _scopes?: string[] + _scopes?: string[], + _resource?: URL ): Promise { throw new Error('Not implemented for example demo'); } @@ -129,18 +138,26 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { clientId: tokenData.clientId, scopes: tokenData.scopes, expiresAt: Math.floor(tokenData.expiresAt / 1000), + resource: tokenData.resource, }; } } -export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => { +export const setupAuthServer = ({authServerUrl, mcpServerUrl, strictResource}: {authServerUrl: URL, mcpServerUrl: URL, strictResource: boolean}): OAuthMetadata => { // Create separate auth server app // NOTE: This is a separate app on a separate port to illustrate // how to separate an OAuth Authorization Server from a Resource // server in the SDK. The SDK is not intended to be provide a standalone // authorization server. - const provider = new DemoInMemoryAuthProvider(); + + const validateResource = strictResource ? (resource?: URL) => { + if (!resource) return false; + const expectedResource = resourceUrlFromServerUrl(mcpServerUrl); + return resource.toString() === expectedResource.toString(); + } : undefined; + + const provider = new DemoInMemoryAuthProvider(validateResource); const authApp = express(); authApp.use(express.json()); // For introspection requests @@ -168,7 +185,8 @@ export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => { active: true, client_id: tokenInfo.clientId, scope: tokenInfo.scopes.join(' '), - exp: tokenInfo.expiresAt + exp: tokenInfo.expiresAt, + aud: tokenInfo.resource, }); return } catch (error) { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index ebe31920..09d30da2 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -5,13 +5,15 @@ import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; -import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult, ResourceLink } from '../../types.js'; +import { CallToolResult, GetPromptResult, isInitializeRequest, PrimitiveSchemaDefinition, ReadResourceResult, ResourceLink } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from 'src/shared/auth.js'; +import { checkResourceAllowed } from 'src/shared/auth-utils.js'; // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); +const strictOAuth = process.argv.includes('--oauth-strict'); // Create an MCP server with implementation details const getServer = () => { @@ -86,6 +88,155 @@ const getServer = () => { }; } ); + // Register a tool that demonstrates elicitation (user input collection) + // This creates a closure that captures the server instance + server.tool( + 'collect-user-info', + 'A tool that collects user information through elicitation', + { + infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect'), + }, + async ({ infoType }): Promise => { + let message: string; + let requestedSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; + + switch (infoType) { + case 'contact': + message = 'Please provide your contact information'; + requestedSchema = { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Full Name', + description: 'Your full name', + }, + email: { + type: 'string', + title: 'Email Address', + description: 'Your email address', + format: 'email', + }, + phone: { + type: 'string', + title: 'Phone Number', + description: 'Your phone number (optional)', + }, + }, + required: ['name', 'email'], + }; + break; + case 'preferences': + message = 'Please set your preferences'; + requestedSchema = { + type: 'object', + properties: { + theme: { + type: 'string', + title: 'Theme', + description: 'Choose your preferred theme', + enum: ['light', 'dark', 'auto'], + enumNames: ['Light', 'Dark', 'Auto'], + }, + notifications: { + type: 'boolean', + title: 'Enable Notifications', + description: 'Would you like to receive notifications?', + default: true, + }, + frequency: { + type: 'string', + title: 'Notification Frequency', + description: 'How often would you like notifications?', + enum: ['daily', 'weekly', 'monthly'], + enumNames: ['Daily', 'Weekly', 'Monthly'], + }, + }, + required: ['theme'], + }; + break; + case 'feedback': + message = 'Please provide your feedback'; + requestedSchema = { + type: 'object', + properties: { + rating: { + type: 'integer', + title: 'Rating', + description: 'Rate your experience (1-5)', + minimum: 1, + maximum: 5, + }, + comments: { + type: 'string', + title: 'Comments', + description: 'Additional comments (optional)', + maxLength: 500, + }, + recommend: { + type: 'boolean', + title: 'Would you recommend this?', + description: 'Would you recommend this to others?', + }, + }, + required: ['rating', 'recommend'], + }; + break; + default: + throw new Error(`Unknown info type: ${infoType}`); + } + + try { + // Use the underlying server instance to elicit input from the client + const result = await server.server.elicitInput({ + message, + requestedSchema, + }); + + if (result.action === 'accept') { + return { + content: [ + { + type: 'text', + text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}`, + }, + ], + }; + } else if (result.action === 'reject') { + return { + content: [ + { + type: 'text', + text: `No information was collected. User rejected ${infoType} information request.`, + }, + ], + }; + } else { + return { + content: [ + { + type: 'text', + text: `Information collection was cancelled by the user.`, + }, + ], + }; + } + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error collecting ${infoType} information: ${error}`, + }, + ], + }; + } + } + ); // Register a simple prompt with title server.registerPrompt( @@ -178,10 +329,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 +349,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 { @@ -279,10 +430,10 @@ app.use(express.json()); let authMiddleware = null; if (useOAuth) { // Create auth middleware for MCP endpoints - const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`); + const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl); + const oauthMetadata: OAuthMetadata = setupAuthServer({authServerUrl, mcpServerUrl, strictResource: strictOAuth}); const tokenVerifier = { verifyAccessToken: async (token: string) => { @@ -309,6 +460,15 @@ if (useOAuth) { const data = await response.json(); + if (strictOAuth) { + if (!data.aud) { + throw new Error(`Resource Indicator (RFC8707) missing`); + } + if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) { + throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); + } + } + // Convert the response to AuthInfo format return { token, @@ -328,7 +488,7 @@ if (useOAuth) { authMiddleware = requireBearerAuth({ verifier: tokenVerifier, - requiredScopes: ['mcp:tools'], + requiredScopes: [], resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), }); } diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index d12a4f99..4a191134 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -5,7 +5,7 @@ import { Client } from '../client/index.js'; import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; import { McpServer } from '../server/mcp.js'; import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; -import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from '../types.js'; +import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema, LATEST_PROTOCOL_VERSION } from '../types.js'; import { z } from 'zod'; describe('Streamable HTTP Transport Session Management', () => { @@ -145,7 +145,7 @@ describe('Streamable HTTP Transport Session Management', () => { params: {} }, ListToolsResultSchema); - + }); it('should operate without session management', async () => { // Create and connect a client @@ -220,15 +220,15 @@ describe('Streamable HTTP Transport Session Management', () => { }); const transport = new StreamableHTTPClientTransport(baseUrl); - + // Verify protocol version is not set before connecting expect(transport.protocolVersion).toBeUndefined(); - + await client.connect(transport); - + // Verify protocol version is set after connecting - expect(transport.protocolVersion).toBe(DEFAULT_NEGOTIATED_PROTOCOL_VERSION); - + expect(transport.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + // Clean up await transport.close(); }); diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index e921d5ea..438db6a6 100644 --- a/src/server/auth/handlers/authorize.test.ts +++ b/src/server/auth/handlers/authorize.test.ts @@ -276,6 +276,34 @@ describe('Authorization Handler', () => { }); }); + describe('Resource parameter validation', () => { + it('propagates resource parameter', async () => { + const mockProviderWithResource = jest.spyOn(mockProvider, 'authorize'); + + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(302); + expect(mockProviderWithResource).toHaveBeenCalledWith( + validClient, + expect.objectContaining({ + resource: new URL('https://api.example.com/resource'), + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge123' + }), + expect.any(Object) + ); + }); + }); + describe('Successful authorization', () => { it('handles successful authorization with all parameters', async () => { const response = await supertest(app) diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index 3e9a336b..126ce006 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -35,6 +35,7 @@ const RequestAuthorizationParamsSchema = z.object({ code_challenge_method: z.literal("S256"), scope: z.string().optional(), state: z.string().optional(), + resource: z.string().url().optional(), }); export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler { @@ -98,7 +99,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error looking up client:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } @@ -115,7 +115,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A throw new InvalidRequestError(parseResult.error.message); } - const { scope, code_challenge } = parseResult.data; + const { scope, code_challenge, resource } = parseResult.data; state = parseResult.data.state; // Validate scopes @@ -138,13 +138,13 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A scopes: requestedScopes, redirectUri: redirect_uri, codeChallenge: code_challenge, + resource: resource ? new URL(resource) : undefined, }, res); } catch (error) { // Post-redirect errors - redirect with error parameters if (error instanceof OAuthError) { res.redirect(302, createErrorRedirect(redirect_uri, error, state)); } else { - console.error("Unexpected error during authorization:", error); const serverError = new ServerError("Internal Server Error"); res.redirect(302, createErrorRedirect(redirect_uri, serverError, state)); } diff --git a/src/server/auth/handlers/register.ts b/src/server/auth/handlers/register.ts index 30b7cdf8..c3137348 100644 --- a/src/server/auth/handlers/register.ts +++ b/src/server/auth/handlers/register.ts @@ -104,7 +104,6 @@ export function clientRegistrationHandler({ const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error registering client:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/handlers/revoke.ts b/src/server/auth/handlers/revoke.ts index 95e8b4b3..0d1b30e0 100644 --- a/src/server/auth/handlers/revoke.ts +++ b/src/server/auth/handlers/revoke.ts @@ -9,7 +9,7 @@ import { InvalidRequestError, ServerError, TooManyRequestsError, - OAuthError + OAuthError, } from "../errors.js"; export type RevocationHandlerOptions = { @@ -21,7 +21,10 @@ export type RevocationHandlerOptions = { rateLimit?: Partial | false; }; -export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler { +export function revocationHandler({ + provider, + rateLimit: rateLimitConfig, +}: RevocationHandlerOptions): RequestHandler { if (!provider.revokeToken) { throw new Error("Auth provider does not support revoking tokens"); } @@ -37,21 +40,25 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo // Apply rate limiting unless explicitly disabled if (rateLimitConfig !== false) { - router.use(rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 50, // 50 requests per windowMs - standardHeaders: true, - legacyHeaders: false, - message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(), - ...rateLimitConfig - })); + router.use( + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 50, // 50 requests per windowMs + standardHeaders: true, + legacyHeaders: false, + message: new TooManyRequestsError( + "You have exceeded the rate limit for token revocation requests" + ).toResponseObject(), + ...rateLimitConfig, + }) + ); } // Authenticate and extract client details router.use(authenticateClient({ clientsStore: provider.clientsStore })); router.post("/", async (req, res) => { - res.setHeader('Cache-Control', 'no-store'); + res.setHeader("Cache-Control", "no-store"); try { const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body); @@ -62,7 +69,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo const client = req.client; if (!client) { // This should never happen - console.error("Missing client information after authentication"); throw new ServerError("Internal Server Error"); } @@ -73,7 +79,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error revoking token:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index c165fe7f..4b7fae02 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -264,12 +264,14 @@ describe('Token Handler', () => { }); it('returns tokens for valid code exchange', async () => { + const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); const response = await supertest(app) .post('/token') .type('form') .send({ client_id: 'valid-client', client_secret: 'valid-secret', + resource: 'https://api.example.com/resource', grant_type: 'authorization_code', code: 'valid_code', code_verifier: 'valid_verifier' @@ -280,6 +282,13 @@ describe('Token Handler', () => { expect(response.body.token_type).toBe('bearer'); expect(response.body.expires_in).toBe(3600); expect(response.body.refresh_token).toBe('mock_refresh_token'); + expect(mockExchangeCode).toHaveBeenCalledWith( + validClient, + 'valid_code', + undefined, // code_verifier is undefined after PKCE validation + undefined, // redirect_uri + new URL('https://api.example.com/resource') // resource parameter + ); }); it('passes through code verifier when using proxy provider', async () => { @@ -440,12 +449,14 @@ describe('Token Handler', () => { }); it('returns new tokens for valid refresh token', async () => { + const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); const response = await supertest(app) .post('/token') .type('form') .send({ client_id: 'valid-client', client_secret: 'valid-secret', + resource: 'https://api.example.com/resource', grant_type: 'refresh_token', refresh_token: 'valid_refresh_token' }); @@ -455,6 +466,12 @@ describe('Token Handler', () => { expect(response.body.token_type).toBe('bearer'); expect(response.body.expires_in).toBe(3600); expect(response.body.refresh_token).toBe('new_mock_refresh_token'); + expect(mockExchangeRefresh).toHaveBeenCalledWith( + validClient, + 'valid_refresh_token', + undefined, // scopes + new URL('https://api.example.com/resource') // resource parameter + ); }); it('respects requested scopes on refresh', async () => { diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index eadbd751..b2ab7439 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -32,11 +32,13 @@ const AuthorizationCodeGrantSchema = z.object({ code: z.string(), code_verifier: z.string(), redirect_uri: z.string().optional(), + resource: z.string().url().optional(), }); const RefreshTokenGrantSchema = z.object({ refresh_token: z.string(), scope: z.string().optional(), + resource: z.string().url().optional(), }); export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler { @@ -78,7 +80,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand const client = req.client; if (!client) { // This should never happen - console.error("Missing client information after authentication"); throw new ServerError("Internal Server Error"); } @@ -89,7 +90,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand throw new InvalidRequestError(parseResult.error.message); } - const { code, code_verifier, redirect_uri } = parseResult.data; + const { code, code_verifier, redirect_uri, resource } = parseResult.data; const skipLocalPkceValidation = provider.skipLocalPkceValidation; @@ -107,7 +108,8 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand client, code, skipLocalPkceValidation ? code_verifier : undefined, - redirect_uri + redirect_uri, + resource ? new URL(resource) : undefined ); res.status(200).json(tokens); break; @@ -119,10 +121,10 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand throw new InvalidRequestError(parseResult.error.message); } - const { refresh_token, scope } = parseResult.data; + const { refresh_token, scope, resource } = parseResult.data; const scopes = scope?.split(" "); - const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes); + const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource ? new URL(resource) : undefined); res.status(200).json(tokens); break; } @@ -140,7 +142,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error exchanging token:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index fd96055a..91f763a9 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -88,7 +88,6 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad } else if (error instanceof OAuthError) { res.status(400).json(error.toResponseObject()); } else { - console.error("Unexpected error authenticating bearer token:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/middleware/clientAuth.ts b/src/server/auth/middleware/clientAuth.ts index 76049c11..ecd9a7b6 100644 --- a/src/server/auth/middleware/clientAuth.ts +++ b/src/server/auth/middleware/clientAuth.ts @@ -64,7 +64,6 @@ export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlew const status = error instanceof ServerError ? 500 : 400; res.status(status).json(error.toResponseObject()); } else { - console.error("Unexpected error authenticating client:", error); const serverError = new ServerError("Internal Server Error"); res.status(500).json(serverError.toResponseObject()); } diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 7815b713..18beb216 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -8,6 +8,7 @@ export type AuthorizationParams = { scopes?: string[]; codeChallenge: string; redirectUri: string; + resource?: URL; }; /** @@ -40,13 +41,14 @@ export interface OAuthServerProvider { client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, - redirectUri?: string + redirectUri?: string, + resource?: URL ): Promise; /** * Exchanges a refresh token for an access token. */ - exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise; + exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; /** * Verifies an access token and returns information about it. diff --git a/src/server/auth/providers/proxyProvider.test.ts b/src/server/auth/providers/proxyProvider.test.ts index 69039c3e..4e98d0dc 100644 --- a/src/server/auth/providers/proxyProvider.test.ts +++ b/src/server/auth/providers/proxyProvider.test.ts @@ -88,6 +88,7 @@ describe("Proxy OAuth Server Provider", () => { codeChallenge: "test-challenge", state: "test-state", scopes: ["read", "write"], + resource: new URL('https://api.example.com/resource'), }, mockResponse ); @@ -100,6 +101,7 @@ describe("Proxy OAuth Server Provider", () => { expectedUrl.searchParams.set("code_challenge_method", "S256"); expectedUrl.searchParams.set("state", "test-state"); expectedUrl.searchParams.set("scope", "read write"); + expectedUrl.searchParams.set('resource', 'https://api.example.com/resource'); expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString()); }); @@ -164,6 +166,41 @@ describe("Proxy OAuth Server Provider", () => { expect(tokens).toEqual(mockTokenResponse); }); + it('includes resource parameter in authorization code exchange', async () => { + const tokens = await provider.exchangeAuthorizationCode( + validClient, + 'test-code', + 'test-verifier', + 'https://example.com/callback', + new URL('https://api.example.com/resource') + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource')) + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); + + it('handles authorization code exchange without resource parameter', async () => { + const tokens = await provider.exchangeAuthorizationCode( + validClient, + 'test-code', + 'test-verifier' + ); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const body = fetchCall[1].body as string; + expect(body).not.toContain('resource='); + expect(tokens).toEqual(mockTokenResponse); + }); + it("exchanges refresh token for new tokens", async () => { const tokens = await provider.exchangeRefreshToken( validClient, @@ -184,6 +221,26 @@ describe("Proxy OAuth Server Provider", () => { expect(tokens).toEqual(mockTokenResponse); }); + it('includes resource parameter in refresh token exchange', async () => { + const tokens = await provider.exchangeRefreshToken( + validClient, + 'test-refresh-token', + ['read', 'write'], + new URL('https://api.example.com/resource') + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource')) + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); }); describe("client registration", () => { diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index db7460e5..de74862b 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -134,6 +134,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { // Add optional standard OAuth parameters if (params.state) searchParams.set("state", params.state); if (params.scopes?.length) searchParams.set("scope", params.scopes.join(" ")); + if (params.resource) searchParams.set("resource", params.resource.href); targetUrl.search = searchParams.toString(); res.redirect(targetUrl.toString()); @@ -152,7 +153,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, - redirectUri?: string + redirectUri?: string, + resource?: URL ): Promise { const params = new URLSearchParams({ grant_type: "authorization_code", @@ -172,6 +174,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { params.append("redirect_uri", redirectUri); } + if (resource) { + params.append("resource", resource.href); + } + const response = await fetch(this._endpoints.tokenUrl, { method: "POST", headers: { @@ -192,7 +198,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { async exchangeRefreshToken( client: OAuthClientInformationFull, refreshToken: string, - scopes?: string[] + scopes?: string[], + resource?: URL ): Promise { const params = new URLSearchParams({ @@ -209,6 +216,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { params.set("scope", scopes.join(" ")); } + if (resource) { + params.set("resource", resource.href); + } + const response = await fetch(this._endpoints.tokenUrl, { method: "POST", headers: { diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index c25c2b60..0189e9ed 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -22,6 +22,12 @@ export interface AuthInfo { */ expiresAt?: number; + /** + * The RFC 8707 resource server identifier for which this token is valid. + * If set, this MUST match the MCP server's resource identifier (minus hash fragment). + */ + resource?: URL; + /** * Additional data associated with the token. * This field should be used for any additional data that needs to be attached to the auth info. diff --git a/src/server/index.test.ts b/src/server/index.test.ts index e015be94..137b8934 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -10,6 +10,7 @@ import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, CreateMessageRequestSchema, + ElicitRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, @@ -275,6 +276,318 @@ test("should respect client capabilities", async () => { await expect(server.listRoots()).rejects.toThrow(/^Client does not support/); }); +test("should respect client elicitation capabilities", async () => { + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + client.setRequestHandler(ElicitRequestSchema, (params) => ({ + action: "accept", + content: { + username: params.params.message.includes("username") ? "test-user" : undefined, + confirmed: true, + }, + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + expect(server.getClientCapabilities()).toEqual({ elicitation: {} }); + + // This should work because elicitation is supported by the client + await expect( + server.elicitInput({ + message: "Please provide your username", + requestedSchema: { + type: "object", + properties: { + username: { + type: "string", + title: "Username", + description: "Your username", + }, + confirmed: { + type: "boolean", + title: "Confirm", + description: "Please confirm", + default: false, + }, + }, + required: ["username"], + }, + }), + ).resolves.toEqual({ + action: "accept", + content: { + username: "test-user", + confirmed: true, + }, + }); + + // This should still throw because sampling is not supported by the client + await expect( + server.createMessage({ + messages: [], + maxTokens: 10, + }), + ).rejects.toThrow(/^Client does not support/); +}); + +test("should validate elicitation response against requested schema", async () => { + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, (request) => ({ + action: "accept", + content: { + name: "John Doe", + email: "john@example.com", + age: 30, + }, + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // Test with valid response + await expect( + server.elicitInput({ + message: "Please provide your information", + requestedSchema: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + }, + email: { + type: "string", + minLength: 1, + }, + age: { + type: "integer", + minimum: 0, + maximum: 150, + }, + }, + required: ["name", "email"], + }, + }), + ).resolves.toEqual({ + action: "accept", + content: { + name: "John Doe", + email: "john@example.com", + age: 30, + }, + }); +}); + +test("should reject elicitation response with invalid data", async () => { + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + // Set up client to return invalid response (missing required field, invalid age) + client.setRequestHandler(ElicitRequestSchema, (request) => ({ + action: "accept", + content: { + email: "", // Invalid - too short + age: -5, // Invalid age + }, + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // Test with invalid response + await expect( + server.elicitInput({ + message: "Please provide your information", + requestedSchema: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + }, + email: { + type: "string", + minLength: 1, + }, + age: { + type: "integer", + minimum: 0, + maximum: 150, + }, + }, + required: ["name", "email"], + }, + }), + ).rejects.toThrow(/does not match requested schema/); +}); + +test("should allow elicitation reject and cancel without validation", async () => { + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + let requestCount = 0; + client.setRequestHandler(ElicitRequestSchema, (request) => { + requestCount++; + if (requestCount === 1) { + return { action: "reject" }; + } else { + return { action: "cancel" }; + } + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const schema = { + type: "object" as const, + properties: { + name: { type: "string" as const }, + }, + required: ["name"], + }; + + // Test reject - should not validate + await expect( + server.elicitInput({ + message: "Please provide your name", + requestedSchema: schema, + }), + ).resolves.toEqual({ + action: "reject", + }); + + // Test cancel - should not validate + await expect( + server.elicitInput({ + message: "Please provide your name", + requestedSchema: schema, + }), + ).resolves.toEqual({ + action: "cancel", + }); +}); + test("should respect server notification capabilities", async () => { const server = new Server( { diff --git a/src/server/index.ts b/src/server/index.ts index caf72f9c..10ae2fad 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,9 @@ import { ClientCapabilities, CreateMessageRequest, CreateMessageResultSchema, + ElicitRequest, + ElicitResult, + ElicitResultSchema, EmptyResultSchema, Implementation, InitializedNotificationSchema, @@ -18,6 +21,8 @@ import { ListRootsRequest, ListRootsResultSchema, LoggingMessageNotification, + McpError, + ErrorCode, Notification, Request, ResourceUpdatedNotification, @@ -28,6 +33,7 @@ import { ServerResult, SUPPORTED_PROTOCOL_VERSIONS, } from "../types.js"; +import Ajv from "ajv"; export type ServerOptions = ProtocolOptions & { /** @@ -129,6 +135,14 @@ export class Server< } break; + case "elicitation/create": + if (!this._clientCapabilities?.elicitation) { + throw new Error( + `Client does not support elicitation (required for ${method})`, + ); + } + break; + case "roots/list": if (!this._clientCapabilities?.roots) { throw new Error( @@ -296,6 +310,44 @@ export class Server< ); } + async elicitInput( + params: ElicitRequest["params"], + options?: RequestOptions, + ): Promise { + const result = await this.request( + { method: "elicitation/create", params }, + ElicitResultSchema, + options, + ); + + // Validate the response content against the requested schema if action is "accept" + if (result.action === "accept" && result.content) { + try { + const ajv = new Ajv(); + + const validate = ajv.compile(params.requestedSchema); + const isValid = validate(result.content); + + if (!isValid) { + throw new McpError( + ErrorCode.InvalidParams, + `Elicitation response content does not match requested schema: ${ajv.errorsText(validate.errors)}`, + ); + } + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InternalError, + `Error validating elicitation response: ${error}`, + ); + } + } + + return result; + } + async listRoots( params?: ListRootsRequest["params"], options?: RequestOptions, diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index b0ea4d77..5b711a23 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -14,6 +14,7 @@ import { LoggingMessageNotificationSchema, Notification, TextContent, + ElicitRequestSchema, } from "../types.js"; import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; @@ -4017,3 +4018,222 @@ describe("Tool title precedence", () => { expect(result4.completion.values).toEqual(["Guest"]); }); }); + +describe("elicitInput()", () => { + + const checkAvailability = jest.fn().mockResolvedValue(false); + const findAlternatives = jest.fn().mockResolvedValue([]); + const makeBooking = jest.fn().mockResolvedValue("BOOKING-123"); + + let mcpServer: McpServer; + let client: Client; + + beforeEach(() => { + jest.clearAllMocks(); + + // Create server with restaurant booking tool + mcpServer = new McpServer({ + name: "restaurant-booking-server", + version: "1.0.0", + }); + + // Register the restaurant booking tool from README example + mcpServer.tool( + "book-restaurant", + { + restaurant: z.string(), + date: z.string(), + partySize: z.number() + }, + async ({ restaurant, date, partySize }) => { + // Check availability + const available = await checkAvailability(restaurant, date, partySize); + + if (!available) { + // Ask user if they want to try alternative dates + const result = await mcpServer.server.elicitInput({ + message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, + requestedSchema: { + type: "object", + properties: { + checkAlternatives: { + type: "boolean", + title: "Check alternative dates", + description: "Would you like me to check other dates?" + }, + flexibleDates: { + type: "string", + title: "Date flexibility", + description: "How flexible are your dates?", + enum: ["next_day", "same_week", "next_week"], + enumNames: ["Next day", "Same week", "Next week"] + } + }, + required: ["checkAlternatives"] + } + }); + + if (result.action === "accept" && result.content?.checkAlternatives) { + const alternatives = await findAlternatives( + restaurant, + date, + partySize, + result.content.flexibleDates as string + ); + return { + content: [{ + type: "text", + text: `Found these alternatives: ${alternatives.join(", ")}` + }] + }; + } + + return { + content: [{ + type: "text", + text: "No booking made. Original date not available." + }] + }; + } + + await makeBooking(restaurant, date, partySize); + return { + content: [{ + type: "text", + text: `Booked table for ${partySize} at ${restaurant} on ${date}` + }] + }; + } + ); + + // Create client with elicitation capability + client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: { + elicitation: {}, + }, + } + ); + }); + + test("should successfully elicit additional information", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + findAlternatives.mockResolvedValue(["2024-12-26", "2024-12-27", "2024-12-28"]); + + // Set up client to accept alternative date checking + client.setRequestHandler(ElicitRequestSchema, async (request) => { + expect(request.params.message).toContain("No tables available at ABC Restaurant on 2024-12-25"); + return { + action: "accept", + content: { + checkAlternatives: true, + flexibleDates: "same_week" + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2, "same_week"); + expect(result.content).toEqual([{ + type: "text", + text: "Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28" + }]); + }); + + test("should handle user declining to elicitation request", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + + // Set up client to reject alternative date checking + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: "accept", + content: { + checkAlternatives: false + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([{ + type: "text", + text: "No booking made. Original date not available." + }]); + }); + + test("should handle user cancelling the elicitation", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + + // Set up client to cancel the elicitation + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: "cancel" + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([{ + type: "text", + text: "No booking made. Original date not available." + }]); + }); +}); diff --git a/src/shared/auth-utils.test.ts b/src/shared/auth-utils.test.ts new file mode 100644 index 00000000..c1fa7bdf --- /dev/null +++ b/src/shared/auth-utils.test.ts @@ -0,0 +1,61 @@ +import { resourceUrlFromServerUrl, checkResourceAllowed } from './auth-utils.js'; + +describe('auth-utils', () => { + describe('resourceUrlFromServerUrl', () => { + it('should remove fragments', () => { + expect(resourceUrlFromServerUrl(new URL('https://example.com/path#fragment')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com#fragment')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1#fragment')).href).toBe('https://example.com/path?query=1'); + }); + + it('should return URL unchanged if no fragment', () => { + expect(resourceUrlFromServerUrl(new URL('https://example.com')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1')).href).toBe('https://example.com/path?query=1'); + }); + + it('should keep everything else unchanged', () => { + // Case sensitivity preserved + expect(resourceUrlFromServerUrl(new URL('https://EXAMPLE.COM/PATH')).href).toBe('https://example.com/PATH'); + // Ports preserved + expect(resourceUrlFromServerUrl(new URL('https://example.com:443/path')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com:8080/path')).href).toBe('https://example.com:8080/path'); + // Query parameters preserved + expect(resourceUrlFromServerUrl(new URL('https://example.com?foo=bar&baz=qux')).href).toBe('https://example.com/?foo=bar&baz=qux'); + // Trailing slashes preserved + expect(resourceUrlFromServerUrl(new URL('https://example.com/')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path/')).href).toBe('https://example.com/path/'); + }); + }); + + describe('resourceMatches', () => { + it('should match identical URLs', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.com/path' })).toBe(true); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/' })).toBe(true); + }); + + it('should not match URLs with different paths', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/path1', configuredResource: 'https://example.com/path2' })).toBe(false); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/path' })).toBe(false); + }); + + it('should not match URLs with different domains', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.org/path' })).toBe(false); + }); + + it('should not match URLs with different ports', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com:8080/path', configuredResource: 'https://example.com/path' })).toBe(false); + }); + + it('should not match URLs where one path is a sub-path of another', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/mcpxxxx', configuredResource: 'https://example.com/mcp' })).toBe(false); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/subfolder' })).toBe(false); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/api/v1', configuredResource: 'https://example.com/api' })).toBe(true); + }); + + it('should handle trailing slashes vs no trailing slashes', () => { + expect(checkResourceAllowed({ requestedResource: 'https://example.com/mcp/', configuredResource: 'https://example.com/mcp' })).toBe(true); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/' })).toBe(false); + }); + }); +}); diff --git a/src/shared/auth-utils.ts b/src/shared/auth-utils.ts new file mode 100644 index 00000000..97a77c01 --- /dev/null +++ b/src/shared/auth-utils.ts @@ -0,0 +1,54 @@ +/** + * Utilities for handling OAuth resource URIs. + */ + +/** + * Converts a server URL to a resource URL by removing the fragment. + * RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component". + * Keeps everything else unchanged (scheme, domain, port, path, query). + */ +export function resourceUrlFromServerUrl(url: URL | string ): URL { + const resourceURL = typeof url === "string" ? new URL(url) : new URL(url.href); + resourceURL.hash = ''; // Remove fragment + return resourceURL; +} + +/** + * Checks if a requested resource URL matches a configured resource URL. + * A requested resource matches if it has the same scheme, domain, port, + * and its path starts with the configured resource's path. + * + * @param requestedResource The resource URL being requested + * @param configuredResource The resource URL that has been configured + * @returns true if the requested resource matches the configured resource, false otherwise + */ + export function checkResourceAllowed( + { requestedResource, configuredResource }: { + requestedResource: URL | string; + configuredResource: URL | string + } + ): boolean { + const requested = typeof requestedResource === "string" ? new URL(requestedResource) : new URL(requestedResource.href); + const configured = typeof configuredResource === "string" ? new URL(configuredResource) : new URL(configuredResource.href); + + // Compare the origin (scheme, domain, and port) + if (requested.origin !== configured.origin) { + return false; + } + + // Handle cases like requested=/foo and configured=/foo/ + if (requested.pathname.length < configured.pathname.length) { + return false + } + + // Check if the requested path starts with the configured path + // Ensure both paths end with / for proper comparison + // This ensures that if we have paths like "/api" and "/api/users", + // we properly detect that "/api/users" is a subpath of "/api" + // By adding a trailing slash if missing, we avoid false positives + // where paths like "/api123" would incorrectly match "/api" + const requestedPath = requested.pathname.endsWith('/') ? requested.pathname : requested.pathname + '/'; + const configuredPath = configured.pathname.endsWith('/') ? configured.pathname : configured.pathname + '/'; + + return requestedPath.startsWith(configuredPath); + } diff --git a/src/shared/metadataUtils.ts b/src/shared/metadataUtils.ts index 410827a5..0119a669 100644 --- a/src/shared/metadataUtils.ts +++ b/src/shared/metadataUtils.ts @@ -15,7 +15,7 @@ export function getDisplayName(metadata: BaseMetadata): string { if (metadata.title !== undefined && metadata.title !== '') { return metadata.title; } - + // Then check for annotations.title (only present in Tool objects) if ('annotations' in metadata) { const metadataWithAnnotations = metadata as BaseMetadata & { annotations?: { title?: string } }; @@ -23,14 +23,7 @@ export function getDisplayName(metadata: BaseMetadata): string { return metadataWithAnnotations.annotations.title; } } - + // Finally fall back to name return metadata.name; } - -/** - * Checks if an object has a custom title different from its name. - */ -export function hasCustomTitle(metadata: BaseMetadata): boolean { - return metadata.title !== undefined && metadata.title !== metadata.name; -} \ No newline at end of file diff --git a/src/shared/protocol.test.ts b/src/shared/protocol.test.ts index 05bc8f3b..5c6b72d2 100644 --- a/src/shared/protocol.test.ts +++ b/src/shared/protocol.test.ts @@ -465,6 +465,7 @@ describe("mergeCapabilities", () => { experimental: { feature: true, }, + elicitation: {}, roots: { newProp: true, }, @@ -473,6 +474,7 @@ describe("mergeCapabilities", () => { const merged = mergeCapabilities(base, additional); expect(merged).toEqual({ sampling: {}, + elicitation: {}, roots: { listChanged: true, newProp: true, diff --git a/src/types.test.ts b/src/types.test.ts index bc109110..0aee62a9 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -12,7 +12,7 @@ describe("Types", () => { test("should have correct latest protocol version", () => { expect(LATEST_PROTOCOL_VERSION).toBeDefined(); - expect(LATEST_PROTOCOL_VERSION).toBe("2025-03-26"); + expect(LATEST_PROTOCOL_VERSION).toBe("2025-06-18"); }); test("should have correct supported protocol versions", () => { expect(SUPPORTED_PROTOCOL_VERSIONS).toBeDefined(); @@ -20,6 +20,7 @@ describe("Types", () => { expect(SUPPORTED_PROTOCOL_VERSIONS).toContain(LATEST_PROTOCOL_VERSION); expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2024-11-05"); expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2024-10-07"); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2025-03-26"); }); describe("ResourceLink", () => { diff --git a/src/types.ts b/src/types.ts index 1c248d77..3606a6be 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,10 @@ import { z, ZodTypeAny } from "zod"; -export const LATEST_PROTOCOL_VERSION = "2025-03-26"; +export const LATEST_PROTOCOL_VERSION = "2025-06-18"; export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"; export const SUPPORTED_PROTOCOL_VERSIONS = [ LATEST_PROTOCOL_VERSION, + "2025-03-26", "2024-11-05", "2024-10-07", ]; @@ -44,8 +45,9 @@ export const RequestSchema = z.object({ const BaseNotificationParamsSchema = z .object({ /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -58,8 +60,9 @@ export const NotificationSchema = z.object({ export const ResultSchema = z .object({ /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -236,6 +239,10 @@ export const ClientCapabilitiesSchema = z * Present if the client supports sampling from an LLM. */ sampling: z.optional(z.object({}).passthrough()), + /** + * Present if the client supports eliciting user input. + */ + elicitation: z.optional(z.object({}).passthrough()), /** * Present if the client supports listing roots. */ @@ -436,7 +443,8 @@ export const ResourceContentsSchema = z */ mimeType: z.optional(z.string()), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. */ _meta: z.optional(z.object({}).passthrough()), }) @@ -478,8 +486,9 @@ export const ResourceSchema = BaseMetadataSchema.extend({ mimeType: z.optional(z.string()), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }); @@ -505,8 +514,9 @@ export const ResourceTemplateSchema = BaseMetadataSchema.extend({ mimeType: z.optional(z.string()), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }); @@ -642,7 +652,8 @@ export const PromptSchema = BaseMetadataSchema.extend({ */ arguments: z.optional(z.array(PromptArgumentSchema)), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. */ _meta: z.optional(z.object({}).passthrough()), }); @@ -690,8 +701,9 @@ export const TextContentSchema = z text: z.string(), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -712,8 +724,9 @@ export const ImageContentSchema = z mimeType: z.string(), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -734,8 +747,9 @@ export const AudioContentSchema = z mimeType: z.string(), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -748,7 +762,8 @@ export const EmbeddedResourceSchema = z type: z.literal("resource"), resource: z.union([TextResourceContentsSchema, BlobResourceContentsSchema]), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. */ _meta: z.optional(z.object({}).passthrough()), }) @@ -895,8 +910,9 @@ export const ToolSchema = BaseMetadataSchema.extend({ annotations: z.optional(ToolAnnotationsSchema), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }); @@ -1127,6 +1143,107 @@ export const CreateMessageResultSchema = ResultSchema.extend({ ]), }); +/* Elicitation */ +/** + * Primitive schema definition for boolean fields. + */ +export const BooleanSchemaSchema = z + .object({ + type: z.literal("boolean"), + title: z.optional(z.string()), + description: z.optional(z.string()), + default: z.optional(z.boolean()), + }) + .passthrough(); + +/** + * Primitive schema definition for string fields. + */ +export const StringSchemaSchema = z + .object({ + type: z.literal("string"), + title: z.optional(z.string()), + description: z.optional(z.string()), + minLength: z.optional(z.number()), + maxLength: z.optional(z.number()), + format: z.optional(z.enum(["email", "uri", "date", "date-time"])), + }) + .passthrough(); + +/** + * Primitive schema definition for number fields. + */ +export const NumberSchemaSchema = z + .object({ + type: z.enum(["number", "integer"]), + title: z.optional(z.string()), + description: z.optional(z.string()), + minimum: z.optional(z.number()), + maximum: z.optional(z.number()), + }) + .passthrough(); + +/** + * Primitive schema definition for enum fields. + */ +export const EnumSchemaSchema = z + .object({ + type: z.literal("string"), + title: z.optional(z.string()), + description: z.optional(z.string()), + enum: z.array(z.string()), + enumNames: z.optional(z.array(z.string())), + }) + .passthrough(); + +/** + * Union of all primitive schema definitions. + */ +export const PrimitiveSchemaDefinitionSchema = z.union([ + BooleanSchemaSchema, + StringSchemaSchema, + NumberSchemaSchema, + EnumSchemaSchema, +]); + +/** + * A request from the server to elicit user input via the client. + * The client should present the message and form fields to the user. + */ +export const ElicitRequestSchema = RequestSchema.extend({ + method: z.literal("elicitation/create"), + params: BaseRequestParamsSchema.extend({ + /** + * The message to present to the user. + */ + message: z.string(), + /** + * The schema for the requested user input. + */ + requestedSchema: z + .object({ + type: z.literal("object"), + properties: z.record(z.string(), PrimitiveSchemaDefinitionSchema), + required: z.optional(z.array(z.string())), + }) + .passthrough(), + }), +}); + +/** + * The client's response to an elicitation/create request from the server. + */ +export const ElicitResultSchema = ResultSchema.extend({ + /** + * The user's response action. + */ + action: z.enum(["accept", "reject", "cancel"]), + /** + * The collected user input content (only present if action is "accept"). + */ + content: z.optional(z.record(z.string(), z.unknown())), +}); + /* Autocomplete */ /** * A reference to a resource or resource template definition. @@ -1230,8 +1347,9 @@ export const RootSchema = z name: z.optional(z.string()), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -1284,6 +1402,7 @@ export const ClientNotificationSchema = z.union([ export const ClientResultSchema = z.union([ EmptyResultSchema, CreateMessageResultSchema, + ElicitResultSchema, ListRootsResultSchema, ]); @@ -1291,6 +1410,7 @@ export const ClientResultSchema = z.union([ export const ServerRequestSchema = z.union([ PingRequestSchema, CreateMessageRequestSchema, + ElicitRequestSchema, ListRootsRequestSchema, ]); @@ -1438,6 +1558,15 @@ export type SamplingMessage = Infer; export type CreateMessageRequest = Infer; export type CreateMessageResult = Infer; +/* Elicitation */ +export type BooleanSchema = Infer; +export type StringSchema = Infer; +export type NumberSchema = Infer; +export type EnumSchema = Infer; +export type PrimitiveSchemaDefinition = Infer; +export type ElicitRequest = Infer; +export type ElicitResult = Infer; + /* Autocomplete */ export type ResourceTemplateReference = Infer; /**