From c342dacb5ed55a0366fd2512bb642d1ceaa2ed87 Mon Sep 17 00:00:00 2001 From: Vinicius Costa Date: Mon, 22 Sep 2025 09:31:40 -0300 Subject: [PATCH 1/4] Updates the sampling code example in the README (#958) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cee7eb855..43b62ac60 100644 --- a/README.md +++ b/README.md @@ -437,7 +437,7 @@ mcpServer.registerTool( async function main() { const transport = new StdioServerTransport(); await mcpServer.connect(transport); - console.log("MCP server is running..."); + console.error("MCP server is running..."); } main().catch((error) => { From 9841a6cf2959d0f361ac5eee6e1a2e0f3515b943 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Mon, 22 Sep 2025 07:29:39 -0700 Subject: [PATCH 2/4] Use redirect Uri passed in in `demoInMemoryOAuthProvider` (#931) --- .../server/demoInMemoryOAuthProvider.test.ts | 298 ++++++++++++++++++ .../server/demoInMemoryOAuthProvider.ts | 6 +- 2 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 src/examples/server/demoInMemoryOAuthProvider.test.ts diff --git a/src/examples/server/demoInMemoryOAuthProvider.test.ts b/src/examples/server/demoInMemoryOAuthProvider.test.ts new file mode 100644 index 000000000..cb99e1ffb --- /dev/null +++ b/src/examples/server/demoInMemoryOAuthProvider.test.ts @@ -0,0 +1,298 @@ +import { Response } from 'express'; +import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js'; +import { AuthorizationParams } from '../../server/auth/provider.js'; +import { OAuthClientInformationFull } from '../../shared/auth.js'; +import { InvalidRequestError } from '../../server/auth/errors.js'; + +describe('DemoInMemoryAuthProvider', () => { + let provider: DemoInMemoryAuthProvider; + let mockResponse: Response & { getRedirectUrl: () => string }; + + const createMockResponse = (): Response & { getRedirectUrl: () => string } => { + let capturedRedirectUrl: string | undefined; + + const mockRedirect = jest.fn().mockImplementation((url: string | number, status?: number) => { + if (typeof url === 'string') { + capturedRedirectUrl = url; + } else if (typeof status === 'string') { + capturedRedirectUrl = status; + } + return mockResponse; + }); + + const mockResponse = { + redirect: mockRedirect, + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + getRedirectUrl: () => { + if (capturedRedirectUrl === undefined) { + throw new Error('No redirect URL was captured. Ensure redirect() was called first.'); + } + return capturedRedirectUrl; + }, + } as unknown as Response & { getRedirectUrl: () => string }; + + return mockResponse; + }; + + beforeEach(() => { + provider = new DemoInMemoryAuthProvider(); + mockResponse = createMockResponse(); + }); + + describe('authorize', () => { + const validClient: OAuthClientInformationFull = { + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uris: [ + 'https://example.com/callback', + 'https://example.com/callback2' + ], + scope: 'test-scope' + }; + + it('should redirect to the requested redirect_uri when valid', async () => { + const params: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'test-state', + codeChallenge: 'test-challenge', + scopes: ['test-scope'] + }; + + await provider.authorize(validClient, params, mockResponse); + + expect(mockResponse.redirect).toHaveBeenCalled(); + expect(mockResponse.getRedirectUrl()).toBeDefined(); + + const url = new URL(mockResponse.getRedirectUrl()); + expect(url.origin + url.pathname).toBe('https://example.com/callback'); + expect(url.searchParams.get('state')).toBe('test-state'); + expect(url.searchParams.has('code')).toBe(true); + }); + + it('should throw InvalidRequestError for unregistered redirect_uri', async () => { + const params: AuthorizationParams = { + redirectUri: 'https://evil.com/callback', + state: 'test-state', + codeChallenge: 'test-challenge', + scopes: ['test-scope'] + }; + + await expect( + provider.authorize(validClient, params, mockResponse) + ).rejects.toThrow(InvalidRequestError); + + await expect( + provider.authorize(validClient, params, mockResponse) + ).rejects.toThrow('Unregistered redirect_uri'); + + expect(mockResponse.redirect).not.toHaveBeenCalled(); + }); + + it('should generate unique authorization codes for multiple requests', async () => { + const params1: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'state-1', + codeChallenge: 'challenge-1', + scopes: ['test-scope'] + }; + + const params2: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'state-2', + codeChallenge: 'challenge-2', + scopes: ['test-scope'] + }; + + await provider.authorize(validClient, params1, mockResponse); + const firstRedirectUrl = mockResponse.getRedirectUrl(); + const firstCode = new URL(firstRedirectUrl).searchParams.get('code'); + + // Reset the mock for the second call + mockResponse = createMockResponse(); + await provider.authorize(validClient, params2, mockResponse); + const secondRedirectUrl = mockResponse.getRedirectUrl(); + const secondCode = new URL(secondRedirectUrl).searchParams.get('code'); + + expect(firstCode).toBeDefined(); + expect(secondCode).toBeDefined(); + expect(firstCode).not.toBe(secondCode); + }); + + it('should handle params without state', async () => { + const params: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-challenge', + scopes: ['test-scope'] + }; + + await provider.authorize(validClient, params, mockResponse); + + expect(mockResponse.redirect).toHaveBeenCalled(); + expect(mockResponse.getRedirectUrl()).toBeDefined(); + + const url = new URL(mockResponse.getRedirectUrl()); + expect(url.searchParams.has('state')).toBe(false); + expect(url.searchParams.has('code')).toBe(true); + }); + }); + + describe('challengeForAuthorizationCode', () => { + const validClient: OAuthClientInformationFull = { + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uris: ['https://example.com/callback'], + scope: 'test-scope' + }; + + it('should return the code challenge for a valid authorization code', async () => { + const params: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'test-state', + codeChallenge: 'test-challenge-value', + scopes: ['test-scope'] + }; + + await provider.authorize(validClient, params, mockResponse); + const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; + + const challenge = await provider.challengeForAuthorizationCode(validClient, code); + expect(challenge).toBe('test-challenge-value'); + }); + + it('should throw error for invalid authorization code', async () => { + await expect( + provider.challengeForAuthorizationCode(validClient, 'invalid-code') + ).rejects.toThrow('Invalid authorization code'); + }); + }); + + describe('exchangeAuthorizationCode', () => { + const validClient: OAuthClientInformationFull = { + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uris: ['https://example.com/callback'], + scope: 'test-scope' + }; + + it('should exchange valid authorization code for tokens', async () => { + const params: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'test-state', + codeChallenge: 'test-challenge', + scopes: ['test-scope', 'other-scope'] + }; + + await provider.authorize(validClient, params, mockResponse); + const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; + + const tokens = await provider.exchangeAuthorizationCode(validClient, code); + + expect(tokens).toEqual({ + access_token: expect.any(String), + token_type: 'bearer', + expires_in: 3600, + scope: 'test-scope other-scope' + }); + }); + + it('should throw error for invalid authorization code', async () => { + await expect( + provider.exchangeAuthorizationCode(validClient, 'invalid-code') + ).rejects.toThrow('Invalid authorization code'); + }); + + it('should throw error when client_id does not match', async () => { + const params: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'test-state', + codeChallenge: 'test-challenge', + scopes: ['test-scope'] + }; + + await provider.authorize(validClient, params, mockResponse); + const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; + + const differentClient: OAuthClientInformationFull = { + client_id: 'different-client', + client_secret: 'different-secret', + redirect_uris: ['https://example.com/callback'], + scope: 'test-scope' + }; + + await expect( + provider.exchangeAuthorizationCode(differentClient, code) + ).rejects.toThrow('Authorization code was not issued to this client'); + }); + + it('should delete authorization code after successful exchange', async () => { + const params: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'test-state', + codeChallenge: 'test-challenge', + scopes: ['test-scope'] + }; + + await provider.authorize(validClient, params, mockResponse); + const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; + + // First exchange should succeed + await provider.exchangeAuthorizationCode(validClient, code); + + // Second exchange should fail + await expect( + provider.exchangeAuthorizationCode(validClient, code) + ).rejects.toThrow('Invalid authorization code'); + }); + + it('should validate resource when validateResource is provided', async () => { + const validateResource = jest.fn().mockReturnValue(false); + const strictProvider = new DemoInMemoryAuthProvider(validateResource); + + const params: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'test-state', + codeChallenge: 'test-challenge', + scopes: ['test-scope'], + resource: new URL('https://invalid-resource.com') + }; + + await strictProvider.authorize(validClient, params, mockResponse); + const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; + + await expect( + strictProvider.exchangeAuthorizationCode(validClient, code) + ).rejects.toThrow('Invalid resource: https://invalid-resource.com/'); + + expect(validateResource).toHaveBeenCalledWith(params.resource); + }); + }); + + describe('DemoInMemoryClientsStore', () => { + let store: DemoInMemoryClientsStore; + + beforeEach(() => { + store = new DemoInMemoryClientsStore(); + }); + + it('should register and retrieve client', async () => { + const client: OAuthClientInformationFull = { + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uris: ['https://example.com/callback'], + scope: 'test-scope' + }; + + await store.registerClient(client); + const retrieved = await store.getClient('test-client'); + + expect(retrieved).toEqual(client); + }); + + it('should return undefined for non-existent client', async () => { + const retrieved = await store.getClient('non-existent'); + expect(retrieved).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index c83748d35..995fa98b4 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -6,6 +6,7 @@ import express, { Request, Response } from "express"; import { AuthInfo } from '../../server/auth/types.js'; import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js'; import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js'; +import { InvalidRequestError } from '../../server/auth/errors.js'; export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { @@ -57,7 +58,10 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { params }); - const targetUrl = new URL(client.redirect_uris[0]); + if (!client.redirect_uris.includes(params.redirectUri)) { + throw new InvalidRequestError("Unregistered redirect_uri"); + } + const targetUrl = new URL(params.redirectUri); targetUrl.search = searchParams.toString(); res.redirect(targetUrl.toString()); } From 1d475bb3f75674a46d81dba881ea743a763cbc12 Mon Sep 17 00:00:00 2001 From: "Blust.AI" <159488814+blustAI@users.noreply.github.com> Date: Mon, 22 Sep 2025 07:32:29 -0700 Subject: [PATCH 3/4] fix(auth-router): correct Protected Resource Metadata for pathful RS and add explicit resourceServerUrl (RFC 9728) (#858) Co-authored-by: Eugene --- src/server/auth/router.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index a06bf73a1..727699e0c 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -41,6 +41,12 @@ export type AuthRouterOptions = { */ resourceName?: string; + /** + * The URL of the protected resource (RS) whose metadata we advertise. + * If not provided, falls back to `baseUrl` and then to `issuerUrl` (AS=RS). + */ + resourceServerUrl?: URL; + // Individual options per route authorizationOptions?: Omit; clientRegistrationOptions?: Omit; @@ -130,8 +136,8 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { router.use(mcpAuthMetadataRouter({ oauthMetadata, - // This router is used for AS+RS combo's, so the issuer is also the resource server - resourceServerUrl: new URL(oauthMetadata.issuer), + // Prefer explicit RS; otherwise fall back to AS baseUrl, then to issuer (back-compat) + resourceServerUrl: options.resourceServerUrl ?? options.baseUrl ?? new URL(oauthMetadata.issuer), serviceDocumentationUrl: options.serviceDocumentationUrl, scopesSupported: options.scopesSupported, resourceName: options.resourceName @@ -185,7 +191,7 @@ export type AuthMetadataOptions = { resourceName?: string; } -export function mcpAuthMetadataRouter(options: AuthMetadataOptions) { +export function mcpAuthMetadataRouter(options: AuthMetadataOptions): express.Router { checkIssuerUrl(new URL(options.oauthMetadata.issuer)); const router = express.Router(); @@ -202,9 +208,11 @@ export function mcpAuthMetadataRouter(options: AuthMetadataOptions) { resource_documentation: options.serviceDocumentationUrl?.href, }; - router.use("/.well-known/oauth-protected-resource", metadataHandler(protectedResourceMetadata)); + // Serve PRM at the path-specific URL per RFC 9728 + const rsPath = new URL(options.resourceServerUrl.href).pathname; + router.use(`/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`, metadataHandler(protectedResourceMetadata)); - // Always add this for backwards compatibility + // Always add this for OAuth Authorization Server metadata per RFC 8414 router.use("/.well-known/oauth-authorization-server", metadataHandler(options.oauthMetadata)); return router; @@ -219,8 +227,10 @@ export function mcpAuthMetadataRouter(options: AuthMetadataOptions) { * * @example * getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp')) - * // Returns: 'https://api.example.com/.well-known/oauth-protected-resource' + * // Returns: 'https://api.example.com/.well-known/oauth-protected-resource/mcp' */ export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string { - return new URL('/.well-known/oauth-protected-resource', serverUrl).href; + const u = new URL(serverUrl.href); + const rsPath = u.pathname && u.pathname !== '/' ? u.pathname : ''; + return new URL(`/.well-known/oauth-protected-resource${rsPath}`, u).href; } From cdae862254f57a4d3b27f86989f8ac781e4e7cb6 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Tue, 23 Sep 2025 08:30:27 -0600 Subject: [PATCH 4/4] docs: Update README with demo server instructions --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 43b62ac60..289d9e4c6 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,9 @@ import { z } from "zod"; const server = new McpServer({ name: "demo-server", version: "1.0.0" +}, { + // use instructions to describe the use cases your server is intended to handle + instructions: "This is a demo server" }); // Add an addition tool