((resolve, reject) => {
+ const server = createServer((req, res) => {
+ // Ignore favicon requests
+ if (req.url === '/favicon.ico') {
+ res.writeHead(404);
+ res.end();
+ return;
+ }
+
+ console.log(`📥 Received callback: ${req.url}`);
+ const parsedUrl = new URL(req.url || '', 'http://localhost');
+ const code = parsedUrl.searchParams.get('code');
+ const error = parsedUrl.searchParams.get('error');
+
+ if (code) {
+ console.log(`✅ Authorization code received: ${code?.substring(0, 10)}...`);
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(`
+
+
+ Authorization Successful!
+ You can close this window and return to the terminal.
+
+
+
+ `);
+
+ resolve(code);
+ setTimeout(() => server.close(), 3000);
+ } else if (error) {
+ console.log(`❌ Authorization error: ${error}`);
+ res.writeHead(400, { 'Content-Type': 'text/html' });
+ res.end(`
+
+
+ Authorization Failed
+ Error: ${error}
+
+
+ `);
+ reject(new Error(`OAuth authorization failed: ${error}`));
+ } else {
+ console.log(`❌ No authorization code or error in callback`);
+ res.writeHead(400);
+ res.end('Bad request');
+ reject(new Error('No authorization code provided'));
+ }
+ });
+
+ server.listen(CALLBACK_PORT, () => {
+ console.log(`OAuth callback server started on http://localhost:${CALLBACK_PORT}`);
+ });
+ });
+ }
+
+ private async attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise {
+ console.log('🚢 Creating transport with OAuth provider...');
+ const baseUrl = new URL(this.serverUrl);
+ const transport = new StreamableHTTPClientTransport(baseUrl, {
+ authProvider: oauthProvider
+ });
+ console.log('🚢 Transport created');
+
+ try {
+ console.log('🔌 Attempting connection (this will trigger OAuth redirect)...');
+ await this.client!.connect(transport);
+ console.log('✅ Connected successfully');
+ } catch (error) {
+ if (error instanceof UnauthorizedError) {
+ console.log('🔐 OAuth required - waiting for authorization...');
+ const callbackPromise = this.waitForOAuthCallback();
+ const authCode = await callbackPromise;
+ await transport.finishAuth(authCode);
+ console.log('🔐 Authorization code received:', authCode);
+ console.log('🔌 Reconnecting with authenticated transport...');
+ await this.attemptConnection(oauthProvider);
+ } else {
+ console.error('❌ Connection failed with non-auth error:', error);
+ throw error;
+ }
+ }
+ }
+
+ /**
+ * Establishes connection to the MCP server with OAuth authentication
+ */
+ async connect(): Promise {
+ console.log(`🔗 Attempting to connect to ${this.serverUrl}...`);
+
+ const clientMetadata: OAuthClientMetadata = {
+ client_name: 'Simple OAuth MCP Client',
+ redirect_uris: [CALLBACK_URL],
+ grant_types: ['authorization_code', 'refresh_token'],
+ response_types: ['code'],
+ token_endpoint_auth_method: 'client_secret_post',
+ scope: 'mcp:tools'
+ };
+
+ console.log('🔐 Creating OAuth provider...');
+ const oauthProvider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, (redirectUrl: URL) => {
+ console.log(`📌 OAuth redirect handler called - opening browser`);
+ console.log(`Opening browser to: ${redirectUrl.toString()}`);
+ this.openBrowser(redirectUrl.toString());
+ });
+ console.log('🔐 OAuth provider created');
+
+ console.log('👤 Creating MCP client...');
+ this.client = new Client(
+ {
+ name: 'simple-oauth-client',
+ version: '1.0.0'
+ },
+ { capabilities: {} }
+ );
+ console.log('👤 Client created');
+
+ console.log('🔐 Starting OAuth flow...');
+
+ await this.attemptConnection(oauthProvider);
+
+ // Start interactive loop
+ await this.interactiveLoop();
+ }
+
+ /**
+ * Main interactive loop for user commands
+ */
+ async interactiveLoop(): Promise {
+ console.log('\n🎯 Interactive MCP Client with OAuth');
+ console.log('Commands:');
+ console.log(' list - List available tools');
+ console.log(' call [args] - Call a tool');
+ console.log(' quit - Exit the client');
+ console.log();
+
+ while (true) {
+ try {
+ const command = await this.question('mcp> ');
+
+ if (!command.trim()) {
+ continue;
+ }
+
+ if (command === 'quit') {
+ console.log('\n👋 Goodbye!');
+ this.close();
+ process.exit(0);
+ } else if (command === 'list') {
+ await this.listTools();
+ } else if (command.startsWith('call ')) {
+ await this.handleCallTool(command);
+ } else {
+ console.log("❌ Unknown command. Try 'list', 'call ', or 'quit'");
+ }
+ } catch (error) {
+ if (error instanceof Error && error.message === 'SIGINT') {
+ console.log('\n\n👋 Goodbye!');
+ break;
+ }
+ console.error('❌ Error:', error);
+ }
+ }
+ }
+
+ private async listTools(): Promise {
+ if (!this.client) {
+ console.log('❌ Not connected to server');
+ return;
+ }
+
+ try {
+ const request: ListToolsRequest = {
+ method: 'tools/list',
+ params: {}
+ };
+
+ const result = await this.client.request(request, ListToolsResultSchema);
+
+ if (result.tools && result.tools.length > 0) {
+ console.log('\n📋 Available tools:');
+ result.tools.forEach((tool, index) => {
+ console.log(`${index + 1}. ${tool.name}`);
+ if (tool.description) {
+ console.log(` Description: ${tool.description}`);
+ }
+ console.log();
+ });
+ } else {
+ console.log('No tools available');
+ }
+ } catch (error) {
+ console.error('❌ Failed to list tools:', error);
+ }
+ }
+
+ private async handleCallTool(command: string): Promise {
+ const parts = command.split(/\s+/);
+ const toolName = parts[1];
+
+ if (!toolName) {
+ console.log('❌ Please specify a tool name');
+ return;
+ }
+
+ // Parse arguments (simple JSON-like format)
+ let toolArgs: Record = {};
+ if (parts.length > 2) {
+ const argsString = parts.slice(2).join(' ');
+ try {
+ toolArgs = JSON.parse(argsString);
+ } catch {
+ console.log('❌ Invalid arguments format (expected JSON)');
+ return;
+ }
+ }
+
+ await this.callTool(toolName, toolArgs);
+ }
+
+ private async callTool(toolName: string, toolArgs: Record): Promise {
+ if (!this.client) {
+ console.log('❌ Not connected to server');
+ return;
+ }
+
+ try {
+ const request: CallToolRequest = {
+ method: 'tools/call',
+ params: {
+ name: toolName,
+ arguments: toolArgs
+ }
+ };
+
+ const result = await this.client.request(request, CallToolResultSchema);
+
+ console.log(`\n🔧 Tool '${toolName}' result:`);
+ if (result.content) {
+ result.content.forEach(content => {
+ if (content.type === 'text') {
+ console.log(content.text);
+ } else {
+ console.log(content);
+ }
+ });
+ } else {
+ console.log(result);
+ }
+ } catch (error) {
+ console.error(`❌ Failed to call tool '${toolName}':`, error);
+ }
+ }
+
+ close(): void {
+ this.rl.close();
+ if (this.client) {
+ // Note: Client doesn't have a close method in the current implementation
+ // This would typically close the transport connection
+ }
+ }
+}
+
+/**
+ * Main entry point
+ */
+async function main(): Promise {
+ const serverUrl = process.env.MCP_SERVER_URL || DEFAULT_SERVER_URL;
+
+ console.log('🚀 Simple MCP OAuth Client');
+ console.log(`Connecting to: ${serverUrl}`);
+ console.log();
+
+ const client = new InteractiveOAuthClient(serverUrl);
+
+ // Handle graceful shutdown
+ process.on('SIGINT', () => {
+ console.log('\n\n👋 Goodbye!');
+ client.close();
+ process.exit(0);
+ });
+
+ try {
+ await client.connect();
+ } catch (error) {
+ console.error('Failed to start client:', error);
+ process.exit(1);
+ } finally {
+ client.close();
+ }
+}
+
+// Run if this file is executed directly
+main().catch(error => {
+ console.error('Unhandled error:', error);
+ process.exit(1);
+});
diff --git a/typescript-sdk/src/examples/client/simpleStreamableHttp.ts b/typescript-sdk/src/examples/client/simpleStreamableHttp.ts
new file mode 100644
index 0000000..bf58a7a
--- /dev/null
+++ b/typescript-sdk/src/examples/client/simpleStreamableHttp.ts
@@ -0,0 +1,837 @@
+import { Client } from '../../client/index.js';
+import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js';
+import { createInterface } from 'node:readline';
+import {
+ ListToolsRequest,
+ ListToolsResultSchema,
+ CallToolRequest,
+ CallToolResultSchema,
+ ListPromptsRequest,
+ ListPromptsResultSchema,
+ GetPromptRequest,
+ GetPromptResultSchema,
+ ListResourcesRequest,
+ ListResourcesResultSchema,
+ LoggingMessageNotificationSchema,
+ ResourceListChangedNotificationSchema,
+ ElicitRequestSchema,
+ ResourceLink,
+ ReadResourceRequest,
+ ReadResourceResultSchema
+} from '../../types.js';
+import { getDisplayName } from '../../shared/metadataUtils.js';
+import { Ajv } from 'ajv';
+
+// Create readline interface for user input
+const readline = createInterface({
+ input: process.stdin,
+ output: process.stdout
+});
+
+// Track received notifications for debugging resumability
+let notificationCount = 0;
+
+// Global client and transport for interactive commands
+let client: Client | null = null;
+let transport: StreamableHTTPClientTransport | null = null;
+let serverUrl = 'http://localhost:3000/mcp';
+let notificationsToolLastEventId: string | undefined = undefined;
+let sessionId: string | undefined = undefined;
+
+async function main(): Promise {
+ console.log('MCP Interactive Client');
+ console.log('=====================');
+
+ // Connect to server immediately with default settings
+ await connect();
+
+ // Print help and start the command loop
+ printHelp();
+ commandLoop();
+}
+
+function printHelp(): void {
+ console.log('\nAvailable commands:');
+ console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)');
+ console.log(' disconnect - Disconnect from server');
+ console.log(' terminate-session - Terminate the current session');
+ console.log(' reconnect - Reconnect to the server');
+ console.log(' list-tools - List available tools');
+ 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');
+ console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments');
+ console.log(' list-resources - List available resources');
+ console.log(' read-resource - Read a specific resource by URI');
+ console.log(' help - Show this help');
+ console.log(' quit - Exit the program');
+}
+
+function commandLoop(): void {
+ readline.question('\n> ', async input => {
+ const args = input.trim().split(/\s+/);
+ const command = args[0]?.toLowerCase();
+
+ try {
+ switch (command) {
+ case 'connect':
+ await connect(args[1]);
+ break;
+
+ case 'disconnect':
+ await disconnect();
+ break;
+
+ case 'terminate-session':
+ await terminateSession();
+ break;
+
+ case 'reconnect':
+ await reconnect();
+ break;
+
+ case 'list-tools':
+ await listTools();
+ break;
+
+ case 'call-tool':
+ if (args.length < 2) {
+ console.log('Usage: call-tool [args]');
+ } else {
+ const toolName = args[1];
+ let toolArgs = {};
+ if (args.length > 2) {
+ try {
+ toolArgs = JSON.parse(args.slice(2).join(' '));
+ } catch {
+ console.log('Invalid JSON arguments. Using empty args.');
+ }
+ }
+ await callTool(toolName, toolArgs);
+ }
+ break;
+
+ case 'greet':
+ await callGreetTool(args[1] || 'MCP User');
+ break;
+
+ case 'multi-greet':
+ 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;
+ await startNotifications(interval, count);
+ break;
+ }
+
+ case 'run-notifications-tool-with-resumability': {
+ const interval = args[1] ? parseInt(args[1], 10) : 2000;
+ const count = args[2] ? parseInt(args[2], 10) : 10;
+ await runNotificationsToolWithResumability(interval, count);
+ break;
+ }
+
+ case 'list-prompts':
+ await listPrompts();
+ break;
+
+ case 'get-prompt':
+ if (args.length < 2) {
+ console.log('Usage: get-prompt [args]');
+ } else {
+ const promptName = args[1];
+ let promptArgs = {};
+ if (args.length > 2) {
+ try {
+ promptArgs = JSON.parse(args.slice(2).join(' '));
+ } catch {
+ console.log('Invalid JSON arguments. Using empty args.');
+ }
+ }
+ await getPrompt(promptName, promptArgs);
+ }
+ break;
+
+ case 'list-resources':
+ await listResources();
+ break;
+
+ case 'read-resource':
+ if (args.length < 2) {
+ console.log('Usage: read-resource ');
+ } else {
+ await readResource(args[1]);
+ }
+ break;
+
+ case 'help':
+ printHelp();
+ break;
+
+ case 'quit':
+ case 'exit':
+ await cleanup();
+ return;
+
+ default:
+ if (command) {
+ console.log(`Unknown command: ${command}`);
+ }
+ break;
+ }
+ } catch (error) {
+ console.error(`Error executing command: ${error}`);
+ }
+
+ // Continue the command loop
+ commandLoop();
+ });
+}
+
+async function connect(url?: string): Promise {
+ if (client) {
+ console.log('Already connected. Disconnect first.');
+ return;
+ }
+
+ if (url) {
+ serverUrl = url;
+ }
+
+ console.log(`Connecting to ${serverUrl}...`);
+
+ try {
+ // 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: 'decline' };
+ }
+ }
+
+ // Validate the complete object against the schema
+ const isValid = validate(content);
+
+ if (!isValid) {
+ console.log('❌ Validation errors:');
+ validate.errors?.forEach(error => {
+ console.log(` - ${error.instancePath || '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: 'decline' };
+ }
+ }
+
+ // 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: 'decline' };
+ }
+ }
+ }
+
+ console.log('Maximum attempts reached. Declining request.');
+ return { action: 'decline' };
+ });
+
+ transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
+ sessionId: sessionId
+ });
+
+ // Set up notification handlers
+ client.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
+ notificationCount++;
+ console.log(`\nNotification #${notificationCount}: ${notification.params.level} - ${notification.params.data}`);
+ // Re-display the prompt
+ process.stdout.write('> ');
+ });
+
+ client.setNotificationHandler(ResourceListChangedNotificationSchema, async _ => {
+ console.log(`\nResource list changed notification received!`);
+ try {
+ if (!client) {
+ console.log('Client disconnected, cannot fetch resources');
+ return;
+ }
+ const resourcesResult = await client.request(
+ {
+ method: 'resources/list',
+ params: {}
+ },
+ ListResourcesResultSchema
+ );
+ console.log('Available resources count:', resourcesResult.resources.length);
+ } catch {
+ console.log('Failed to list resources after change notification');
+ }
+ // Re-display the prompt
+ process.stdout.write('> ');
+ });
+
+ // Connect the client
+ await client.connect(transport);
+ sessionId = transport.sessionId;
+ console.log('Transport created with session ID:', sessionId);
+ console.log('Connected to MCP server');
+ } catch (error) {
+ console.error('Failed to connect:', error);
+ client = null;
+ transport = null;
+ }
+}
+
+async function disconnect(): Promise {
+ if (!client || !transport) {
+ console.log('Not connected.');
+ return;
+ }
+
+ try {
+ await transport.close();
+ console.log('Disconnected from MCP server');
+ client = null;
+ transport = null;
+ } catch (error) {
+ console.error('Error disconnecting:', error);
+ }
+}
+
+async function terminateSession(): Promise {
+ if (!client || !transport) {
+ console.log('Not connected.');
+ return;
+ }
+
+ try {
+ console.log('Terminating session with ID:', transport.sessionId);
+ await transport.terminateSession();
+ console.log('Session terminated successfully');
+
+ // Check if sessionId was cleared after termination
+ if (!transport.sessionId) {
+ console.log('Session ID has been cleared');
+ sessionId = undefined;
+
+ // Also close the transport and clear client objects
+ await transport.close();
+ console.log('Transport closed after session termination');
+ client = null;
+ transport = null;
+ } else {
+ console.log('Server responded with 405 Method Not Allowed (session termination not supported)');
+ console.log('Session ID is still active:', transport.sessionId);
+ }
+ } catch (error) {
+ console.error('Error terminating session:', error);
+ }
+}
+
+async function reconnect(): Promise {
+ if (client) {
+ await disconnect();
+ }
+ await connect();
+}
+
+async function listTools(): Promise {
+ if (!client) {
+ console.log('Not connected to server.');
+ return;
+ }
+
+ try {
+ const toolsRequest: ListToolsRequest = {
+ method: 'tools/list',
+ params: {}
+ };
+ const toolsResult = await client.request(toolsRequest, ListToolsResultSchema);
+
+ console.log('Available tools:');
+ if (toolsResult.tools.length === 0) {
+ console.log(' No tools available');
+ } else {
+ for (const tool of toolsResult.tools) {
+ console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`);
+ }
+ }
+ } catch (error) {
+ console.log(`Tools not supported by this server (${error})`);
+ }
+}
+
+async function callTool(name: string, args: Record): Promise {
+ if (!client) {
+ console.log('Not connected to server.');
+ return;
+ }
+
+ try {
+ const request: CallToolRequest = {
+ method: 'tools/call',
+ params: {
+ name,
+ arguments: args
+ }
+ };
+
+ console.log(`Calling tool '${name}' with args:`, args);
+ const result = await client.request(request, CallToolResultSchema);
+
+ console.log('Tool result:');
+ const resourceLinks: ResourceLink[] = [];
+
+ result.content.forEach(item => {
+ if (item.type === 'text') {
+ console.log(` ${item.text}`);
+ } else if (item.type === 'resource_link') {
+ const resourceLink = item as ResourceLink;
+ resourceLinks.push(resourceLink);
+ console.log(` 📁 Resource Link: ${resourceLink.name}`);
+ console.log(` URI: ${resourceLink.uri}`);
+ if (resourceLink.mimeType) {
+ console.log(` Type: ${resourceLink.mimeType}`);
+ }
+ if (resourceLink.description) {
+ console.log(` Description: ${resourceLink.description}`);
+ }
+ } else if (item.type === 'resource') {
+ console.log(` [Embedded Resource: ${item.resource.uri}]`);
+ } else if (item.type === 'image') {
+ console.log(` [Image: ${item.mimeType}]`);
+ } else if (item.type === 'audio') {
+ console.log(` [Audio: ${item.mimeType}]`);
+ } else {
+ console.log(` [Unknown content type]:`, item);
+ }
+ });
+
+ // Offer to read resource links
+ if (resourceLinks.length > 0) {
+ console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource ' to read their content.`);
+ }
+ } catch (error) {
+ console.log(`Error calling tool ${name}: ${error}`);
+ }
+}
+
+async function callGreetTool(name: string): Promise {
+ await callTool('greet', { name });
+}
+
+async function callMultiGreetTool(name: string): Promise {
+ console.log('Calling multi-greet tool with notifications...');
+ 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 });
+}
+
+async function runNotificationsToolWithResumability(interval: number, count: number): Promise {
+ if (!client) {
+ console.log('Not connected to server.');
+ return;
+ }
+
+ try {
+ console.log(`Starting notification stream with resumability: interval=${interval}ms, count=${count || 'unlimited'}`);
+ console.log(`Using resumption token: ${notificationsToolLastEventId || 'none'}`);
+
+ const request: CallToolRequest = {
+ method: 'tools/call',
+ params: {
+ name: 'start-notification-stream',
+ arguments: { interval, count }
+ }
+ };
+
+ const onLastEventIdUpdate = (event: string) => {
+ notificationsToolLastEventId = event;
+ console.log(`Updated resumption token: ${event}`);
+ };
+
+ const result = await client.request(request, CallToolResultSchema, {
+ resumptionToken: notificationsToolLastEventId,
+ onresumptiontoken: onLastEventIdUpdate
+ });
+
+ console.log('Tool result:');
+ result.content.forEach(item => {
+ if (item.type === 'text') {
+ console.log(` ${item.text}`);
+ } else {
+ console.log(` ${item.type} content:`, item);
+ }
+ });
+ } catch (error) {
+ console.log(`Error starting notification stream: ${error}`);
+ }
+}
+
+async function listPrompts(): Promise {
+ if (!client) {
+ console.log('Not connected to server.');
+ return;
+ }
+
+ try {
+ const promptsRequest: ListPromptsRequest = {
+ method: 'prompts/list',
+ params: {}
+ };
+ const promptsResult = await client.request(promptsRequest, ListPromptsResultSchema);
+ console.log('Available prompts:');
+ if (promptsResult.prompts.length === 0) {
+ console.log(' No prompts available');
+ } else {
+ for (const prompt of promptsResult.prompts) {
+ console.log(` - id: ${prompt.name}, name: ${getDisplayName(prompt)}, description: ${prompt.description}`);
+ }
+ }
+ } catch (error) {
+ console.log(`Prompts not supported by this server (${error})`);
+ }
+}
+
+async function getPrompt(name: string, args: Record): Promise {
+ if (!client) {
+ console.log('Not connected to server.');
+ return;
+ }
+
+ try {
+ const promptRequest: GetPromptRequest = {
+ method: 'prompts/get',
+ params: {
+ name,
+ arguments: args as Record
+ }
+ };
+
+ const promptResult = await client.request(promptRequest, GetPromptResultSchema);
+ console.log('Prompt template:');
+ promptResult.messages.forEach((msg, index) => {
+ console.log(` [${index + 1}] ${msg.role}: ${msg.content.text}`);
+ });
+ } catch (error) {
+ console.log(`Error getting prompt ${name}: ${error}`);
+ }
+}
+
+async function listResources(): Promise {
+ if (!client) {
+ console.log('Not connected to server.');
+ return;
+ }
+
+ try {
+ const resourcesRequest: ListResourcesRequest = {
+ method: 'resources/list',
+ params: {}
+ };
+ const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema);
+
+ console.log('Available resources:');
+ if (resourcesResult.resources.length === 0) {
+ console.log(' No resources available');
+ } else {
+ for (const resource of resourcesResult.resources) {
+ console.log(` - id: ${resource.name}, name: ${getDisplayName(resource)}, description: ${resource.uri}`);
+ }
+ }
+ } catch (error) {
+ console.log(`Resources not supported by this server (${error})`);
+ }
+}
+
+async function readResource(uri: string): Promise {
+ if (!client) {
+ console.log('Not connected to server.');
+ return;
+ }
+
+ try {
+ const request: ReadResourceRequest = {
+ method: 'resources/read',
+ params: { uri }
+ };
+
+ console.log(`Reading resource: ${uri}`);
+ const result = await client.request(request, ReadResourceResultSchema);
+
+ console.log('Resource contents:');
+ for (const content of result.contents) {
+ console.log(` URI: ${content.uri}`);
+ if (content.mimeType) {
+ console.log(` Type: ${content.mimeType}`);
+ }
+
+ if ('text' in content && typeof content.text === 'string') {
+ console.log(' Content:');
+ console.log(' ---');
+ console.log(
+ content.text
+ .split('\n')
+ .map((line: string) => ' ' + line)
+ .join('\n')
+ );
+ console.log(' ---');
+ } else if ('blob' in content && typeof content.blob === 'string') {
+ console.log(` [Binary data: ${content.blob.length} bytes]`);
+ }
+ }
+ } catch (error) {
+ console.log(`Error reading resource ${uri}: ${error}`);
+ }
+}
+
+async function cleanup(): Promise {
+ if (client && transport) {
+ try {
+ // First try to terminate the session gracefully
+ if (transport.sessionId) {
+ try {
+ console.log('Terminating session before exit...');
+ await transport.terminateSession();
+ console.log('Session terminated successfully');
+ } catch (error) {
+ console.error('Error terminating session:', error);
+ }
+ }
+
+ // Then close the transport
+ await transport.close();
+ } catch (error) {
+ console.error('Error closing transport:', error);
+ }
+ }
+
+ process.stdin.setRawMode(false);
+ readline.close();
+ console.log('\nGoodbye!');
+ process.exit(0);
+}
+
+// Set up raw mode for keyboard input to capture Escape key
+process.stdin.setRawMode(true);
+process.stdin.on('data', async data => {
+ // Check for Escape key (27)
+ if (data.length === 1 && data[0] === 27) {
+ console.log('\nESC key pressed. Disconnecting from server...');
+
+ // Abort current operation and disconnect from server
+ if (client && transport) {
+ await disconnect();
+ console.log('Disconnected. Press Enter to continue.');
+ } else {
+ console.log('Not connected to server.');
+ }
+
+ // Re-display the prompt
+ process.stdout.write('> ');
+ }
+});
+
+// Handle Ctrl+C
+process.on('SIGINT', async () => {
+ console.log('\nReceived SIGINT. Cleaning up...');
+ await cleanup();
+});
+
+// Start the interactive client
+main().catch((error: unknown) => {
+ console.error('Error running MCP client:', error);
+ process.exit(1);
+});
diff --git a/typescript-sdk/src/examples/client/streamableHttpWithSseFallbackClient.ts b/typescript-sdk/src/examples/client/streamableHttpWithSseFallbackClient.ts
new file mode 100644
index 0000000..657f489
--- /dev/null
+++ b/typescript-sdk/src/examples/client/streamableHttpWithSseFallbackClient.ts
@@ -0,0 +1,191 @@
+import { Client } from '../../client/index.js';
+import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js';
+import { SSEClientTransport } from '../../client/sse.js';
+import {
+ ListToolsRequest,
+ ListToolsResultSchema,
+ CallToolRequest,
+ CallToolResultSchema,
+ LoggingMessageNotificationSchema
+} from '../../types.js';
+
+/**
+ * Simplified Backwards Compatible MCP Client
+ *
+ * This client demonstrates backward compatibility with both:
+ * 1. Modern servers using Streamable HTTP transport (protocol version 2025-03-26)
+ * 2. Older servers using HTTP+SSE transport (protocol version 2024-11-05)
+ *
+ * Following the MCP specification for backwards compatibility:
+ * - Attempts to POST an initialize request to the server URL first (modern transport)
+ * - If that fails with 4xx status, falls back to GET request for SSE stream (older transport)
+ */
+
+// Command line args processing
+const args = process.argv.slice(2);
+const serverUrl = args[0] || 'http://localhost:3000/mcp';
+
+async function main(): Promise {
+ console.log('MCP Backwards Compatible Client');
+ console.log('===============================');
+ console.log(`Connecting to server at: ${serverUrl}`);
+
+ let client: Client;
+ let transport: StreamableHTTPClientTransport | SSEClientTransport;
+
+ try {
+ // Try connecting with automatic transport detection
+ const connection = await connectWithBackwardsCompatibility(serverUrl);
+ client = connection.client;
+ transport = connection.transport;
+
+ // Set up notification handler
+ client.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
+ console.log(`Notification: ${notification.params.level} - ${notification.params.data}`);
+ });
+
+ // DEMO WORKFLOW:
+ // 1. List available tools
+ console.log('\n=== Listing Available Tools ===');
+ await listTools(client);
+
+ // 2. Call the notification tool
+ console.log('\n=== Starting Notification Stream ===');
+ await startNotificationTool(client);
+
+ // 3. Wait for all notifications (5 seconds)
+ console.log('\n=== Waiting for all notifications ===');
+ await new Promise(resolve => setTimeout(resolve, 5000));
+
+ // 4. Disconnect
+ console.log('\n=== Disconnecting ===');
+ await transport.close();
+ console.log('Disconnected from MCP server');
+ } catch (error) {
+ console.error('Error running client:', error);
+ process.exit(1);
+ }
+}
+
+/**
+ * Connect to an MCP server with backwards compatibility
+ * Following the spec for client backward compatibility
+ */
+async function connectWithBackwardsCompatibility(url: string): Promise<{
+ client: Client;
+ transport: StreamableHTTPClientTransport | SSEClientTransport;
+ transportType: 'streamable-http' | 'sse';
+}> {
+ console.log('1. Trying Streamable HTTP transport first...');
+
+ // Step 1: Try Streamable HTTP transport first
+ const client = new Client({
+ name: 'backwards-compatible-client',
+ version: '1.0.0'
+ });
+
+ client.onerror = error => {
+ console.error('Client error:', error);
+ };
+ const baseUrl = new URL(url);
+
+ try {
+ // Create modern transport
+ const streamableTransport = new StreamableHTTPClientTransport(baseUrl);
+ await client.connect(streamableTransport);
+
+ console.log('Successfully connected using modern Streamable HTTP transport.');
+ return {
+ client,
+ transport: streamableTransport,
+ transportType: 'streamable-http'
+ };
+ } catch (error) {
+ // Step 2: If transport fails, try the older SSE transport
+ console.log(`StreamableHttp transport connection failed: ${error}`);
+ console.log('2. Falling back to deprecated HTTP+SSE transport...');
+
+ try {
+ // Create SSE transport pointing to /sse endpoint
+ const sseTransport = new SSEClientTransport(baseUrl);
+ const sseClient = new Client({
+ name: 'backwards-compatible-client',
+ version: '1.0.0'
+ });
+ await sseClient.connect(sseTransport);
+
+ console.log('Successfully connected using deprecated HTTP+SSE transport.');
+ return {
+ client: sseClient,
+ transport: sseTransport,
+ transportType: 'sse'
+ };
+ } catch (sseError) {
+ console.error(`Failed to connect with either transport method:\n1. Streamable HTTP error: ${error}\n2. SSE error: ${sseError}`);
+ throw new Error('Could not connect to server with any available transport');
+ }
+ }
+}
+
+/**
+ * List available tools on the server
+ */
+async function listTools(client: Client): Promise {
+ try {
+ const toolsRequest: ListToolsRequest = {
+ method: 'tools/list',
+ params: {}
+ };
+ const toolsResult = await client.request(toolsRequest, ListToolsResultSchema);
+
+ console.log('Available tools:');
+ if (toolsResult.tools.length === 0) {
+ console.log(' No tools available');
+ } else {
+ for (const tool of toolsResult.tools) {
+ console.log(` - ${tool.name}: ${tool.description}`);
+ }
+ }
+ } catch (error) {
+ console.log(`Tools not supported by this server: ${error}`);
+ }
+}
+
+/**
+ * Start a notification stream by calling the notification tool
+ */
+async function startNotificationTool(client: Client): Promise {
+ try {
+ // Call the notification tool using reasonable defaults
+ const request: CallToolRequest = {
+ method: 'tools/call',
+ params: {
+ name: 'start-notification-stream',
+ arguments: {
+ interval: 1000, // 1 second between notifications
+ count: 5 // Send 5 notifications
+ }
+ }
+ };
+
+ console.log('Calling notification tool...');
+ const result = await client.request(request, CallToolResultSchema);
+
+ console.log('Tool result:');
+ result.content.forEach(item => {
+ if (item.type === 'text') {
+ console.log(` ${item.text}`);
+ } else {
+ console.log(` ${item.type} content:`, item);
+ }
+ });
+ } catch (error) {
+ console.log(`Error calling notification tool: ${error}`);
+ }
+}
+
+// Start the client
+main().catch((error: unknown) => {
+ console.error('Error running MCP client:', error);
+ process.exit(1);
+});
diff --git a/typescript-sdk/src/examples/server/demoInMemoryOAuthProvider.test.ts b/typescript-sdk/src/examples/server/demoInMemoryOAuthProvider.test.ts
new file mode 100644
index 0000000..0fc7daf
--- /dev/null
+++ b/typescript-sdk/src/examples/server/demoInMemoryOAuthProvider.test.ts
@@ -0,0 +1,285 @@
+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();
+ });
+ });
+});
diff --git a/typescript-sdk/src/examples/server/demoInMemoryOAuthProvider.ts b/typescript-sdk/src/examples/server/demoInMemoryOAuthProvider.ts
new file mode 100644
index 0000000..ba1d3a4
--- /dev/null
+++ b/typescript-sdk/src/examples/server/demoInMemoryOAuthProvider.ts
@@ -0,0 +1,232 @@
+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 '../../shared/auth.js';
+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 {
+ private clients = new Map();
+
+ async getClient(clientId: string) {
+ return this.clients.get(clientId);
+ }
+
+ async registerClient(clientMetadata: OAuthClientInformationFull) {
+ this.clients.set(clientMetadata.client_id, clientMetadata);
+ return clientMetadata;
+ }
+}
+
+/**
+ * 🚨 DEMO ONLY - NOT FOR PRODUCTION
+ *
+ * This example demonstrates MCP OAuth flow but lacks some of the features required for production use,
+ * for example:
+ * - Persistent token storage
+ * - Rate limiting
+ */
+export class DemoInMemoryAuthProvider implements OAuthServerProvider {
+ clientsStore = new DemoInMemoryClientsStore();
+ private codes = new Map<
+ string,
+ {
+ params: AuthorizationParams;
+ client: OAuthClientInformationFull;
+ }
+ >();
+ private tokens = new Map();
+
+ constructor(private validateResource?: (resource?: URL) => boolean) {}
+
+ async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise {
+ const code = randomUUID();
+
+ const searchParams = new URLSearchParams({
+ code
+ });
+ if (params.state !== undefined) {
+ searchParams.set('state', params.state);
+ }
+
+ this.codes.set(code, {
+ client,
+ params
+ });
+
+ 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());
+ }
+
+ async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise {
+ // Store the challenge with the code data
+ const codeData = this.codes.get(authorizationCode);
+ if (!codeData) {
+ throw new Error('Invalid authorization code');
+ }
+
+ return codeData.params.codeChallenge;
+ }
+
+ async exchangeAuthorizationCode(
+ client: OAuthClientInformationFull,
+ authorizationCode: string,
+ // Note: code verifier is checked in token.ts by default
+ // it's unused here for that reason.
+ _codeVerifier?: string
+ ): Promise {
+ const codeData = this.codes.get(authorizationCode);
+ if (!codeData) {
+ throw new Error('Invalid authorization code');
+ }
+
+ if (codeData.client.client_id !== client.client_id) {
+ 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();
+
+ const tokenData = {
+ token,
+ clientId: client.client_id,
+ scopes: codeData.params.scopes || [],
+ expiresAt: Date.now() + 3600000, // 1 hour
+ resource: codeData.params.resource,
+ type: 'access'
+ };
+
+ this.tokens.set(token, tokenData);
+
+ return {
+ access_token: token,
+ token_type: 'bearer',
+ expires_in: 3600,
+ scope: (codeData.params.scopes || []).join(' ')
+ };
+ }
+
+ async exchangeRefreshToken(
+ _client: OAuthClientInformationFull,
+ _refreshToken: string,
+ _scopes?: string[],
+ _resource?: URL
+ ): Promise {
+ throw new Error('Not implemented for example demo');
+ }
+
+ async verifyAccessToken(token: string): Promise {
+ const tokenData = this.tokens.get(token);
+ if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) {
+ throw new Error('Invalid or expired token');
+ }
+
+ return {
+ token,
+ clientId: tokenData.clientId,
+ scopes: tokenData.scopes,
+ expiresAt: Math.floor(tokenData.expiresAt / 1000),
+ resource: tokenData.resource
+ };
+ }
+}
+
+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 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
+ authApp.use(express.urlencoded());
+
+ // Add OAuth routes to the auth server
+ // NOTE: this will also add a protected resource metadata route,
+ // but it won't be used, so leave it.
+ authApp.use(
+ mcpAuthRouter({
+ provider,
+ issuerUrl: authServerUrl,
+ scopesSupported: ['mcp:tools']
+ })
+ );
+
+ authApp.post('/introspect', async (req: Request, res: Response) => {
+ try {
+ const { token } = req.body;
+ if (!token) {
+ res.status(400).json({ error: 'Token is required' });
+ return;
+ }
+
+ const tokenInfo = await provider.verifyAccessToken(token);
+ res.json({
+ active: true,
+ client_id: tokenInfo.clientId,
+ scope: tokenInfo.scopes.join(' '),
+ exp: tokenInfo.expiresAt,
+ aud: tokenInfo.resource
+ });
+ return;
+ } catch (error) {
+ res.status(401).json({
+ active: false,
+ error: 'Unauthorized',
+ error_description: `Invalid token: ${error}`
+ });
+ }
+ });
+
+ const auth_port = authServerUrl.port;
+ // Start the auth server
+ authApp.listen(auth_port, error => {
+ if (error) {
+ console.error('Failed to start server:', error);
+ process.exit(1);
+ }
+ console.log(`OAuth Authorization Server listening on port ${auth_port}`);
+ });
+
+ // Note: we could fetch this from the server, but then we end up
+ // with some top level async which gets annoying.
+ const oauthMetadata: OAuthMetadata = createOAuthMetadata({
+ provider,
+ issuerUrl: authServerUrl,
+ scopesSupported: ['mcp:tools']
+ });
+
+ oauthMetadata.introspection_endpoint = new URL('/introspect', authServerUrl).href;
+
+ return oauthMetadata;
+};
diff --git a/typescript-sdk/src/examples/server/elicitationExample.ts b/typescript-sdk/src/examples/server/elicitationExample.ts
new file mode 100644
index 0000000..8b1f81d
--- /dev/null
+++ b/typescript-sdk/src/examples/server/elicitationExample.ts
@@ -0,0 +1,476 @@
+// Run with: npx tsx src/examples/server/elicitationExample.ts
+//
+// This example demonstrates how to use elicitation to collect structured user input
+// with JSON Schema validation via a local HTTP server with SSE streaming.
+// Elicitation allows servers to request user input through the client interface
+// with schema-based validation.
+
+import { randomUUID } from 'node:crypto';
+import cors from 'cors';
+import express, { type Request, type Response } from 'express';
+import { McpServer } from '../../server/mcp.js';
+import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
+import { isInitializeRequest } from '../../types.js';
+
+// Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults
+// The validator supports format validation (email, date, etc.) if ajv-formats is installed
+const mcpServer = new McpServer(
+ {
+ name: 'elicitation-example-server',
+ version: '1.0.0'
+ },
+ {
+ capabilities: {}
+ }
+);
+
+/**
+ * Example 1: Simple user registration tool
+ * Collects username, email, and password from the user
+ */
+mcpServer.registerTool(
+ 'register_user',
+ {
+ description: 'Register a new user account by collecting their information',
+ inputSchema: {}
+ },
+ async () => {
+ try {
+ // Request user information through elicitation
+ const result = await mcpServer.server.elicitInput({
+ message: 'Please provide your registration information:',
+ requestedSchema: {
+ type: 'object',
+ properties: {
+ username: {
+ type: 'string',
+ title: 'Username',
+ description: 'Your desired username (3-20 characters)',
+ minLength: 3,
+ maxLength: 20
+ },
+ email: {
+ type: 'string',
+ title: 'Email',
+ description: 'Your email address',
+ format: 'email'
+ },
+ password: {
+ type: 'string',
+ title: 'Password',
+ description: 'Your password (min 8 characters)',
+ minLength: 8
+ },
+ newsletter: {
+ type: 'boolean',
+ title: 'Newsletter',
+ description: 'Subscribe to newsletter?',
+ default: false
+ }
+ },
+ required: ['username', 'email', 'password']
+ }
+ });
+
+ // Handle the different possible actions
+ if (result.action === 'accept' && result.content) {
+ const { username, email, newsletter } = result.content as {
+ username: string;
+ email: string;
+ password: string;
+ newsletter?: boolean;
+ };
+
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Registration successful!\n\nUsername: ${username}\nEmail: ${email}\nNewsletter: ${newsletter ? 'Yes' : 'No'}`
+ }
+ ]
+ };
+ } else if (result.action === 'decline') {
+ return {
+ content: [
+ {
+ type: 'text',
+ text: 'Registration cancelled by user.'
+ }
+ ]
+ };
+ } else {
+ return {
+ content: [
+ {
+ type: 'text',
+ text: 'Registration was cancelled.'
+ }
+ ]
+ };
+ }
+ } catch (error) {
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Registration failed: ${error instanceof Error ? error.message : String(error)}`
+ }
+ ],
+ isError: true
+ };
+ }
+ }
+);
+
+/**
+ * Example 2: Multi-step workflow with multiple elicitation requests
+ * Demonstrates how to collect information in multiple steps
+ */
+mcpServer.registerTool(
+ 'create_event',
+ {
+ description: 'Create a calendar event by collecting event details',
+ inputSchema: {}
+ },
+ async () => {
+ try {
+ // Step 1: Collect basic event information
+ const basicInfo = await mcpServer.server.elicitInput({
+ message: 'Step 1: Enter basic event information',
+ requestedSchema: {
+ type: 'object',
+ properties: {
+ title: {
+ type: 'string',
+ title: 'Event Title',
+ description: 'Name of the event',
+ minLength: 1
+ },
+ description: {
+ type: 'string',
+ title: 'Description',
+ description: 'Event description (optional)'
+ }
+ },
+ required: ['title']
+ }
+ });
+
+ if (basicInfo.action !== 'accept' || !basicInfo.content) {
+ return {
+ content: [{ type: 'text', text: 'Event creation cancelled.' }]
+ };
+ }
+
+ // Step 2: Collect date and time
+ const dateTime = await mcpServer.server.elicitInput({
+ message: 'Step 2: Enter date and time',
+ requestedSchema: {
+ type: 'object',
+ properties: {
+ date: {
+ type: 'string',
+ title: 'Date',
+ description: 'Event date',
+ format: 'date'
+ },
+ startTime: {
+ type: 'string',
+ title: 'Start Time',
+ description: 'Event start time (HH:MM)',
+ pattern: '^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'
+ },
+ duration: {
+ type: 'integer',
+ title: 'Duration',
+ description: 'Duration in minutes',
+ minimum: 15,
+ maximum: 480
+ }
+ },
+ required: ['date', 'startTime', 'duration']
+ }
+ });
+
+ if (dateTime.action !== 'accept' || !dateTime.content) {
+ return {
+ content: [{ type: 'text', text: 'Event creation cancelled.' }]
+ };
+ }
+
+ // Combine all collected information
+ const event = {
+ ...basicInfo.content,
+ ...dateTime.content
+ };
+
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Event created successfully!\n\n${JSON.stringify(event, null, 2)}`
+ }
+ ]
+ };
+ } catch (error) {
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Event creation failed: ${error instanceof Error ? error.message : String(error)}`
+ }
+ ],
+ isError: true
+ };
+ }
+ }
+);
+
+/**
+ * Example 3: Collecting address information
+ * Demonstrates validation with patterns and optional fields
+ */
+mcpServer.registerTool(
+ 'update_shipping_address',
+ {
+ description: 'Update shipping address with validation',
+ inputSchema: {}
+ },
+ async () => {
+ try {
+ const result = await mcpServer.server.elicitInput({
+ message: 'Please provide your shipping address:',
+ requestedSchema: {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string',
+ title: 'Full Name',
+ description: 'Recipient name',
+ minLength: 1
+ },
+ street: {
+ type: 'string',
+ title: 'Street Address',
+ minLength: 1
+ },
+ city: {
+ type: 'string',
+ title: 'City',
+ minLength: 1
+ },
+ state: {
+ type: 'string',
+ title: 'State/Province',
+ minLength: 2,
+ maxLength: 2
+ },
+ zipCode: {
+ type: 'string',
+ title: 'ZIP/Postal Code',
+ description: '5-digit ZIP code',
+ pattern: '^[0-9]{5}$'
+ },
+ phone: {
+ type: 'string',
+ title: 'Phone Number (optional)',
+ description: 'Contact phone number'
+ }
+ },
+ required: ['name', 'street', 'city', 'state', 'zipCode']
+ }
+ });
+
+ if (result.action === 'accept' && result.content) {
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Address updated successfully!\n\n${JSON.stringify(result.content, null, 2)}`
+ }
+ ]
+ };
+ } else if (result.action === 'decline') {
+ return {
+ content: [{ type: 'text', text: 'Address update cancelled by user.' }]
+ };
+ } else {
+ return {
+ content: [{ type: 'text', text: 'Address update was cancelled.' }]
+ };
+ }
+ } catch (error) {
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Address update failed: ${error instanceof Error ? error.message : String(error)}`
+ }
+ ],
+ isError: true
+ };
+ }
+ }
+);
+
+async function main() {
+ const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
+
+ const app = express();
+ app.use(express.json());
+
+ // Allow CORS for all domains, expose the Mcp-Session-Id header
+ app.use(
+ cors({
+ origin: '*',
+ exposedHeaders: ['Mcp-Session-Id']
+ })
+ );
+
+ // Map to store transports by session ID
+ const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
+
+ // MCP POST endpoint
+ const mcpPostHandler = async (req: Request, res: Response) => {
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+ if (sessionId) {
+ console.log(`Received MCP request for session: ${sessionId}`);
+ }
+
+ try {
+ let transport: StreamableHTTPServerTransport;
+ if (sessionId && transports[sessionId]) {
+ // Reuse existing transport for this session
+ transport = transports[sessionId];
+ } else if (!sessionId && isInitializeRequest(req.body)) {
+ // New initialization request - create new transport
+ transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ onsessioninitialized: sessionId => {
+ // Store the transport by session ID when session is initialized
+ console.log(`Session initialized with ID: ${sessionId}`);
+ transports[sessionId] = transport;
+ }
+ });
+
+ // Set up onclose handler to clean up transport when closed
+ transport.onclose = () => {
+ const sid = transport.sessionId;
+ if (sid && transports[sid]) {
+ console.log(`Transport closed for session ${sid}, removing from transports map`);
+ delete transports[sid];
+ }
+ };
+
+ // Connect the transport to the MCP server BEFORE handling the request
+ await mcpServer.connect(transport);
+
+ await transport.handleRequest(req, res, req.body);
+ return;
+ } else {
+ // Invalid request - no session ID or not initialization request
+ res.status(400).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32000,
+ message: 'Bad Request: No valid session ID provided'
+ },
+ id: null
+ });
+ return;
+ }
+
+ // Handle the request with existing transport
+ await transport.handleRequest(req, res, req.body);
+ } catch (error) {
+ console.error('Error handling MCP request:', error);
+ if (!res.headersSent) {
+ res.status(500).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32603,
+ message: 'Internal server error'
+ },
+ id: null
+ });
+ }
+ }
+ };
+
+ app.post('/mcp', mcpPostHandler);
+
+ // Handle GET requests for SSE streams
+ const mcpGetHandler = async (req: Request, res: Response) => {
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+ if (!sessionId || !transports[sessionId]) {
+ res.status(400).send('Invalid or missing session ID');
+ return;
+ }
+
+ console.log(`Establishing SSE stream for session ${sessionId}`);
+ const transport = transports[sessionId];
+ await transport.handleRequest(req, res);
+ };
+
+ app.get('/mcp', mcpGetHandler);
+
+ // Handle DELETE requests for session termination
+ const mcpDeleteHandler = async (req: Request, res: Response) => {
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+ if (!sessionId || !transports[sessionId]) {
+ res.status(400).send('Invalid or missing session ID');
+ return;
+ }
+
+ console.log(`Received session termination request for session ${sessionId}`);
+
+ try {
+ const transport = transports[sessionId];
+ await transport.handleRequest(req, res);
+ } catch (error) {
+ console.error('Error handling session termination:', error);
+ if (!res.headersSent) {
+ res.status(500).send('Error processing session termination');
+ }
+ }
+ };
+
+ app.delete('/mcp', mcpDeleteHandler);
+
+ // Start listening
+ app.listen(PORT, error => {
+ if (error) {
+ console.error('Failed to start server:', error);
+ process.exit(1);
+ }
+ console.log(`Elicitation example server is running on http://localhost:${PORT}/mcp`);
+ console.log('Available tools:');
+ console.log(' - register_user: Collect user registration information');
+ console.log(' - create_event: Multi-step event creation');
+ console.log(' - update_shipping_address: Collect and validate address');
+ console.log('\nConnect your MCP client to this server using the HTTP transport.');
+ });
+
+ // Handle server shutdown
+ process.on('SIGINT', async () => {
+ console.log('Shutting down server...');
+
+ // Close all active transports to properly clean up resources
+ for (const sessionId in transports) {
+ try {
+ console.log(`Closing transport for session ${sessionId}`);
+ await transports[sessionId].close();
+ delete transports[sessionId];
+ } catch (error) {
+ console.error(`Error closing transport for session ${sessionId}:`, error);
+ }
+ }
+ console.log('Server shutdown complete');
+ process.exit(0);
+ });
+}
+
+main().catch(error => {
+ console.error('Server error:', error);
+ process.exit(1);
+});
diff --git a/typescript-sdk/src/examples/server/jsonResponseStreamableHttp.ts b/typescript-sdk/src/examples/server/jsonResponseStreamableHttp.ts
new file mode 100644
index 0000000..8b64077
--- /dev/null
+++ b/typescript-sdk/src/examples/server/jsonResponseStreamableHttp.ts
@@ -0,0 +1,186 @@
+import express, { Request, Response } from 'express';
+import { randomUUID } from 'node:crypto';
+import { McpServer } from '../../server/mcp.js';
+import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
+import { z } from 'zod';
+import { CallToolResult, isInitializeRequest } from '../../types.js';
+import cors from 'cors';
+
+// Create an MCP server with implementation details
+const getServer = () => {
+ const server = new McpServer(
+ {
+ name: 'json-response-streamable-http-server',
+ version: '1.0.0'
+ },
+ {
+ capabilities: {
+ logging: {}
+ }
+ }
+ );
+
+ // Register a simple tool that returns a greeting
+ server.tool(
+ 'greet',
+ 'A simple greeting tool',
+ {
+ name: z.string().describe('Name to greet')
+ },
+ async ({ name }): Promise => {
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Hello, ${name}!`
+ }
+ ]
+ };
+ }
+ );
+
+ // Register a tool that sends multiple greetings with notifications
+ server.tool(
+ 'multi-greet',
+ 'A tool that sends different greetings with delays between them',
+ {
+ name: z.string().describe('Name to greet')
+ },
+ async ({ name }, extra): Promise => {
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+ await server.sendLoggingMessage(
+ {
+ level: 'debug',
+ data: `Starting multi-greet for ${name}`
+ },
+ extra.sessionId
+ );
+
+ await sleep(1000); // Wait 1 second before first greeting
+
+ await server.sendLoggingMessage(
+ {
+ level: 'info',
+ data: `Sending first greeting to ${name}`
+ },
+ extra.sessionId
+ );
+
+ await sleep(1000); // Wait another second before second greeting
+
+ await server.sendLoggingMessage(
+ {
+ level: 'info',
+ data: `Sending second greeting to ${name}`
+ },
+ extra.sessionId
+ );
+
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Good morning, ${name}!`
+ }
+ ]
+ };
+ }
+ );
+ return server;
+};
+
+const app = express();
+app.use(express.json());
+
+// Configure CORS to expose Mcp-Session-Id header for browser-based clients
+app.use(
+ cors({
+ origin: '*', // Allow all origins - adjust as needed for production
+ exposedHeaders: ['Mcp-Session-Id']
+ })
+);
+
+// Map to store transports by session ID
+const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
+
+app.post('/mcp', async (req: Request, res: Response) => {
+ console.log('Received MCP request:', req.body);
+ try {
+ // Check for existing session ID
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+ let transport: StreamableHTTPServerTransport;
+
+ if (sessionId && transports[sessionId]) {
+ // Reuse existing transport
+ transport = transports[sessionId];
+ } else if (!sessionId && isInitializeRequest(req.body)) {
+ // New initialization request - use JSON response mode
+ transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ enableJsonResponse: true, // Enable JSON response mode
+ onsessioninitialized: sessionId => {
+ // Store the transport by session ID when session is initialized
+ // This avoids race conditions where requests might come in before the session is stored
+ console.log(`Session initialized with ID: ${sessionId}`);
+ transports[sessionId] = transport;
+ }
+ });
+
+ // Connect the transport to the MCP server BEFORE handling the request
+ const server = getServer();
+ await server.connect(transport);
+ await transport.handleRequest(req, res, req.body);
+ return; // Already handled
+ } else {
+ // Invalid request - no session ID or not initialization request
+ res.status(400).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32000,
+ message: 'Bad Request: No valid session ID provided'
+ },
+ id: null
+ });
+ return;
+ }
+
+ // Handle the request with existing transport - no need to reconnect
+ await transport.handleRequest(req, res, req.body);
+ } catch (error) {
+ console.error('Error handling MCP request:', error);
+ if (!res.headersSent) {
+ res.status(500).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32603,
+ message: 'Internal server error'
+ },
+ id: null
+ });
+ }
+ }
+});
+
+// Handle GET requests for SSE streams according to spec
+app.get('/mcp', async (req: Request, res: Response) => {
+ // Since this is a very simple example, we don't support GET requests for this server
+ // The spec requires returning 405 Method Not Allowed in this case
+ res.status(405).set('Allow', 'POST').send('Method Not Allowed');
+});
+
+// Start the server
+const PORT = 3000;
+app.listen(PORT, error => {
+ if (error) {
+ console.error('Failed to start server:', error);
+ process.exit(1);
+ }
+ console.log(`MCP Streamable HTTP Server listening on port ${PORT}`);
+});
+
+// Handle server shutdown
+process.on('SIGINT', async () => {
+ console.log('Shutting down server...');
+ process.exit(0);
+});
diff --git a/typescript-sdk/src/examples/server/mcpServerOutputSchema.ts b/typescript-sdk/src/examples/server/mcpServerOutputSchema.ts
new file mode 100644
index 0000000..5d1cab0
--- /dev/null
+++ b/typescript-sdk/src/examples/server/mcpServerOutputSchema.ts
@@ -0,0 +1,80 @@
+#!/usr/bin/env node
+/**
+ * Example MCP server using the high-level McpServer API with outputSchema
+ * This demonstrates how to easily create tools with structured output
+ */
+
+import { McpServer } from '../../server/mcp.js';
+import { StdioServerTransport } from '../../server/stdio.js';
+import { z } from 'zod';
+
+const server = new McpServer({
+ name: 'mcp-output-schema-high-level-example',
+ version: '1.0.0'
+});
+
+// Define a tool with structured output - Weather data
+server.registerTool(
+ 'get_weather',
+ {
+ description: 'Get weather information for a city',
+ inputSchema: {
+ city: z.string().describe('City name'),
+ country: z.string().describe('Country code (e.g., US, UK)')
+ },
+ outputSchema: {
+ temperature: z.object({
+ celsius: z.number(),
+ fahrenheit: z.number()
+ }),
+ conditions: z.enum(['sunny', 'cloudy', 'rainy', 'stormy', 'snowy']),
+ humidity: z.number().min(0).max(100),
+ wind: z.object({
+ speed_kmh: z.number(),
+ direction: z.string()
+ })
+ }
+ },
+ async ({ city, country }) => {
+ // Parameters are available but not used in this example
+ void city;
+ void country;
+ // Simulate weather API call
+ const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10;
+ const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][Math.floor(Math.random() * 5)];
+
+ const structuredContent = {
+ temperature: {
+ celsius: temp_c,
+ fahrenheit: Math.round(((temp_c * 9) / 5 + 32) * 10) / 10
+ },
+ conditions,
+ humidity: Math.round(Math.random() * 100),
+ wind: {
+ speed_kmh: Math.round(Math.random() * 50),
+ direction: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)]
+ }
+ };
+
+ return {
+ content: [
+ {
+ type: 'text',
+ text: JSON.stringify(structuredContent, null, 2)
+ }
+ ],
+ structuredContent
+ };
+ }
+);
+
+async function main() {
+ const transport = new StdioServerTransport();
+ await server.connect(transport);
+ console.error('High-level Output Schema Example Server running on stdio');
+}
+
+main().catch(error => {
+ console.error('Server error:', error);
+ process.exit(1);
+});
diff --git a/typescript-sdk/src/examples/server/simpleSseServer.ts b/typescript-sdk/src/examples/server/simpleSseServer.ts
new file mode 100644
index 0000000..b993343
--- /dev/null
+++ b/typescript-sdk/src/examples/server/simpleSseServer.ts
@@ -0,0 +1,174 @@
+import express, { Request, Response } from 'express';
+import { McpServer } from '../../server/mcp.js';
+import { SSEServerTransport } from '../../server/sse.js';
+import { z } from 'zod';
+import { CallToolResult } from '../../types.js';
+
+/**
+ * This example server demonstrates the deprecated HTTP+SSE transport
+ * (protocol version 2024-11-05). It mainly used for testing backward compatible clients.
+ *
+ * The server exposes two endpoints:
+ * - /mcp: For establishing the SSE stream (GET)
+ * - /messages: For receiving client messages (POST)
+ *
+ */
+
+// Create an MCP server instance
+const getServer = () => {
+ const server = new McpServer(
+ {
+ name: 'simple-sse-server',
+ version: '1.0.0'
+ },
+ { capabilities: { logging: {} } }
+ );
+
+ server.tool(
+ 'start-notification-stream',
+ 'Starts sending periodic notifications',
+ {
+ interval: z.number().describe('Interval in milliseconds between notifications').default(1000),
+ count: z.number().describe('Number of notifications to send').default(10)
+ },
+ async ({ interval, count }, extra): Promise => {
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+ let counter = 0;
+
+ // Send the initial notification
+ await server.sendLoggingMessage(
+ {
+ level: 'info',
+ data: `Starting notification stream with ${count} messages every ${interval}ms`
+ },
+ extra.sessionId
+ );
+
+ // Send periodic notifications
+ while (counter < count) {
+ counter++;
+ await sleep(interval);
+
+ try {
+ await server.sendLoggingMessage(
+ {
+ level: 'info',
+ data: `Notification #${counter} at ${new Date().toISOString()}`
+ },
+ extra.sessionId
+ );
+ } catch (error) {
+ console.error('Error sending notification:', error);
+ }
+ }
+
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Completed sending ${count} notifications every ${interval}ms`
+ }
+ ]
+ };
+ }
+ );
+ return server;
+};
+
+const app = express();
+app.use(express.json());
+
+// Store transports by session ID
+const transports: Record = {};
+
+// SSE endpoint for establishing the stream
+app.get('/mcp', async (req: Request, res: Response) => {
+ console.log('Received GET request to /sse (establishing SSE stream)');
+
+ try {
+ // Create a new SSE transport for the client
+ // The endpoint for POST messages is '/messages'
+ const transport = new SSEServerTransport('/messages', res);
+
+ // Store the transport by session ID
+ const sessionId = transport.sessionId;
+ transports[sessionId] = transport;
+
+ // Set up onclose handler to clean up transport when closed
+ transport.onclose = () => {
+ console.log(`SSE transport closed for session ${sessionId}`);
+ delete transports[sessionId];
+ };
+
+ // Connect the transport to the MCP server
+ const server = getServer();
+ await server.connect(transport);
+
+ console.log(`Established SSE stream with session ID: ${sessionId}`);
+ } catch (error) {
+ console.error('Error establishing SSE stream:', error);
+ if (!res.headersSent) {
+ res.status(500).send('Error establishing SSE stream');
+ }
+ }
+});
+
+// Messages endpoint for receiving client JSON-RPC requests
+app.post('/messages', async (req: Request, res: Response) => {
+ console.log('Received POST request to /messages');
+
+ // Extract session ID from URL query parameter
+ // In the SSE protocol, this is added by the client based on the endpoint event
+ const sessionId = req.query.sessionId as string | undefined;
+
+ if (!sessionId) {
+ console.error('No session ID provided in request URL');
+ res.status(400).send('Missing sessionId parameter');
+ return;
+ }
+
+ const transport = transports[sessionId];
+ if (!transport) {
+ console.error(`No active transport found for session ID: ${sessionId}`);
+ res.status(404).send('Session not found');
+ return;
+ }
+
+ try {
+ // Handle the POST message with the transport
+ await transport.handlePostMessage(req, res, req.body);
+ } catch (error) {
+ console.error('Error handling request:', error);
+ if (!res.headersSent) {
+ res.status(500).send('Error handling request');
+ }
+ }
+});
+
+// Start the server
+const PORT = 3000;
+app.listen(PORT, error => {
+ if (error) {
+ console.error('Failed to start server:', error);
+ process.exit(1);
+ }
+ console.log(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`);
+});
+
+// Handle server shutdown
+process.on('SIGINT', async () => {
+ console.log('Shutting down server...');
+
+ // Close all active transports to properly clean up resources
+ for (const sessionId in transports) {
+ try {
+ console.log(`Closing transport for session ${sessionId}`);
+ await transports[sessionId].close();
+ delete transports[sessionId];
+ } catch (error) {
+ console.error(`Error closing transport for session ${sessionId}:`, error);
+ }
+ }
+ console.log('Server shutdown complete');
+ process.exit(0);
+});
diff --git a/typescript-sdk/src/examples/server/simpleStatelessStreamableHttp.ts b/typescript-sdk/src/examples/server/simpleStatelessStreamableHttp.ts
new file mode 100644
index 0000000..f71e5db
--- /dev/null
+++ b/typescript-sdk/src/examples/server/simpleStatelessStreamableHttp.ts
@@ -0,0 +1,180 @@
+import express, { Request, Response } from 'express';
+import { McpServer } from '../../server/mcp.js';
+import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
+import { z } from 'zod';
+import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js';
+import cors from 'cors';
+
+const getServer = () => {
+ // Create an MCP server with implementation details
+ const server = new McpServer(
+ {
+ name: 'stateless-streamable-http-server',
+ version: '1.0.0'
+ },
+ { capabilities: { logging: {} } }
+ );
+
+ // Register a simple prompt
+ server.prompt(
+ 'greeting-template',
+ 'A simple greeting prompt template',
+ {
+ name: z.string().describe('Name to include in greeting')
+ },
+ async ({ name }): Promise => {
+ return {
+ messages: [
+ {
+ role: 'user',
+ content: {
+ type: 'text',
+ text: `Please greet ${name} in a friendly manner.`
+ }
+ }
+ ]
+ };
+ }
+ );
+
+ // Register a tool specifically for testing resumability
+ server.tool(
+ 'start-notification-stream',
+ 'Starts sending periodic notifications for testing resumability',
+ {
+ interval: z.number().describe('Interval in milliseconds between notifications').default(100),
+ count: z.number().describe('Number of notifications to send (0 for 100)').default(10)
+ },
+ async ({ interval, count }, extra): Promise => {
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+ let counter = 0;
+
+ while (count === 0 || counter < count) {
+ counter++;
+ try {
+ await server.sendLoggingMessage(
+ {
+ level: 'info',
+ data: `Periodic notification #${counter} at ${new Date().toISOString()}`
+ },
+ extra.sessionId
+ );
+ } catch (error) {
+ console.error('Error sending notification:', error);
+ }
+ // Wait for the specified interval
+ await sleep(interval);
+ }
+
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Started sending periodic notifications every ${interval}ms`
+ }
+ ]
+ };
+ }
+ );
+
+ // Create a simple resource at a fixed URI
+ server.resource(
+ 'greeting-resource',
+ 'https://example.com/greetings/default',
+ { mimeType: 'text/plain' },
+ async (): Promise => {
+ return {
+ contents: [
+ {
+ uri: 'https://example.com/greetings/default',
+ text: 'Hello, world!'
+ }
+ ]
+ };
+ }
+ );
+ return server;
+};
+
+const app = express();
+app.use(express.json());
+
+// Configure CORS to expose Mcp-Session-Id header for browser-based clients
+app.use(
+ cors({
+ origin: '*', // Allow all origins - adjust as needed for production
+ exposedHeaders: ['Mcp-Session-Id']
+ })
+);
+
+app.post('/mcp', async (req: Request, res: Response) => {
+ const server = getServer();
+ try {
+ const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: undefined
+ });
+ await server.connect(transport);
+ await transport.handleRequest(req, res, req.body);
+ res.on('close', () => {
+ console.log('Request closed');
+ transport.close();
+ server.close();
+ });
+ } catch (error) {
+ console.error('Error handling MCP request:', error);
+ if (!res.headersSent) {
+ res.status(500).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32603,
+ message: 'Internal server error'
+ },
+ id: null
+ });
+ }
+ }
+});
+
+app.get('/mcp', async (req: Request, res: Response) => {
+ console.log('Received GET MCP request');
+ res.writeHead(405).end(
+ JSON.stringify({
+ jsonrpc: '2.0',
+ error: {
+ code: -32000,
+ message: 'Method not allowed.'
+ },
+ id: null
+ })
+ );
+});
+
+app.delete('/mcp', async (req: Request, res: Response) => {
+ console.log('Received DELETE MCP request');
+ res.writeHead(405).end(
+ JSON.stringify({
+ jsonrpc: '2.0',
+ error: {
+ code: -32000,
+ message: 'Method not allowed.'
+ },
+ id: null
+ })
+ );
+});
+
+// Start the server
+const PORT = 3000;
+app.listen(PORT, error => {
+ if (error) {
+ console.error('Failed to start server:', error);
+ process.exit(1);
+ }
+ console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`);
+});
+
+// Handle server shutdown
+process.on('SIGINT', async () => {
+ console.log('Shutting down server...');
+ process.exit(0);
+});
diff --git a/typescript-sdk/src/examples/server/simpleStreamableHttp.ts b/typescript-sdk/src/examples/server/simpleStreamableHttp.ts
new file mode 100644
index 0000000..5872cb4
--- /dev/null
+++ b/typescript-sdk/src/examples/server/simpleStreamableHttp.ts
@@ -0,0 +1,698 @@
+import express, { Request, Response } from 'express';
+import { randomUUID } from 'node:crypto';
+import { z } from 'zod';
+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,
+ 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';
+
+import cors from 'cors';
+
+// 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 = () => {
+ const server = new McpServer(
+ {
+ name: 'simple-streamable-http-server',
+ version: '1.0.0',
+ icons: [{ src: './mcp.svg', sizes: ['512x512'], mimeType: 'image/svg+xml' }],
+ websiteUrl: 'https://github.com/modelcontextprotocol/typescript-sdk'
+ },
+ { capabilities: { logging: {} } }
+ );
+
+ // Register a simple tool that returns a greeting
+ server.registerTool(
+ 'greet',
+ {
+ title: 'Greeting Tool', // Display name for UI
+ description: 'A simple greeting tool',
+ inputSchema: {
+ name: z.string().describe('Name to greet')
+ }
+ },
+ async ({ name }): Promise => {
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Hello, ${name}!`
+ }
+ ]
+ };
+ }
+ );
+
+ // Register a tool that sends multiple greetings with notifications (with annotations)
+ server.tool(
+ 'multi-greet',
+ 'A tool that sends different greetings with delays between them',
+ {
+ name: z.string().describe('Name to greet')
+ },
+ {
+ title: 'Multiple Greeting Tool',
+ readOnlyHint: true,
+ openWorldHint: false
+ },
+ async ({ name }, extra): Promise => {
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+ await server.sendLoggingMessage(
+ {
+ level: 'debug',
+ data: `Starting multi-greet for ${name}`
+ },
+ extra.sessionId
+ );
+
+ await sleep(1000); // Wait 1 second before first greeting
+
+ await server.sendLoggingMessage(
+ {
+ level: 'info',
+ data: `Sending first greeting to ${name}`
+ },
+ extra.sessionId
+ );
+
+ await sleep(1000); // Wait another second before second greeting
+
+ await server.sendLoggingMessage(
+ {
+ level: 'info',
+ data: `Sending second greeting to ${name}`
+ },
+ extra.sessionId
+ );
+
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Good morning, ${name}!`
+ }
+ ]
+ };
+ }
+ );
+ // 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 === 'decline') {
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `No information was collected. User declined ${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(
+ 'greeting-template',
+ {
+ title: 'Greeting Template', // Display name for UI
+ description: 'A simple greeting prompt template',
+ argsSchema: {
+ name: z.string().describe('Name to include in greeting')
+ }
+ },
+ async ({ name }): Promise => {
+ return {
+ messages: [
+ {
+ role: 'user',
+ content: {
+ type: 'text',
+ text: `Please greet ${name} in a friendly manner.`
+ }
+ }
+ ]
+ };
+ }
+ );
+
+ // Register a tool specifically for testing resumability
+ server.tool(
+ 'start-notification-stream',
+ 'Starts sending periodic notifications for testing resumability',
+ {
+ interval: z.number().describe('Interval in milliseconds between notifications').default(100),
+ count: z.number().describe('Number of notifications to send (0 for 100)').default(50)
+ },
+ async ({ interval, count }, extra): Promise => {
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+ let counter = 0;
+
+ while (count === 0 || counter < count) {
+ counter++;
+ try {
+ await server.sendLoggingMessage(
+ {
+ level: 'info',
+ data: `Periodic notification #${counter} at ${new Date().toISOString()}`
+ },
+ extra.sessionId
+ );
+ } catch (error) {
+ console.error('Error sending notification:', error);
+ }
+ // Wait for the specified interval
+ await sleep(interval);
+ }
+
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Started sending periodic notifications every ${interval}ms`
+ }
+ ]
+ };
+ }
+ );
+
+ // Create a simple resource at a fixed URI
+ server.registerResource(
+ 'greeting-resource',
+ 'https://example.com/greetings/default',
+ {
+ title: 'Default Greeting', // Display name for UI
+ description: 'A simple greeting resource',
+ mimeType: 'text/plain'
+ },
+ async (): Promise => {
+ return {
+ contents: [
+ {
+ uri: 'https://example.com/greetings/default',
+ text: 'Hello, world!'
+ }
+ ]
+ };
+ }
+ );
+
+ // Create additional resources for ResourceLink demonstration
+ server.registerResource(
+ 'example-file-1',
+ 'file:///example/file1.txt',
+ {
+ title: 'Example File 1',
+ description: 'First example file for ResourceLink demonstration',
+ mimeType: 'text/plain'
+ },
+ async (): Promise => {
+ return {
+ contents: [
+ {
+ uri: 'file:///example/file1.txt',
+ text: 'This is the content of file 1'
+ }
+ ]
+ };
+ }
+ );
+
+ server.registerResource(
+ 'example-file-2',
+ 'file:///example/file2.txt',
+ {
+ title: 'Example File 2',
+ description: 'Second example file for ResourceLink demonstration',
+ mimeType: 'text/plain'
+ },
+ async (): Promise => {
+ return {
+ contents: [
+ {
+ uri: 'file:///example/file2.txt',
+ text: 'This is the content of file 2'
+ }
+ ]
+ };
+ }
+ );
+
+ // Register a tool that returns ResourceLinks
+ server.registerTool(
+ 'list-files',
+ {
+ title: 'List Files with ResourceLinks',
+ description: 'Returns a list of files as ResourceLinks without embedding their content',
+ inputSchema: {
+ includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links')
+ }
+ },
+ async ({ includeDescriptions = true }): Promise => {
+ const resourceLinks: ResourceLink[] = [
+ {
+ type: 'resource_link',
+ uri: 'https://example.com/greetings/default',
+ name: 'Default Greeting',
+ mimeType: 'text/plain',
+ ...(includeDescriptions && { description: 'A simple greeting resource' })
+ },
+ {
+ type: 'resource_link',
+ uri: 'file:///example/file1.txt',
+ name: 'Example File 1',
+ mimeType: 'text/plain',
+ ...(includeDescriptions && { description: 'First example file for ResourceLink demonstration' })
+ },
+ {
+ type: 'resource_link',
+ uri: 'file:///example/file2.txt',
+ name: 'Example File 2',
+ mimeType: 'text/plain',
+ ...(includeDescriptions && { description: 'Second example file for ResourceLink demonstration' })
+ }
+ ];
+
+ return {
+ content: [
+ {
+ type: 'text',
+ text: 'Here are the available files as resource links:'
+ },
+ ...resourceLinks,
+ {
+ type: 'text',
+ text: '\nYou can read any of these resources using their URI.'
+ }
+ ]
+ };
+ }
+ );
+
+ return server;
+};
+
+const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000;
+const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001;
+
+const app = express();
+app.use(express.json());
+
+// Allow CORS all domains, expose the Mcp-Session-Id header
+app.use(
+ cors({
+ origin: '*', // Allow all origins
+ exposedHeaders: ['Mcp-Session-Id']
+ })
+);
+
+// Set up OAuth if enabled
+let authMiddleware = null;
+if (useOAuth) {
+ // Create auth middleware for MCP endpoints
+ const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
+ const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
+
+ const oauthMetadata: OAuthMetadata = setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth });
+
+ const tokenVerifier = {
+ verifyAccessToken: async (token: string) => {
+ const endpoint = oauthMetadata.introspection_endpoint;
+
+ if (!endpoint) {
+ throw new Error('No token verification endpoint available in metadata');
+ }
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: new URLSearchParams({
+ token: token
+ }).toString()
+ });
+
+ if (!response.ok) {
+ throw new Error(`Invalid or expired token: ${await response.text()}`);
+ }
+
+ 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,
+ clientId: data.client_id,
+ scopes: data.scope ? data.scope.split(' ') : [],
+ expiresAt: data.exp
+ };
+ }
+ };
+ // Add metadata routes to the main MCP server
+ app.use(
+ mcpAuthMetadataRouter({
+ oauthMetadata,
+ resourceServerUrl: mcpServerUrl,
+ scopesSupported: ['mcp:tools'],
+ resourceName: 'MCP Demo Server'
+ })
+ );
+
+ authMiddleware = requireBearerAuth({
+ verifier: tokenVerifier,
+ requiredScopes: [],
+ resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
+ });
+}
+
+// Map to store transports by session ID
+const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
+
+// MCP POST endpoint with optional auth
+const mcpPostHandler = async (req: Request, res: Response) => {
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+ if (sessionId) {
+ console.log(`Received MCP request for session: ${sessionId}`);
+ } else {
+ console.log('Request body:', req.body);
+ }
+
+ if (useOAuth && req.auth) {
+ console.log('Authenticated user:', req.auth);
+ }
+ try {
+ let transport: StreamableHTTPServerTransport;
+ if (sessionId && transports[sessionId]) {
+ // Reuse existing transport
+ transport = transports[sessionId];
+ } else if (!sessionId && isInitializeRequest(req.body)) {
+ // New initialization request
+ const eventStore = new InMemoryEventStore();
+ transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ eventStore, // Enable resumability
+ onsessioninitialized: sessionId => {
+ // Store the transport by session ID when session is initialized
+ // This avoids race conditions where requests might come in before the session is stored
+ console.log(`Session initialized with ID: ${sessionId}`);
+ transports[sessionId] = transport;
+ }
+ });
+
+ // Set up onclose handler to clean up transport when closed
+ transport.onclose = () => {
+ const sid = transport.sessionId;
+ if (sid && transports[sid]) {
+ console.log(`Transport closed for session ${sid}, removing from transports map`);
+ delete transports[sid];
+ }
+ };
+
+ // Connect the transport to the MCP server BEFORE handling the request
+ // so responses can flow back through the same transport
+ const server = getServer();
+ await server.connect(transport);
+
+ await transport.handleRequest(req, res, req.body);
+ return; // Already handled
+ } else {
+ // Invalid request - no session ID or not initialization request
+ res.status(400).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32000,
+ message: 'Bad Request: No valid session ID provided'
+ },
+ id: null
+ });
+ return;
+ }
+
+ // Handle the request with existing transport - no need to reconnect
+ // The existing transport is already connected to the server
+ await transport.handleRequest(req, res, req.body);
+ } catch (error) {
+ console.error('Error handling MCP request:', error);
+ if (!res.headersSent) {
+ res.status(500).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32603,
+ message: 'Internal server error'
+ },
+ id: null
+ });
+ }
+ }
+};
+
+// Set up routes with conditional auth middleware
+if (useOAuth && authMiddleware) {
+ app.post('/mcp', authMiddleware, mcpPostHandler);
+} else {
+ app.post('/mcp', mcpPostHandler);
+}
+
+// Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
+const mcpGetHandler = async (req: Request, res: Response) => {
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+ if (!sessionId || !transports[sessionId]) {
+ res.status(400).send('Invalid or missing session ID');
+ return;
+ }
+
+ if (useOAuth && req.auth) {
+ console.log('Authenticated SSE connection from user:', req.auth);
+ }
+
+ // Check for Last-Event-ID header for resumability
+ const lastEventId = req.headers['last-event-id'] as string | undefined;
+ if (lastEventId) {
+ console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
+ } else {
+ console.log(`Establishing new SSE stream for session ${sessionId}`);
+ }
+
+ const transport = transports[sessionId];
+ await transport.handleRequest(req, res);
+};
+
+// Set up GET route with conditional auth middleware
+if (useOAuth && authMiddleware) {
+ app.get('/mcp', authMiddleware, mcpGetHandler);
+} else {
+ app.get('/mcp', mcpGetHandler);
+}
+
+// Handle DELETE requests for session termination (according to MCP spec)
+const mcpDeleteHandler = async (req: Request, res: Response) => {
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+ if (!sessionId || !transports[sessionId]) {
+ res.status(400).send('Invalid or missing session ID');
+ return;
+ }
+
+ console.log(`Received session termination request for session ${sessionId}`);
+
+ try {
+ const transport = transports[sessionId];
+ await transport.handleRequest(req, res);
+ } catch (error) {
+ console.error('Error handling session termination:', error);
+ if (!res.headersSent) {
+ res.status(500).send('Error processing session termination');
+ }
+ }
+};
+
+// Set up DELETE route with conditional auth middleware
+if (useOAuth && authMiddleware) {
+ app.delete('/mcp', authMiddleware, mcpDeleteHandler);
+} else {
+ app.delete('/mcp', mcpDeleteHandler);
+}
+
+app.listen(MCP_PORT, error => {
+ if (error) {
+ console.error('Failed to start server:', error);
+ process.exit(1);
+ }
+ console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`);
+});
+
+// Handle server shutdown
+process.on('SIGINT', async () => {
+ console.log('Shutting down server...');
+
+ // Close all active transports to properly clean up resources
+ for (const sessionId in transports) {
+ try {
+ console.log(`Closing transport for session ${sessionId}`);
+ await transports[sessionId].close();
+ delete transports[sessionId];
+ } catch (error) {
+ console.error(`Error closing transport for session ${sessionId}:`, error);
+ }
+ }
+ console.log('Server shutdown complete');
+ process.exit(0);
+});
diff --git a/typescript-sdk/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/typescript-sdk/src/examples/server/sseAndStreamableHttpCompatibleServer.ts
new file mode 100644
index 0000000..50e2e51
--- /dev/null
+++ b/typescript-sdk/src/examples/server/sseAndStreamableHttpCompatibleServer.ts
@@ -0,0 +1,260 @@
+import express, { Request, Response } from 'express';
+import { randomUUID } from 'node:crypto';
+import { McpServer } from '../../server/mcp.js';
+import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
+import { SSEServerTransport } from '../../server/sse.js';
+import { z } from 'zod';
+import { CallToolResult, isInitializeRequest } from '../../types.js';
+import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
+import cors from 'cors';
+
+/**
+ * This example server demonstrates backwards compatibility with both:
+ * 1. The deprecated HTTP+SSE transport (protocol version 2024-11-05)
+ * 2. The Streamable HTTP transport (protocol version 2025-03-26)
+ *
+ * It maintains a single MCP server instance but exposes two transport options:
+ * - /mcp: The new Streamable HTTP endpoint (supports GET/POST/DELETE)
+ * - /sse: The deprecated SSE endpoint for older clients (GET to establish stream)
+ * - /messages: The deprecated POST endpoint for older clients (POST to send messages)
+ */
+
+const getServer = () => {
+ const server = new McpServer(
+ {
+ name: 'backwards-compatible-server',
+ version: '1.0.0'
+ },
+ { capabilities: { logging: {} } }
+ );
+
+ // Register a simple tool that sends notifications over time
+ server.tool(
+ 'start-notification-stream',
+ 'Starts sending periodic notifications for testing resumability',
+ {
+ interval: z.number().describe('Interval in milliseconds between notifications').default(100),
+ count: z.number().describe('Number of notifications to send (0 for 100)').default(50)
+ },
+ async ({ interval, count }, extra): Promise => {
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+ let counter = 0;
+
+ while (count === 0 || counter < count) {
+ counter++;
+ try {
+ await server.sendLoggingMessage(
+ {
+ level: 'info',
+ data: `Periodic notification #${counter} at ${new Date().toISOString()}`
+ },
+ extra.sessionId
+ );
+ } catch (error) {
+ console.error('Error sending notification:', error);
+ }
+ // Wait for the specified interval
+ await sleep(interval);
+ }
+
+ return {
+ content: [
+ {
+ type: 'text',
+ text: `Started sending periodic notifications every ${interval}ms`
+ }
+ ]
+ };
+ }
+ );
+ return server;
+};
+
+// Create Express application
+const app = express();
+app.use(express.json());
+
+// Configure CORS to expose Mcp-Session-Id header for browser-based clients
+app.use(
+ cors({
+ origin: '*', // Allow all origins - adjust as needed for production
+ exposedHeaders: ['Mcp-Session-Id']
+ })
+);
+
+// Store transports by session ID
+const transports: Record = {};
+
+//=============================================================================
+// STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26)
+//=============================================================================
+
+// Handle all MCP Streamable HTTP requests (GET, POST, DELETE) on a single endpoint
+app.all('/mcp', async (req: Request, res: Response) => {
+ console.log(`Received ${req.method} request to /mcp`);
+
+ try {
+ // Check for existing session ID
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+ let transport: StreamableHTTPServerTransport;
+
+ if (sessionId && transports[sessionId]) {
+ // Check if the transport is of the correct type
+ const existingTransport = transports[sessionId];
+ if (existingTransport instanceof StreamableHTTPServerTransport) {
+ // Reuse existing transport
+ transport = existingTransport;
+ } else {
+ // Transport exists but is not a StreamableHTTPServerTransport (could be SSEServerTransport)
+ res.status(400).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32000,
+ message: 'Bad Request: Session exists but uses a different transport protocol'
+ },
+ id: null
+ });
+ return;
+ }
+ } else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) {
+ const eventStore = new InMemoryEventStore();
+ transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ eventStore, // Enable resumability
+ onsessioninitialized: sessionId => {
+ // Store the transport by session ID when session is initialized
+ console.log(`StreamableHTTP session initialized with ID: ${sessionId}`);
+ transports[sessionId] = transport;
+ }
+ });
+
+ // Set up onclose handler to clean up transport when closed
+ transport.onclose = () => {
+ const sid = transport.sessionId;
+ if (sid && transports[sid]) {
+ console.log(`Transport closed for session ${sid}, removing from transports map`);
+ delete transports[sid];
+ }
+ };
+
+ // Connect the transport to the MCP server
+ const server = getServer();
+ await server.connect(transport);
+ } else {
+ // Invalid request - no session ID or not initialization request
+ res.status(400).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32000,
+ message: 'Bad Request: No valid session ID provided'
+ },
+ id: null
+ });
+ return;
+ }
+
+ // Handle the request with the transport
+ await transport.handleRequest(req, res, req.body);
+ } catch (error) {
+ console.error('Error handling MCP request:', error);
+ if (!res.headersSent) {
+ res.status(500).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32603,
+ message: 'Internal server error'
+ },
+ id: null
+ });
+ }
+ }
+});
+
+//=============================================================================
+// DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05)
+//=============================================================================
+
+app.get('/sse', async (req: Request, res: Response) => {
+ console.log('Received GET request to /sse (deprecated SSE transport)');
+ const transport = new SSEServerTransport('/messages', res);
+ transports[transport.sessionId] = transport;
+ res.on('close', () => {
+ delete transports[transport.sessionId];
+ });
+ const server = getServer();
+ await server.connect(transport);
+});
+
+app.post('/messages', async (req: Request, res: Response) => {
+ const sessionId = req.query.sessionId as string;
+ let transport: SSEServerTransport;
+ const existingTransport = transports[sessionId];
+ if (existingTransport instanceof SSEServerTransport) {
+ // Reuse existing transport
+ transport = existingTransport;
+ } else {
+ // Transport exists but is not a SSEServerTransport (could be StreamableHTTPServerTransport)
+ res.status(400).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32000,
+ message: 'Bad Request: Session exists but uses a different transport protocol'
+ },
+ id: null
+ });
+ return;
+ }
+ if (transport) {
+ await transport.handlePostMessage(req, res, req.body);
+ } else {
+ res.status(400).send('No transport found for sessionId');
+ }
+});
+
+// Start the server
+const PORT = 3000;
+app.listen(PORT, error => {
+ if (error) {
+ console.error('Failed to start server:', error);
+ process.exit(1);
+ }
+ console.log(`Backwards compatible MCP server listening on port ${PORT}`);
+ console.log(`
+==============================================
+SUPPORTED TRANSPORT OPTIONS:
+
+1. Streamable Http(Protocol version: 2025-03-26)
+ Endpoint: /mcp
+ Methods: GET, POST, DELETE
+ Usage:
+ - Initialize with POST to /mcp
+ - Establish SSE stream with GET to /mcp
+ - Send requests with POST to /mcp
+ - Terminate session with DELETE to /mcp
+
+2. Http + SSE (Protocol version: 2024-11-05)
+ Endpoints: /sse (GET) and /messages (POST)
+ Usage:
+ - Establish SSE stream with GET to /sse
+ - Send requests with POST to /messages?sessionId=
+==============================================
+`);
+});
+
+// Handle server shutdown
+process.on('SIGINT', async () => {
+ console.log('Shutting down server...');
+
+ // Close all active transports to properly clean up resources
+ for (const sessionId in transports) {
+ try {
+ console.log(`Closing transport for session ${sessionId}`);
+ await transports[sessionId].close();
+ delete transports[sessionId];
+ } catch (error) {
+ console.error(`Error closing transport for session ${sessionId}:`, error);
+ }
+ }
+ console.log('Server shutdown complete');
+ process.exit(0);
+});
diff --git a/typescript-sdk/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/typescript-sdk/src/examples/server/standaloneSseWithGetStreamableHttp.ts
new file mode 100644
index 0000000..6229c53
--- /dev/null
+++ b/typescript-sdk/src/examples/server/standaloneSseWithGetStreamableHttp.ts
@@ -0,0 +1,127 @@
+import express, { Request, Response } from 'express';
+import { randomUUID } from 'node:crypto';
+import { McpServer } from '../../server/mcp.js';
+import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
+import { isInitializeRequest, ReadResourceResult } from '../../types.js';
+
+// Create an MCP server with implementation details
+const server = new McpServer({
+ name: 'resource-list-changed-notification-server',
+ version: '1.0.0'
+});
+
+// Store transports by session ID to send notifications
+const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
+
+const addResource = (name: string, content: string) => {
+ const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`;
+ server.resource(
+ name,
+ uri,
+ { mimeType: 'text/plain', description: `Dynamic resource: ${name}` },
+ async (): Promise => {
+ return {
+ contents: [{ uri, text: content }]
+ };
+ }
+ );
+};
+
+addResource('example-resource', 'Initial content for example-resource');
+
+const resourceChangeInterval = setInterval(() => {
+ const name = randomUUID();
+ addResource(name, `Content for ${name}`);
+}, 5000); // Change resources every 5 seconds for testing
+
+const app = express();
+app.use(express.json());
+
+app.post('/mcp', async (req: Request, res: Response) => {
+ console.log('Received MCP request:', req.body);
+ try {
+ // Check for existing session ID
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+ let transport: StreamableHTTPServerTransport;
+
+ if (sessionId && transports[sessionId]) {
+ // Reuse existing transport
+ transport = transports[sessionId];
+ } else if (!sessionId && isInitializeRequest(req.body)) {
+ // New initialization request
+ transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ onsessioninitialized: sessionId => {
+ // Store the transport by session ID when session is initialized
+ // This avoids race conditions where requests might come in before the session is stored
+ console.log(`Session initialized with ID: ${sessionId}`);
+ transports[sessionId] = transport;
+ }
+ });
+
+ // Connect the transport to the MCP server
+ await server.connect(transport);
+
+ // Handle the request - the onsessioninitialized callback will store the transport
+ await transport.handleRequest(req, res, req.body);
+ return; // Already handled
+ } else {
+ // Invalid request - no session ID or not initialization request
+ res.status(400).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32000,
+ message: 'Bad Request: No valid session ID provided'
+ },
+ id: null
+ });
+ return;
+ }
+
+ // Handle the request with existing transport
+ await transport.handleRequest(req, res, req.body);
+ } catch (error) {
+ console.error('Error handling MCP request:', error);
+ if (!res.headersSent) {
+ res.status(500).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32603,
+ message: 'Internal server error'
+ },
+ id: null
+ });
+ }
+ }
+});
+
+// Handle GET requests for SSE streams (now using built-in support from StreamableHTTP)
+app.get('/mcp', async (req: Request, res: Response) => {
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+ if (!sessionId || !transports[sessionId]) {
+ res.status(400).send('Invalid or missing session ID');
+ return;
+ }
+
+ console.log(`Establishing SSE stream for session ${sessionId}`);
+ const transport = transports[sessionId];
+ await transport.handleRequest(req, res);
+});
+
+// Start the server
+const PORT = 3000;
+app.listen(PORT, error => {
+ if (error) {
+ console.error('Failed to start server:', error);
+ process.exit(1);
+ }
+ console.log(`Server listening on port ${PORT}`);
+});
+
+// Handle server shutdown
+process.on('SIGINT', async () => {
+ console.log('Shutting down server...');
+ clearInterval(resourceChangeInterval);
+ await server.close();
+ process.exit(0);
+});
diff --git a/typescript-sdk/src/examples/server/toolWithSampleServer.ts b/typescript-sdk/src/examples/server/toolWithSampleServer.ts
new file mode 100644
index 0000000..ad5a01b
--- /dev/null
+++ b/typescript-sdk/src/examples/server/toolWithSampleServer.ts
@@ -0,0 +1,56 @@
+// Run with: npx tsx src/examples/server/toolWithSampleServer.ts
+
+import { McpServer } from '../../server/mcp.js';
+import { StdioServerTransport } from '../../server/stdio.js';
+import { z } from 'zod';
+
+const mcpServer = new McpServer({
+ name: 'tools-with-sample-server',
+ version: '1.0.0'
+});
+
+// Tool that uses LLM sampling to summarize any text
+mcpServer.registerTool(
+ 'summarize',
+ {
+ description: 'Summarize any text using an LLM',
+ inputSchema: {
+ text: z.string().describe('Text to summarize')
+ }
+ },
+ async ({ text }) => {
+ // Call the LLM through MCP sampling
+ const response = await mcpServer.server.createMessage({
+ messages: [
+ {
+ role: 'user',
+ content: {
+ type: 'text',
+ text: `Please summarize the following text concisely:\n\n${text}`
+ }
+ }
+ ],
+ maxTokens: 500
+ });
+
+ return {
+ content: [
+ {
+ type: 'text',
+ text: response.content.type === 'text' ? response.content.text : 'Unable to generate summary'
+ }
+ ]
+ };
+ }
+);
+
+async function main() {
+ const transport = new StdioServerTransport();
+ await mcpServer.connect(transport);
+ console.log('MCP server is running...');
+}
+
+main().catch(error => {
+ console.error('Server error:', error);
+ process.exit(1);
+});
diff --git a/typescript-sdk/src/examples/shared/inMemoryEventStore.ts b/typescript-sdk/src/examples/shared/inMemoryEventStore.ts
new file mode 100644
index 0000000..d4d02eb
--- /dev/null
+++ b/typescript-sdk/src/examples/shared/inMemoryEventStore.ts
@@ -0,0 +1,78 @@
+import { JSONRPCMessage } from '../../types.js';
+import { EventStore } from '../../server/streamableHttp.js';
+
+/**
+ * Simple in-memory implementation of the EventStore interface for resumability
+ * This is primarily intended for examples and testing, not for production use
+ * where a persistent storage solution would be more appropriate.
+ */
+export class InMemoryEventStore implements EventStore {
+ private events: Map = new Map();
+
+ /**
+ * Generates a unique event ID for a given stream ID
+ */
+ private generateEventId(streamId: string): string {
+ return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
+ }
+
+ /**
+ * Extracts the stream ID from an event ID
+ */
+ private getStreamIdFromEventId(eventId: string): string {
+ const parts = eventId.split('_');
+ return parts.length > 0 ? parts[0] : '';
+ }
+
+ /**
+ * Stores an event with a generated event ID
+ * Implements EventStore.storeEvent
+ */
+ async storeEvent(streamId: string, message: JSONRPCMessage): Promise {
+ const eventId = this.generateEventId(streamId);
+ this.events.set(eventId, { streamId, message });
+ return eventId;
+ }
+
+ /**
+ * Replays events that occurred after a specific event ID
+ * Implements EventStore.replayEventsAfter
+ */
+ async replayEventsAfter(
+ lastEventId: string,
+ { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise }
+ ): Promise {
+ if (!lastEventId || !this.events.has(lastEventId)) {
+ return '';
+ }
+
+ // Extract the stream ID from the event ID
+ const streamId = this.getStreamIdFromEventId(lastEventId);
+ if (!streamId) {
+ return '';
+ }
+
+ let foundLastEvent = false;
+
+ // Sort events by eventId for chronological ordering
+ const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0]));
+
+ for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) {
+ // Only include events from the same stream
+ if (eventStreamId !== streamId) {
+ continue;
+ }
+
+ // Start sending events after we find the lastEventId
+ if (eventId === lastEventId) {
+ foundLastEvent = true;
+ continue;
+ }
+
+ if (foundLastEvent) {
+ await send(eventId, message);
+ }
+ }
+ return streamId;
+ }
+}
diff --git a/typescript-sdk/src/inMemory.test.ts b/typescript-sdk/src/inMemory.test.ts
new file mode 100644
index 0000000..cb758ec
--- /dev/null
+++ b/typescript-sdk/src/inMemory.test.ts
@@ -0,0 +1,119 @@
+import { InMemoryTransport } from './inMemory.js';
+import { JSONRPCMessage } from './types.js';
+import { AuthInfo } from './server/auth/types.js';
+
+describe('InMemoryTransport', () => {
+ let clientTransport: InMemoryTransport;
+ let serverTransport: InMemoryTransport;
+
+ beforeEach(() => {
+ [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+ });
+
+ test('should create linked pair', () => {
+ expect(clientTransport).toBeDefined();
+ expect(serverTransport).toBeDefined();
+ });
+
+ test('should start without error', async () => {
+ await expect(clientTransport.start()).resolves.not.toThrow();
+ await expect(serverTransport.start()).resolves.not.toThrow();
+ });
+
+ test('should send message from client to server', async () => {
+ const message: JSONRPCMessage = {
+ jsonrpc: '2.0',
+ method: 'test',
+ id: 1
+ };
+
+ let receivedMessage: JSONRPCMessage | undefined;
+ serverTransport.onmessage = msg => {
+ receivedMessage = msg;
+ };
+
+ await clientTransport.send(message);
+ expect(receivedMessage).toEqual(message);
+ });
+
+ test('should send message with auth info from client to server', async () => {
+ const message: JSONRPCMessage = {
+ jsonrpc: '2.0',
+ method: 'test',
+ id: 1
+ };
+
+ const authInfo: AuthInfo = {
+ token: 'test-token',
+ clientId: 'test-client',
+ scopes: ['read', 'write'],
+ expiresAt: Date.now() / 1000 + 3600
+ };
+
+ let receivedMessage: JSONRPCMessage | undefined;
+ let receivedAuthInfo: AuthInfo | undefined;
+ serverTransport.onmessage = (msg, extra) => {
+ receivedMessage = msg;
+ receivedAuthInfo = extra?.authInfo;
+ };
+
+ await clientTransport.send(message, { authInfo });
+ expect(receivedMessage).toEqual(message);
+ expect(receivedAuthInfo).toEqual(authInfo);
+ });
+
+ test('should send message from server to client', async () => {
+ const message: JSONRPCMessage = {
+ jsonrpc: '2.0',
+ method: 'test',
+ id: 1
+ };
+
+ let receivedMessage: JSONRPCMessage | undefined;
+ clientTransport.onmessage = msg => {
+ receivedMessage = msg;
+ };
+
+ await serverTransport.send(message);
+ expect(receivedMessage).toEqual(message);
+ });
+
+ test('should handle close', async () => {
+ let clientClosed = false;
+ let serverClosed = false;
+
+ clientTransport.onclose = () => {
+ clientClosed = true;
+ };
+
+ serverTransport.onclose = () => {
+ serverClosed = true;
+ };
+
+ await clientTransport.close();
+ expect(clientClosed).toBe(true);
+ expect(serverClosed).toBe(true);
+ });
+
+ test('should throw error when sending after close', async () => {
+ await clientTransport.close();
+ await expect(clientTransport.send({ jsonrpc: '2.0', method: 'test', id: 1 })).rejects.toThrow('Not connected');
+ });
+
+ test('should queue messages sent before start', async () => {
+ const message: JSONRPCMessage = {
+ jsonrpc: '2.0',
+ method: 'test',
+ id: 1
+ };
+
+ let receivedMessage: JSONRPCMessage | undefined;
+ serverTransport.onmessage = msg => {
+ receivedMessage = msg;
+ };
+
+ await clientTransport.send(message);
+ await serverTransport.start();
+ expect(receivedMessage).toEqual(message);
+ });
+});
diff --git a/typescript-sdk/src/inMemory.ts b/typescript-sdk/src/inMemory.ts
new file mode 100644
index 0000000..2606262
--- /dev/null
+++ b/typescript-sdk/src/inMemory.ts
@@ -0,0 +1,63 @@
+import { Transport } from './shared/transport.js';
+import { JSONRPCMessage, RequestId } from './types.js';
+import { AuthInfo } from './server/auth/types.js';
+
+interface QueuedMessage {
+ message: JSONRPCMessage;
+ extra?: { authInfo?: AuthInfo };
+}
+
+/**
+ * In-memory transport for creating clients and servers that talk to each other within the same process.
+ */
+export class InMemoryTransport implements Transport {
+ private _otherTransport?: InMemoryTransport;
+ private _messageQueue: QueuedMessage[] = [];
+
+ onclose?: () => void;
+ onerror?: (error: Error) => void;
+ onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
+ sessionId?: string;
+
+ /**
+ * Creates a pair of linked in-memory transports that can communicate with each other. One should be passed to a Client and one to a Server.
+ */
+ static createLinkedPair(): [InMemoryTransport, InMemoryTransport] {
+ const clientTransport = new InMemoryTransport();
+ const serverTransport = new InMemoryTransport();
+ clientTransport._otherTransport = serverTransport;
+ serverTransport._otherTransport = clientTransport;
+ return [clientTransport, serverTransport];
+ }
+
+ async start(): Promise {
+ // Process any messages that were queued before start was called
+ while (this._messageQueue.length > 0) {
+ const queuedMessage = this._messageQueue.shift()!;
+ this.onmessage?.(queuedMessage.message, queuedMessage.extra);
+ }
+ }
+
+ async close(): Promise {
+ const other = this._otherTransport;
+ this._otherTransport = undefined;
+ await other?.close();
+ this.onclose?.();
+ }
+
+ /**
+ * Sends a message with optional auth info.
+ * This is useful for testing authentication scenarios.
+ */
+ async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId; authInfo?: AuthInfo }): Promise {
+ if (!this._otherTransport) {
+ throw new Error('Not connected');
+ }
+
+ if (this._otherTransport.onmessage) {
+ this._otherTransport.onmessage(message, { authInfo: options?.authInfo });
+ } else {
+ this._otherTransport._messageQueue.push({ message, extra: { authInfo: options?.authInfo } });
+ }
+ }
+}
diff --git a/typescript-sdk/src/integration-tests/process-cleanup.test.ts b/typescript-sdk/src/integration-tests/process-cleanup.test.ts
new file mode 100644
index 0000000..8c7c42b
--- /dev/null
+++ b/typescript-sdk/src/integration-tests/process-cleanup.test.ts
@@ -0,0 +1,28 @@
+import { Server } from '../server/index.js';
+import { StdioServerTransport } from '../server/stdio.js';
+
+describe('Process cleanup', () => {
+ jest.setTimeout(5000); // 5 second timeout
+
+ it('should exit cleanly after closing transport', async () => {
+ const server = new Server(
+ {
+ name: 'test-server',
+ version: '1.0.0'
+ },
+ {
+ capabilities: {}
+ }
+ );
+
+ const transport = new StdioServerTransport();
+ await server.connect(transport);
+
+ // Close the transport
+ await transport.close();
+
+ // If we reach here without hanging, the test passes
+ // The test runner will fail if the process hangs
+ expect(true).toBe(true);
+ });
+});
diff --git a/typescript-sdk/src/integration-tests/stateManagementStreamableHttp.test.ts b/typescript-sdk/src/integration-tests/stateManagementStreamableHttp.test.ts
new file mode 100644
index 0000000..629b015
--- /dev/null
+++ b/typescript-sdk/src/integration-tests/stateManagementStreamableHttp.test.ts
@@ -0,0 +1,357 @@
+import { createServer, type Server } from 'node:http';
+import { AddressInfo } from 'node:net';
+import { randomUUID } from 'node:crypto';
+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,
+ LATEST_PROTOCOL_VERSION
+} from '../types.js';
+import { z } from 'zod';
+
+describe('Streamable HTTP Transport Session Management', () => {
+ // Function to set up the server with optional session management
+ async function setupServer(withSessionManagement: boolean) {
+ const server: Server = createServer();
+ const mcpServer = new McpServer(
+ { name: 'test-server', version: '1.0.0' },
+ {
+ capabilities: {
+ logging: {},
+ tools: {},
+ resources: {},
+ prompts: {}
+ }
+ }
+ );
+
+ // Add a simple resource
+ mcpServer.resource('test-resource', '/test', { description: 'A test resource' }, async () => ({
+ contents: [
+ {
+ uri: '/test',
+ text: 'This is a test resource content'
+ }
+ ]
+ }));
+
+ mcpServer.prompt('test-prompt', 'A test prompt', async () => ({
+ messages: [
+ {
+ role: 'user',
+ content: {
+ type: 'text',
+ text: 'This is a test prompt'
+ }
+ }
+ ]
+ }));
+
+ mcpServer.tool(
+ 'greet',
+ 'A simple greeting tool',
+ {
+ name: z.string().describe('Name to greet').default('World')
+ },
+ async ({ name }) => {
+ return {
+ content: [{ type: 'text', text: `Hello, ${name}!` }]
+ };
+ }
+ );
+
+ // Create transport with or without session management
+ const serverTransport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: withSessionManagement
+ ? () => randomUUID() // With session management, generate UUID
+ : undefined // Without session management, return undefined
+ });
+
+ await mcpServer.connect(serverTransport);
+
+ server.on('request', async (req, res) => {
+ await serverTransport.handleRequest(req, res);
+ });
+
+ // Start the server on a random port
+ const baseUrl = await new Promise(resolve => {
+ server.listen(0, '127.0.0.1', () => {
+ const addr = server.address() as AddressInfo;
+ resolve(new URL(`http://127.0.0.1:${addr.port}`));
+ });
+ });
+
+ return { server, mcpServer, serverTransport, baseUrl };
+ }
+
+ describe('Stateless Mode', () => {
+ let server: Server;
+ let mcpServer: McpServer;
+ let serverTransport: StreamableHTTPServerTransport;
+ let baseUrl: URL;
+
+ beforeEach(async () => {
+ const setup = await setupServer(false);
+ server = setup.server;
+ mcpServer = setup.mcpServer;
+ serverTransport = setup.serverTransport;
+ baseUrl = setup.baseUrl;
+ });
+
+ afterEach(async () => {
+ // Clean up resources
+ await mcpServer.close().catch(() => {});
+ await serverTransport.close().catch(() => {});
+ server.close();
+ });
+
+ it('should support multiple client connections', async () => {
+ // Create and connect a client
+ const client1 = new Client({
+ name: 'test-client',
+ version: '1.0.0'
+ });
+
+ const transport1 = new StreamableHTTPClientTransport(baseUrl);
+ await client1.connect(transport1);
+
+ // Verify that no session ID was set
+ expect(transport1.sessionId).toBeUndefined();
+
+ // List available tools
+ await client1.request(
+ {
+ method: 'tools/list',
+ params: {}
+ },
+ ListToolsResultSchema
+ );
+
+ const client2 = new Client({
+ name: 'test-client',
+ version: '1.0.0'
+ });
+
+ const transport2 = new StreamableHTTPClientTransport(baseUrl);
+ await client2.connect(transport2);
+
+ // Verify that no session ID was set
+ expect(transport2.sessionId).toBeUndefined();
+
+ // List available tools
+ await client2.request(
+ {
+ method: 'tools/list',
+ params: {}
+ },
+ ListToolsResultSchema
+ );
+ });
+ it('should operate without session management', async () => {
+ // Create and connect a client
+ const client = new Client({
+ name: 'test-client',
+ version: '1.0.0'
+ });
+
+ const transport = new StreamableHTTPClientTransport(baseUrl);
+ await client.connect(transport);
+
+ // Verify that no session ID was set
+ expect(transport.sessionId).toBeUndefined();
+
+ // List available tools
+ const toolsResult = await client.request(
+ {
+ method: 'tools/list',
+ params: {}
+ },
+ ListToolsResultSchema
+ );
+
+ // Verify tools are accessible
+ expect(toolsResult.tools).toContainEqual(
+ expect.objectContaining({
+ name: 'greet'
+ })
+ );
+
+ // List available resources
+ const resourcesResult = await client.request(
+ {
+ method: 'resources/list',
+ params: {}
+ },
+ ListResourcesResultSchema
+ );
+
+ // Verify resources result structure
+ expect(resourcesResult).toHaveProperty('resources');
+
+ // List available prompts
+ const promptsResult = await client.request(
+ {
+ method: 'prompts/list',
+ params: {}
+ },
+ ListPromptsResultSchema
+ );
+
+ // Verify prompts result structure
+ expect(promptsResult).toHaveProperty('prompts');
+ expect(promptsResult.prompts).toContainEqual(
+ expect.objectContaining({
+ name: 'test-prompt'
+ })
+ );
+
+ // Call the greeting tool
+ const greetingResult = await client.request(
+ {
+ method: 'tools/call',
+ params: {
+ name: 'greet',
+ arguments: {
+ name: 'Stateless Transport'
+ }
+ }
+ },
+ CallToolResultSchema
+ );
+
+ // Verify tool result
+ expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateless Transport!' }]);
+
+ // Clean up
+ await transport.close();
+ });
+
+ it('should set protocol version after connecting', async () => {
+ // Create and connect a client
+ const client = new Client({
+ name: 'test-client',
+ version: '1.0.0'
+ });
+
+ 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(LATEST_PROTOCOL_VERSION);
+
+ // Clean up
+ await transport.close();
+ });
+ });
+
+ describe('Stateful Mode', () => {
+ let server: Server;
+ let mcpServer: McpServer;
+ let serverTransport: StreamableHTTPServerTransport;
+ let baseUrl: URL;
+
+ beforeEach(async () => {
+ const setup = await setupServer(true);
+ server = setup.server;
+ mcpServer = setup.mcpServer;
+ serverTransport = setup.serverTransport;
+ baseUrl = setup.baseUrl;
+ });
+
+ afterEach(async () => {
+ // Clean up resources
+ await mcpServer.close().catch(() => {});
+ await serverTransport.close().catch(() => {});
+ server.close();
+ });
+
+ it('should operate with session management', async () => {
+ // Create and connect a client
+ const client = new Client({
+ name: 'test-client',
+ version: '1.0.0'
+ });
+
+ const transport = new StreamableHTTPClientTransport(baseUrl);
+ await client.connect(transport);
+
+ // Verify that a session ID was set
+ expect(transport.sessionId).toBeDefined();
+ expect(typeof transport.sessionId).toBe('string');
+
+ // List available tools
+ const toolsResult = await client.request(
+ {
+ method: 'tools/list',
+ params: {}
+ },
+ ListToolsResultSchema
+ );
+
+ // Verify tools are accessible
+ expect(toolsResult.tools).toContainEqual(
+ expect.objectContaining({
+ name: 'greet'
+ })
+ );
+
+ // List available resources
+ const resourcesResult = await client.request(
+ {
+ method: 'resources/list',
+ params: {}
+ },
+ ListResourcesResultSchema
+ );
+
+ // Verify resources result structure
+ expect(resourcesResult).toHaveProperty('resources');
+
+ // List available prompts
+ const promptsResult = await client.request(
+ {
+ method: 'prompts/list',
+ params: {}
+ },
+ ListPromptsResultSchema
+ );
+
+ // Verify prompts result structure
+ expect(promptsResult).toHaveProperty('prompts');
+ expect(promptsResult.prompts).toContainEqual(
+ expect.objectContaining({
+ name: 'test-prompt'
+ })
+ );
+
+ // Call the greeting tool
+ const greetingResult = await client.request(
+ {
+ method: 'tools/call',
+ params: {
+ name: 'greet',
+ arguments: {
+ name: 'Stateful Transport'
+ }
+ }
+ },
+ CallToolResultSchema
+ );
+
+ // Verify tool result
+ expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateful Transport!' }]);
+
+ // Clean up
+ await transport.close();
+ });
+ });
+});
diff --git a/typescript-sdk/src/integration-tests/taskResumability.test.ts b/typescript-sdk/src/integration-tests/taskResumability.test.ts
new file mode 100644
index 0000000..d397ffa
--- /dev/null
+++ b/typescript-sdk/src/integration-tests/taskResumability.test.ts
@@ -0,0 +1,270 @@
+import { createServer, type Server } from 'node:http';
+import { AddressInfo } from 'node:net';
+import { randomUUID } from 'node:crypto';
+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, LoggingMessageNotificationSchema } from '../types.js';
+import { z } from 'zod';
+import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js';
+
+describe('Transport resumability', () => {
+ let server: Server;
+ let mcpServer: McpServer;
+ let serverTransport: StreamableHTTPServerTransport;
+ let baseUrl: URL;
+ let eventStore: InMemoryEventStore;
+
+ beforeEach(async () => {
+ // Create event store for resumability
+ eventStore = new InMemoryEventStore();
+
+ // Create a simple MCP server
+ mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } });
+
+ // Add a simple notification tool that completes quickly
+ mcpServer.tool(
+ 'send-notification',
+ 'Sends a single notification',
+ {
+ message: z.string().describe('Message to send').default('Test notification')
+ },
+ async ({ message }, { sendNotification }) => {
+ // Send notification immediately
+ await sendNotification({
+ method: 'notifications/message',
+ params: {
+ level: 'info',
+ data: message
+ }
+ });
+
+ return {
+ content: [{ type: 'text', text: 'Notification sent' }]
+ };
+ }
+ );
+
+ // Add a long-running tool that sends multiple notifications
+ mcpServer.tool(
+ 'run-notifications',
+ 'Sends multiple notifications over time',
+ {
+ count: z.number().describe('Number of notifications to send').default(10),
+ interval: z.number().describe('Interval between notifications in ms').default(50)
+ },
+ async ({ count, interval }, { sendNotification }) => {
+ // Send notifications at specified intervals
+ for (let i = 0; i < count; i++) {
+ await sendNotification({
+ method: 'notifications/message',
+ params: {
+ level: 'info',
+ data: `Notification ${i + 1} of ${count}`
+ }
+ });
+
+ // Wait for the specified interval before sending next notification
+ if (i < count - 1) {
+ await new Promise(resolve => setTimeout(resolve, interval));
+ }
+ }
+
+ return {
+ content: [{ type: 'text', text: `Sent ${count} notifications` }]
+ };
+ }
+ );
+
+ // Create a transport with the event store
+ serverTransport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ eventStore
+ });
+
+ // Connect the transport to the MCP server
+ await mcpServer.connect(serverTransport);
+
+ // Create and start an HTTP server
+ server = createServer(async (req, res) => {
+ await serverTransport.handleRequest(req, res);
+ });
+
+ // Start the server on a random port
+ baseUrl = await new Promise(resolve => {
+ server.listen(0, '127.0.0.1', () => {
+ const addr = server.address() as AddressInfo;
+ resolve(new URL(`http://127.0.0.1:${addr.port}`));
+ });
+ });
+ });
+
+ afterEach(async () => {
+ // Clean up resources
+ await mcpServer.close().catch(() => {});
+ await serverTransport.close().catch(() => {});
+ server.close();
+ });
+
+ it('should store session ID when client connects', async () => {
+ // Create and connect a client
+ const client = new Client({
+ name: 'test-client',
+ version: '1.0.0'
+ });
+
+ const transport = new StreamableHTTPClientTransport(baseUrl);
+ await client.connect(transport);
+
+ // Verify session ID was generated
+ expect(transport.sessionId).toBeDefined();
+
+ // Clean up
+ await transport.close();
+ });
+
+ it('should have session ID functionality', async () => {
+ // The ability to store a session ID when connecting
+ const client = new Client({
+ name: 'test-client-reconnection',
+ version: '1.0.0'
+ });
+
+ const transport = new StreamableHTTPClientTransport(baseUrl);
+
+ // Make sure the client can connect and get a session ID
+ await client.connect(transport);
+ expect(transport.sessionId).toBeDefined();
+
+ // Clean up
+ await transport.close();
+ });
+
+ // This test demonstrates the capability to resume long-running tools
+ // across client disconnection/reconnection
+ it('should resume long-running notifications with lastEventId', async () => {
+ // Create unique client ID for this test
+ const clientId = 'test-client-long-running';
+ const notifications = [];
+ let lastEventId: string | undefined;
+
+ // Create first client
+ const client1 = new Client({
+ id: clientId,
+ name: 'test-client',
+ version: '1.0.0'
+ });
+
+ // Set up notification handler for first client
+ client1.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
+ if (notification.method === 'notifications/message') {
+ notifications.push(notification.params);
+ }
+ });
+
+ // Connect first client
+ const transport1 = new StreamableHTTPClientTransport(baseUrl);
+ await client1.connect(transport1);
+ const sessionId = transport1.sessionId;
+ expect(sessionId).toBeDefined();
+
+ // Start a long-running notification stream with tracking of lastEventId
+ const onLastEventIdUpdate = jest.fn((eventId: string) => {
+ lastEventId = eventId;
+ });
+ expect(lastEventId).toBeUndefined();
+ // Start the notification tool with event tracking using request
+ const toolPromise = client1.request(
+ {
+ method: 'tools/call',
+ params: {
+ name: 'run-notifications',
+ arguments: {
+ count: 3,
+ interval: 10
+ }
+ }
+ },
+ CallToolResultSchema,
+ {
+ resumptionToken: lastEventId,
+ onresumptiontoken: onLastEventIdUpdate
+ }
+ );
+
+ // Wait for some notifications to arrive (not all) - shorter wait time
+ await new Promise(resolve => setTimeout(resolve, 20));
+
+ // Verify we received some notifications and lastEventId was updated
+ expect(notifications.length).toBeGreaterThan(0);
+ expect(notifications.length).toBeLessThan(4);
+ expect(onLastEventIdUpdate).toHaveBeenCalled();
+ expect(lastEventId).toBeDefined();
+
+ // Disconnect first client without waiting for completion
+ // When we close the connection, it will cause a ConnectionClosed error for
+ // any in-progress requests, which is expected behavior
+ await transport1.close();
+ // Save the promise so we can catch it after closing
+ const catchPromise = toolPromise.catch(err => {
+ // This error is expected - the connection was intentionally closed
+ if (err?.code !== -32000) {
+ // ConnectionClosed error code
+ console.error('Unexpected error type during transport close:', err);
+ }
+ });
+
+ // Add a short delay to ensure clean disconnect before reconnecting
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ // Wait for the rejection to be handled
+ await catchPromise;
+
+ // Create second client with same client ID
+ const client2 = new Client({
+ id: clientId,
+ name: 'test-client',
+ version: '1.0.0'
+ });
+
+ // Set up notification handler for second client
+ client2.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
+ if (notification.method === 'notifications/message') {
+ notifications.push(notification.params);
+ }
+ });
+
+ // Connect second client with same session ID
+ const transport2 = new StreamableHTTPClientTransport(baseUrl, {
+ sessionId
+ });
+ await client2.connect(transport2);
+
+ // Resume the notification stream using lastEventId
+ // This is the key part - we're resuming the same long-running tool using lastEventId
+ await client2.request(
+ {
+ method: 'tools/call',
+ params: {
+ name: 'run-notifications',
+ arguments: {
+ count: 1,
+ interval: 5
+ }
+ }
+ },
+ CallToolResultSchema,
+ {
+ resumptionToken: lastEventId, // Pass the lastEventId from the previous session
+ onresumptiontoken: onLastEventIdUpdate
+ }
+ );
+
+ // Verify we eventually received at leaset a few motifications
+ expect(notifications.length).toBeGreaterThan(1);
+
+ // Clean up
+ await transport2.close();
+ });
+});
diff --git a/typescript-sdk/src/server/auth/clients.ts b/typescript-sdk/src/server/auth/clients.ts
new file mode 100644
index 0000000..4e3f8e1
--- /dev/null
+++ b/typescript-sdk/src/server/auth/clients.ts
@@ -0,0 +1,22 @@
+import { OAuthClientInformationFull } from '../../shared/auth.js';
+
+/**
+ * Stores information about registered OAuth clients for this server.
+ */
+export interface OAuthRegisteredClientsStore {
+ /**
+ * Returns information about a registered client, based on its ID.
+ */
+ getClient(clientId: string): OAuthClientInformationFull | undefined | Promise;
+
+ /**
+ * Registers a new client with the server. The client ID and secret will be automatically generated by the library. A modified version of the client information can be returned to reflect specific values enforced by the server.
+ *
+ * NOTE: Implementations should NOT delete expired client secrets in-place. Auth middleware provided by this library will automatically check the `client_secret_expires_at` field and reject requests with expired secrets. Any custom logic for authenticating clients should check the `client_secret_expires_at` field as well.
+ *
+ * If unimplemented, dynamic client registration is unsupported.
+ */
+ registerClient?(
+ client: Omit
+ ): OAuthClientInformationFull | Promise;
+}
diff --git a/typescript-sdk/src/server/auth/errors.ts b/typescript-sdk/src/server/auth/errors.ts
new file mode 100644
index 0000000..e6871a1
--- /dev/null
+++ b/typescript-sdk/src/server/auth/errors.ts
@@ -0,0 +1,203 @@
+import { OAuthErrorResponse } from '../../shared/auth.js';
+
+/**
+ * Base class for all OAuth errors
+ */
+export class OAuthError extends Error {
+ static errorCode: string;
+
+ constructor(
+ message: string,
+ public readonly errorUri?: string
+ ) {
+ super(message);
+ this.name = this.constructor.name;
+ }
+
+ /**
+ * Converts the error to a standard OAuth error response object
+ */
+ toResponseObject(): OAuthErrorResponse {
+ const response: OAuthErrorResponse = {
+ error: this.errorCode,
+ error_description: this.message
+ };
+
+ if (this.errorUri) {
+ response.error_uri = this.errorUri;
+ }
+
+ return response;
+ }
+
+ get errorCode(): string {
+ return (this.constructor as typeof OAuthError).errorCode;
+ }
+}
+
+/**
+ * Invalid request error - The request is missing a required parameter,
+ * includes an invalid parameter value, includes a parameter more than once,
+ * or is otherwise malformed.
+ */
+export class InvalidRequestError extends OAuthError {
+ static errorCode = 'invalid_request';
+}
+
+/**
+ * Invalid client error - Client authentication failed (e.g., unknown client, no client
+ * authentication included, or unsupported authentication method).
+ */
+export class InvalidClientError extends OAuthError {
+ static errorCode = 'invalid_client';
+}
+
+/**
+ * Invalid grant error - The provided authorization grant or refresh token is
+ * invalid, expired, revoked, does not match the redirection URI used in the
+ * authorization request, or was issued to another client.
+ */
+export class InvalidGrantError extends OAuthError {
+ static errorCode = 'invalid_grant';
+}
+
+/**
+ * Unauthorized client error - The authenticated client is not authorized to use
+ * this authorization grant type.
+ */
+export class UnauthorizedClientError extends OAuthError {
+ static errorCode = 'unauthorized_client';
+}
+
+/**
+ * Unsupported grant type error - The authorization grant type is not supported
+ * by the authorization server.
+ */
+export class UnsupportedGrantTypeError extends OAuthError {
+ static errorCode = 'unsupported_grant_type';
+}
+
+/**
+ * Invalid scope error - The requested scope is invalid, unknown, malformed, or
+ * exceeds the scope granted by the resource owner.
+ */
+export class InvalidScopeError extends OAuthError {
+ static errorCode = 'invalid_scope';
+}
+
+/**
+ * Access denied error - The resource owner or authorization server denied the request.
+ */
+export class AccessDeniedError extends OAuthError {
+ static errorCode = 'access_denied';
+}
+
+/**
+ * Server error - The authorization server encountered an unexpected condition
+ * that prevented it from fulfilling the request.
+ */
+export class ServerError extends OAuthError {
+ static errorCode = 'server_error';
+}
+
+/**
+ * Temporarily unavailable error - The authorization server is currently unable to
+ * handle the request due to a temporary overloading or maintenance of the server.
+ */
+export class TemporarilyUnavailableError extends OAuthError {
+ static errorCode = 'temporarily_unavailable';
+}
+
+/**
+ * Unsupported response type error - The authorization server does not support
+ * obtaining an authorization code using this method.
+ */
+export class UnsupportedResponseTypeError extends OAuthError {
+ static errorCode = 'unsupported_response_type';
+}
+
+/**
+ * Unsupported token type error - The authorization server does not support
+ * the requested token type.
+ */
+export class UnsupportedTokenTypeError extends OAuthError {
+ static errorCode = 'unsupported_token_type';
+}
+
+/**
+ * Invalid token error - The access token provided is expired, revoked, malformed,
+ * or invalid for other reasons.
+ */
+export class InvalidTokenError extends OAuthError {
+ static errorCode = 'invalid_token';
+}
+
+/**
+ * Method not allowed error - The HTTP method used is not allowed for this endpoint.
+ * (Custom, non-standard error)
+ */
+export class MethodNotAllowedError extends OAuthError {
+ static errorCode = 'method_not_allowed';
+}
+
+/**
+ * Too many requests error - Rate limit exceeded.
+ * (Custom, non-standard error based on RFC 6585)
+ */
+export class TooManyRequestsError extends OAuthError {
+ static errorCode = 'too_many_requests';
+}
+
+/**
+ * Invalid client metadata error - The client metadata is invalid.
+ * (Custom error for dynamic client registration - RFC 7591)
+ */
+export class InvalidClientMetadataError extends OAuthError {
+ static errorCode = 'invalid_client_metadata';
+}
+
+/**
+ * Insufficient scope error - The request requires higher privileges than provided by the access token.
+ */
+export class InsufficientScopeError extends OAuthError {
+ static errorCode = 'insufficient_scope';
+}
+
+/**
+ * A utility class for defining one-off error codes
+ */
+export class CustomOAuthError extends OAuthError {
+ constructor(
+ private readonly customErrorCode: string,
+ message: string,
+ errorUri?: string
+ ) {
+ super(message, errorUri);
+ }
+
+ get errorCode(): string {
+ return this.customErrorCode;
+ }
+}
+
+/**
+ * A full list of all OAuthErrors, enabling parsing from error responses
+ */
+export const OAUTH_ERRORS = {
+ [InvalidRequestError.errorCode]: InvalidRequestError,
+ [InvalidClientError.errorCode]: InvalidClientError,
+ [InvalidGrantError.errorCode]: InvalidGrantError,
+ [UnauthorizedClientError.errorCode]: UnauthorizedClientError,
+ [UnsupportedGrantTypeError.errorCode]: UnsupportedGrantTypeError,
+ [InvalidScopeError.errorCode]: InvalidScopeError,
+ [AccessDeniedError.errorCode]: AccessDeniedError,
+ [ServerError.errorCode]: ServerError,
+ [TemporarilyUnavailableError.errorCode]: TemporarilyUnavailableError,
+ [UnsupportedResponseTypeError.errorCode]: UnsupportedResponseTypeError,
+ [UnsupportedTokenTypeError.errorCode]: UnsupportedTokenTypeError,
+ [InvalidTokenError.errorCode]: InvalidTokenError,
+ [MethodNotAllowedError.errorCode]: MethodNotAllowedError,
+ [TooManyRequestsError.errorCode]: TooManyRequestsError,
+ [InvalidClientMetadataError.errorCode]: InvalidClientMetadataError,
+ [InsufficientScopeError.errorCode]: InsufficientScopeError
+} as const;
diff --git a/typescript-sdk/src/server/auth/handlers/authorize.test.ts b/typescript-sdk/src/server/auth/handlers/authorize.test.ts
new file mode 100644
index 0000000..5dabd19
--- /dev/null
+++ b/typescript-sdk/src/server/auth/handlers/authorize.test.ts
@@ -0,0 +1,326 @@
+import { authorizationHandler, AuthorizationHandlerOptions } from './authorize.js';
+import { OAuthServerProvider, AuthorizationParams } from '../provider.js';
+import { OAuthRegisteredClientsStore } from '../clients.js';
+import { OAuthClientInformationFull, OAuthTokens } from '../../../shared/auth.js';
+import express, { Response } from 'express';
+import supertest from 'supertest';
+import { AuthInfo } from '../types.js';
+import { InvalidTokenError } from '../errors.js';
+
+describe('Authorization Handler', () => {
+ // Mock client data
+ const validClient: OAuthClientInformationFull = {
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ redirect_uris: ['https://example.com/callback'],
+ scope: 'profile email'
+ };
+
+ const multiRedirectClient: OAuthClientInformationFull = {
+ client_id: 'multi-redirect-client',
+ client_secret: 'valid-secret',
+ redirect_uris: ['https://example.com/callback1', 'https://example.com/callback2'],
+ scope: 'profile email'
+ };
+
+ // Mock client store
+ const mockClientStore: OAuthRegisteredClientsStore = {
+ async getClient(clientId: string): Promise {
+ if (clientId === 'valid-client') {
+ return validClient;
+ } else if (clientId === 'multi-redirect-client') {
+ return multiRedirectClient;
+ }
+ return undefined;
+ }
+ };
+
+ // Mock provider
+ const mockProvider: OAuthServerProvider = {
+ clientsStore: mockClientStore,
+
+ async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise {
+ // Mock implementation - redirects to redirectUri with code and state
+ const redirectUrl = new URL(params.redirectUri);
+ redirectUrl.searchParams.set('code', 'mock_auth_code');
+ if (params.state) {
+ redirectUrl.searchParams.set('state', params.state);
+ }
+ res.redirect(302, redirectUrl.toString());
+ },
+
+ async challengeForAuthorizationCode(): Promise {
+ return 'mock_challenge';
+ },
+
+ async exchangeAuthorizationCode(): Promise {
+ return {
+ access_token: 'mock_access_token',
+ token_type: 'bearer',
+ expires_in: 3600,
+ refresh_token: 'mock_refresh_token'
+ };
+ },
+
+ async exchangeRefreshToken(): Promise {
+ return {
+ access_token: 'new_mock_access_token',
+ token_type: 'bearer',
+ expires_in: 3600,
+ refresh_token: 'new_mock_refresh_token'
+ };
+ },
+
+ async verifyAccessToken(token: string): Promise {
+ if (token === 'valid_token') {
+ return {
+ token,
+ clientId: 'valid-client',
+ scopes: ['read', 'write'],
+ expiresAt: Date.now() / 1000 + 3600
+ };
+ }
+ throw new InvalidTokenError('Token is invalid or expired');
+ },
+
+ async revokeToken(): Promise {
+ // Do nothing in mock
+ }
+ };
+
+ // Setup express app with handler
+ let app: express.Express;
+ let options: AuthorizationHandlerOptions;
+
+ beforeEach(() => {
+ app = express();
+ options = { provider: mockProvider };
+ const handler = authorizationHandler(options);
+ app.use('/authorize', handler);
+ });
+
+ describe('HTTP method validation', () => {
+ it('rejects non-GET/POST methods', async () => {
+ const response = await supertest(app).put('/authorize').query({ client_id: 'valid-client' });
+
+ expect(response.status).toBe(405); // Method not allowed response from handler
+ });
+ });
+
+ describe('Client validation', () => {
+ it('requires client_id parameter', async () => {
+ const response = await supertest(app).get('/authorize');
+
+ expect(response.status).toBe(400);
+ expect(response.text).toContain('client_id');
+ });
+
+ it('validates that client exists', async () => {
+ const response = await supertest(app).get('/authorize').query({ client_id: 'nonexistent-client' });
+
+ expect(response.status).toBe(400);
+ });
+ });
+
+ describe('Redirect URI validation', () => {
+ it('uses the only redirect_uri if client has just one and none provided', async () => {
+ const response = await supertest(app).get('/authorize').query({
+ client_id: 'valid-client',
+ response_type: 'code',
+ code_challenge: 'challenge123',
+ code_challenge_method: 'S256'
+ });
+
+ expect(response.status).toBe(302);
+ const location = new URL(response.header.location);
+ expect(location.origin + location.pathname).toBe('https://example.com/callback');
+ });
+
+ it('requires redirect_uri if client has multiple', async () => {
+ const response = await supertest(app).get('/authorize').query({
+ client_id: 'multi-redirect-client',
+ response_type: 'code',
+ code_challenge: 'challenge123',
+ code_challenge_method: 'S256'
+ });
+
+ expect(response.status).toBe(400);
+ });
+
+ it('validates redirect_uri against client registered URIs', async () => {
+ const response = await supertest(app).get('/authorize').query({
+ client_id: 'valid-client',
+ redirect_uri: 'https://malicious.com/callback',
+ response_type: 'code',
+ code_challenge: 'challenge123',
+ code_challenge_method: 'S256'
+ });
+
+ expect(response.status).toBe(400);
+ });
+
+ it('accepts valid redirect_uri that client registered with', async () => {
+ 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'
+ });
+
+ expect(response.status).toBe(302);
+ const location = new URL(response.header.location);
+ expect(location.origin + location.pathname).toBe('https://example.com/callback');
+ });
+ });
+
+ describe('Authorization request validation', () => {
+ it('requires response_type=code', async () => {
+ const response = await supertest(app).get('/authorize').query({
+ client_id: 'valid-client',
+ redirect_uri: 'https://example.com/callback',
+ response_type: 'token', // invalid - we only support code flow
+ code_challenge: 'challenge123',
+ code_challenge_method: 'S256'
+ });
+
+ expect(response.status).toBe(302);
+ const location = new URL(response.header.location);
+ expect(location.searchParams.get('error')).toBe('invalid_request');
+ });
+
+ it('requires code_challenge parameter', async () => {
+ const response = await supertest(app).get('/authorize').query({
+ client_id: 'valid-client',
+ redirect_uri: 'https://example.com/callback',
+ response_type: 'code',
+ code_challenge_method: 'S256'
+ // Missing code_challenge
+ });
+
+ expect(response.status).toBe(302);
+ const location = new URL(response.header.location);
+ expect(location.searchParams.get('error')).toBe('invalid_request');
+ });
+
+ it('requires code_challenge_method=S256', async () => {
+ 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: 'plain' // Only S256 is supported
+ });
+
+ expect(response.status).toBe(302);
+ const location = new URL(response.header.location);
+ expect(location.searchParams.get('error')).toBe('invalid_request');
+ });
+ });
+
+ describe('Scope validation', () => {
+ it('validates requested scopes against client registered scopes', async () => {
+ 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',
+ scope: 'profile email admin' // 'admin' not in client scopes
+ });
+
+ expect(response.status).toBe(302);
+ const location = new URL(response.header.location);
+ expect(location.searchParams.get('error')).toBe('invalid_scope');
+ });
+
+ it('accepts valid scopes subset', async () => {
+ 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',
+ scope: 'profile' // subset of client scopes
+ });
+
+ expect(response.status).toBe(302);
+ const location = new URL(response.header.location);
+ expect(location.searchParams.has('code')).toBe(true);
+ });
+ });
+
+ 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).get('/authorize').query({
+ client_id: 'valid-client',
+ redirect_uri: 'https://example.com/callback',
+ response_type: 'code',
+ code_challenge: 'challenge123',
+ code_challenge_method: 'S256',
+ scope: 'profile email',
+ state: 'xyz789'
+ });
+
+ expect(response.status).toBe(302);
+ const location = new URL(response.header.location);
+ expect(location.origin + location.pathname).toBe('https://example.com/callback');
+ expect(location.searchParams.get('code')).toBe('mock_auth_code');
+ expect(location.searchParams.get('state')).toBe('xyz789');
+ });
+
+ it('preserves state parameter in response', async () => {
+ 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',
+ state: 'state-value-123'
+ });
+
+ expect(response.status).toBe(302);
+ const location = new URL(response.header.location);
+ expect(location.searchParams.get('state')).toBe('state-value-123');
+ });
+
+ it('handles POST requests the same as GET', async () => {
+ const response = await supertest(app).post('/authorize').type('form').send({
+ client_id: 'valid-client',
+ response_type: 'code',
+ code_challenge: 'challenge123',
+ code_challenge_method: 'S256'
+ });
+
+ expect(response.status).toBe(302);
+ const location = new URL(response.header.location);
+ expect(location.searchParams.has('code')).toBe(true);
+ });
+ });
+});
diff --git a/typescript-sdk/src/server/auth/handlers/authorize.ts b/typescript-sdk/src/server/auth/handlers/authorize.ts
new file mode 100644
index 0000000..14c7121
--- /dev/null
+++ b/typescript-sdk/src/server/auth/handlers/authorize.ts
@@ -0,0 +1,173 @@
+import { RequestHandler } from 'express';
+import { z } from 'zod';
+import express from 'express';
+import { OAuthServerProvider } from '../provider.js';
+import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit';
+import { allowedMethods } from '../middleware/allowedMethods.js';
+import { InvalidRequestError, InvalidClientError, InvalidScopeError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js';
+
+export type AuthorizationHandlerOptions = {
+ provider: OAuthServerProvider;
+ /**
+ * Rate limiting configuration for the authorization endpoint.
+ * Set to false to disable rate limiting for this endpoint.
+ */
+ rateLimit?: Partial | false;
+};
+
+// Parameters that must be validated in order to issue redirects.
+const ClientAuthorizationParamsSchema = z.object({
+ client_id: z.string(),
+ redirect_uri: z
+ .string()
+ .optional()
+ .refine(value => value === undefined || URL.canParse(value), { message: 'redirect_uri must be a valid URL' })
+});
+
+// Parameters that must be validated for a successful authorization request. Failure can be reported to the redirect URI.
+const RequestAuthorizationParamsSchema = z.object({
+ response_type: z.literal('code'),
+ code_challenge: z.string(),
+ 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 {
+ // Create a router to apply middleware
+ const router = express.Router();
+ router.use(allowedMethods(['GET', 'POST']));
+ router.use(express.urlencoded({ extended: false }));
+
+ // Apply rate limiting unless explicitly disabled
+ if (rateLimitConfig !== false) {
+ router.use(
+ rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ max: 100, // 100 requests per windowMs
+ standardHeaders: true,
+ legacyHeaders: false,
+ message: new TooManyRequestsError('You have exceeded the rate limit for authorization requests').toResponseObject(),
+ ...rateLimitConfig
+ })
+ );
+ }
+
+ router.all('/', async (req, res) => {
+ res.setHeader('Cache-Control', 'no-store');
+
+ // In the authorization flow, errors are split into two categories:
+ // 1. Pre-redirect errors (direct response with 400)
+ // 2. Post-redirect errors (redirect with error parameters)
+
+ // Phase 1: Validate client_id and redirect_uri. Any errors here must be direct responses.
+ let client_id, redirect_uri, client;
+ try {
+ const result = ClientAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query);
+ if (!result.success) {
+ throw new InvalidRequestError(result.error.message);
+ }
+
+ client_id = result.data.client_id;
+ redirect_uri = result.data.redirect_uri;
+
+ client = await provider.clientsStore.getClient(client_id);
+ if (!client) {
+ throw new InvalidClientError('Invalid client_id');
+ }
+
+ if (redirect_uri !== undefined) {
+ if (!client.redirect_uris.includes(redirect_uri)) {
+ throw new InvalidRequestError('Unregistered redirect_uri');
+ }
+ } else if (client.redirect_uris.length === 1) {
+ redirect_uri = client.redirect_uris[0];
+ } else {
+ throw new InvalidRequestError('redirect_uri must be specified when client has multiple registered URIs');
+ }
+ } catch (error) {
+ // Pre-redirect errors - return direct response
+ //
+ // These don't need to be JSON encoded, as they'll be displayed in a user
+ // agent, but OTOH they all represent exceptional situations (arguably,
+ // "programmer error"), so presenting a nice HTML page doesn't help the
+ // user anyway.
+ if (error instanceof OAuthError) {
+ const status = error instanceof ServerError ? 500 : 400;
+ res.status(status).json(error.toResponseObject());
+ } else {
+ const serverError = new ServerError('Internal Server Error');
+ res.status(500).json(serverError.toResponseObject());
+ }
+
+ return;
+ }
+
+ // Phase 2: Validate other parameters. Any errors here should go into redirect responses.
+ let state;
+ try {
+ // Parse and validate authorization parameters
+ const parseResult = RequestAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query);
+ if (!parseResult.success) {
+ throw new InvalidRequestError(parseResult.error.message);
+ }
+
+ const { scope, code_challenge, resource } = parseResult.data;
+ state = parseResult.data.state;
+
+ // Validate scopes
+ let requestedScopes: string[] = [];
+ if (scope !== undefined) {
+ requestedScopes = scope.split(' ');
+ const allowedScopes = new Set(client.scope?.split(' '));
+
+ // Check each requested scope against allowed scopes
+ for (const scope of requestedScopes) {
+ if (!allowedScopes.has(scope)) {
+ throw new InvalidScopeError(`Client was not registered with scope ${scope}`);
+ }
+ }
+ }
+
+ // All validation passed, proceed with authorization
+ await provider.authorize(
+ client,
+ {
+ state,
+ 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 {
+ const serverError = new ServerError('Internal Server Error');
+ res.redirect(302, createErrorRedirect(redirect_uri, serverError, state));
+ }
+ }
+ });
+
+ return router;
+}
+
+/**
+ * Helper function to create redirect URL with error parameters
+ */
+function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string): string {
+ const errorUrl = new URL(redirectUri);
+ errorUrl.searchParams.set('error', error.errorCode);
+ errorUrl.searchParams.set('error_description', error.message);
+ if (error.errorUri) {
+ errorUrl.searchParams.set('error_uri', error.errorUri);
+ }
+ if (state) {
+ errorUrl.searchParams.set('state', state);
+ }
+ return errorUrl.href;
+}
diff --git a/typescript-sdk/src/server/auth/handlers/metadata.test.ts b/typescript-sdk/src/server/auth/handlers/metadata.test.ts
new file mode 100644
index 0000000..32feb64
--- /dev/null
+++ b/typescript-sdk/src/server/auth/handlers/metadata.test.ts
@@ -0,0 +1,78 @@
+import { metadataHandler } from './metadata.js';
+import { OAuthMetadata } from '../../../shared/auth.js';
+import express from 'express';
+import supertest from 'supertest';
+
+describe('Metadata Handler', () => {
+ const exampleMetadata: OAuthMetadata = {
+ issuer: 'https://auth.example.com',
+ authorization_endpoint: 'https://auth.example.com/authorize',
+ token_endpoint: 'https://auth.example.com/token',
+ registration_endpoint: 'https://auth.example.com/register',
+ revocation_endpoint: 'https://auth.example.com/revoke',
+ scopes_supported: ['profile', 'email'],
+ response_types_supported: ['code'],
+ grant_types_supported: ['authorization_code', 'refresh_token'],
+ token_endpoint_auth_methods_supported: ['client_secret_basic'],
+ code_challenge_methods_supported: ['S256']
+ };
+
+ let app: express.Express;
+
+ beforeEach(() => {
+ // Setup express app with metadata handler
+ app = express();
+ app.use('/.well-known/oauth-authorization-server', metadataHandler(exampleMetadata));
+ });
+
+ it('requires GET method', async () => {
+ const response = await supertest(app).post('/.well-known/oauth-authorization-server').send({});
+
+ expect(response.status).toBe(405);
+ expect(response.headers.allow).toBe('GET');
+ expect(response.body).toEqual({
+ error: 'method_not_allowed',
+ error_description: 'The method POST is not allowed for this endpoint'
+ });
+ });
+
+ it('returns the metadata object', async () => {
+ const response = await supertest(app).get('/.well-known/oauth-authorization-server');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(exampleMetadata);
+ });
+
+ it('includes CORS headers in response', async () => {
+ const response = await supertest(app).get('/.well-known/oauth-authorization-server').set('Origin', 'https://example.com');
+
+ expect(response.header['access-control-allow-origin']).toBe('*');
+ });
+
+ it('supports OPTIONS preflight requests', async () => {
+ const response = await supertest(app)
+ .options('/.well-known/oauth-authorization-server')
+ .set('Origin', 'https://example.com')
+ .set('Access-Control-Request-Method', 'GET');
+
+ expect(response.status).toBe(204);
+ expect(response.header['access-control-allow-origin']).toBe('*');
+ });
+
+ it('works with minimal metadata', async () => {
+ // Setup a new express app with minimal metadata
+ const minimalApp = express();
+ const minimalMetadata: OAuthMetadata = {
+ issuer: 'https://auth.example.com',
+ authorization_endpoint: 'https://auth.example.com/authorize',
+ token_endpoint: 'https://auth.example.com/token',
+ response_types_supported: ['code']
+ };
+ minimalApp.use('/.well-known/oauth-authorization-server', metadataHandler(minimalMetadata));
+
+ const response = await supertest(minimalApp).get('/.well-known/oauth-authorization-server');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual(minimalMetadata);
+ });
+});
diff --git a/typescript-sdk/src/server/auth/handlers/metadata.ts b/typescript-sdk/src/server/auth/handlers/metadata.ts
new file mode 100644
index 0000000..d8ca0e6
--- /dev/null
+++ b/typescript-sdk/src/server/auth/handlers/metadata.ts
@@ -0,0 +1,19 @@
+import express, { RequestHandler } from 'express';
+import { OAuthMetadata, OAuthProtectedResourceMetadata } from '../../../shared/auth.js';
+import cors from 'cors';
+import { allowedMethods } from '../middleware/allowedMethods.js';
+
+export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler {
+ // Nested router so we can configure middleware and restrict HTTP method
+ const router = express.Router();
+
+ // Configure CORS to allow any origin, to make accessible to web-based MCP clients
+ router.use(cors());
+
+ router.use(allowedMethods(['GET']));
+ router.get('/', (req, res) => {
+ res.status(200).json(metadata);
+ });
+
+ return router;
+}
diff --git a/typescript-sdk/src/server/auth/handlers/register.test.ts b/typescript-sdk/src/server/auth/handlers/register.test.ts
new file mode 100644
index 0000000..c482143
--- /dev/null
+++ b/typescript-sdk/src/server/auth/handlers/register.test.ts
@@ -0,0 +1,271 @@
+import { clientRegistrationHandler, ClientRegistrationHandlerOptions } from './register.js';
+import { OAuthRegisteredClientsStore } from '../clients.js';
+import { OAuthClientInformationFull, OAuthClientMetadata } from '../../../shared/auth.js';
+import express from 'express';
+import supertest from 'supertest';
+
+describe('Client Registration Handler', () => {
+ // Mock client store with registration support
+ const mockClientStoreWithRegistration: OAuthRegisteredClientsStore = {
+ async getClient(_clientId: string): Promise {
+ return undefined;
+ },
+
+ async registerClient(client: OAuthClientInformationFull): Promise {
+ // Return the client info as-is in the mock
+ return client;
+ }
+ };
+
+ // Mock client store without registration support
+ const mockClientStoreWithoutRegistration: OAuthRegisteredClientsStore = {
+ async getClient(_clientId: string): Promise {
+ return undefined;
+ }
+ // No registerClient method
+ };
+
+ describe('Handler creation', () => {
+ it('throws error if client store does not support registration', () => {
+ const options: ClientRegistrationHandlerOptions = {
+ clientsStore: mockClientStoreWithoutRegistration
+ };
+
+ expect(() => clientRegistrationHandler(options)).toThrow('does not support registering clients');
+ });
+
+ it('creates handler if client store supports registration', () => {
+ const options: ClientRegistrationHandlerOptions = {
+ clientsStore: mockClientStoreWithRegistration
+ };
+
+ expect(() => clientRegistrationHandler(options)).not.toThrow();
+ });
+ });
+
+ describe('Request handling', () => {
+ let app: express.Express;
+ let spyRegisterClient: jest.SpyInstance;
+
+ beforeEach(() => {
+ // Setup express app with registration handler
+ app = express();
+ const options: ClientRegistrationHandlerOptions = {
+ clientsStore: mockClientStoreWithRegistration,
+ clientSecretExpirySeconds: 86400 // 1 day for testing
+ };
+
+ app.use('/register', clientRegistrationHandler(options));
+
+ // Spy on the registerClient method
+ spyRegisterClient = jest.spyOn(mockClientStoreWithRegistration, 'registerClient');
+ });
+
+ afterEach(() => {
+ spyRegisterClient.mockRestore();
+ });
+
+ it('requires POST method', async () => {
+ const response = await supertest(app)
+ .get('/register')
+ .send({
+ redirect_uris: ['https://example.com/callback']
+ });
+
+ expect(response.status).toBe(405);
+ expect(response.headers.allow).toBe('POST');
+ expect(response.body).toEqual({
+ error: 'method_not_allowed',
+ error_description: 'The method GET is not allowed for this endpoint'
+ });
+ expect(spyRegisterClient).not.toHaveBeenCalled();
+ });
+
+ it('validates required client metadata', async () => {
+ const response = await supertest(app).post('/register').send({
+ // Missing redirect_uris (required)
+ client_name: 'Test Client'
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_client_metadata');
+ expect(spyRegisterClient).not.toHaveBeenCalled();
+ });
+
+ it('validates redirect URIs format', async () => {
+ const response = await supertest(app)
+ .post('/register')
+ .send({
+ redirect_uris: ['invalid-url'] // Invalid URL format
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_client_metadata');
+ expect(response.body.error_description).toContain('redirect_uris');
+ expect(spyRegisterClient).not.toHaveBeenCalled();
+ });
+
+ it('successfully registers client with minimal metadata', async () => {
+ const clientMetadata: OAuthClientMetadata = {
+ redirect_uris: ['https://example.com/callback']
+ };
+
+ const response = await supertest(app).post('/register').send(clientMetadata);
+
+ expect(response.status).toBe(201);
+
+ // Verify the generated client information
+ expect(response.body.client_id).toBeDefined();
+ expect(response.body.client_secret).toBeDefined();
+ expect(response.body.client_id_issued_at).toBeDefined();
+ expect(response.body.client_secret_expires_at).toBeDefined();
+ expect(response.body.redirect_uris).toEqual(['https://example.com/callback']);
+
+ // Verify client was registered
+ expect(spyRegisterClient).toHaveBeenCalledTimes(1);
+ });
+
+ it('sets client_secret to undefined for token_endpoint_auth_method=none', async () => {
+ const clientMetadata: OAuthClientMetadata = {
+ redirect_uris: ['https://example.com/callback'],
+ token_endpoint_auth_method: 'none'
+ };
+
+ const response = await supertest(app).post('/register').send(clientMetadata);
+
+ expect(response.status).toBe(201);
+ expect(response.body.client_secret).toBeUndefined();
+ expect(response.body.client_secret_expires_at).toBeUndefined();
+ });
+
+ it('sets client_secret_expires_at for public clients only', async () => {
+ // Test for public client (token_endpoint_auth_method not 'none')
+ const publicClientMetadata: OAuthClientMetadata = {
+ redirect_uris: ['https://example.com/callback'],
+ token_endpoint_auth_method: 'client_secret_basic'
+ };
+
+ const publicResponse = await supertest(app).post('/register').send(publicClientMetadata);
+
+ expect(publicResponse.status).toBe(201);
+ expect(publicResponse.body.client_secret).toBeDefined();
+ expect(publicResponse.body.client_secret_expires_at).toBeDefined();
+
+ // Test for non-public client (token_endpoint_auth_method is 'none')
+ const nonPublicClientMetadata: OAuthClientMetadata = {
+ redirect_uris: ['https://example.com/callback'],
+ token_endpoint_auth_method: 'none'
+ };
+
+ const nonPublicResponse = await supertest(app).post('/register').send(nonPublicClientMetadata);
+
+ expect(nonPublicResponse.status).toBe(201);
+ expect(nonPublicResponse.body.client_secret).toBeUndefined();
+ expect(nonPublicResponse.body.client_secret_expires_at).toBeUndefined();
+ });
+
+ it('sets expiry based on clientSecretExpirySeconds', async () => {
+ // Create handler with custom expiry time
+ const customApp = express();
+ const options: ClientRegistrationHandlerOptions = {
+ clientsStore: mockClientStoreWithRegistration,
+ clientSecretExpirySeconds: 3600 // 1 hour
+ };
+
+ customApp.use('/register', clientRegistrationHandler(options));
+
+ const response = await supertest(customApp)
+ .post('/register')
+ .send({
+ redirect_uris: ['https://example.com/callback']
+ });
+
+ expect(response.status).toBe(201);
+
+ // Verify the expiration time (~1 hour from now)
+ const issuedAt = response.body.client_id_issued_at;
+ const expiresAt = response.body.client_secret_expires_at;
+ expect(expiresAt - issuedAt).toBe(3600);
+ });
+
+ it('sets no expiry when clientSecretExpirySeconds=0', async () => {
+ // Create handler with no expiry
+ const customApp = express();
+ const options: ClientRegistrationHandlerOptions = {
+ clientsStore: mockClientStoreWithRegistration,
+ clientSecretExpirySeconds: 0 // No expiry
+ };
+
+ customApp.use('/register', clientRegistrationHandler(options));
+
+ const response = await supertest(customApp)
+ .post('/register')
+ .send({
+ redirect_uris: ['https://example.com/callback']
+ });
+
+ expect(response.status).toBe(201);
+ expect(response.body.client_secret_expires_at).toBe(0);
+ });
+
+ it('sets no client_id when clientIdGeneration=false', async () => {
+ // Create handler with no expiry
+ const customApp = express();
+ const options: ClientRegistrationHandlerOptions = {
+ clientsStore: mockClientStoreWithRegistration,
+ clientIdGeneration: false
+ };
+
+ customApp.use('/register', clientRegistrationHandler(options));
+
+ const response = await supertest(customApp)
+ .post('/register')
+ .send({
+ redirect_uris: ['https://example.com/callback']
+ });
+
+ expect(response.status).toBe(201);
+ expect(response.body.client_id).toBeUndefined();
+ expect(response.body.client_id_issued_at).toBeUndefined();
+ });
+
+ it('handles client with all metadata fields', async () => {
+ const fullClientMetadata: OAuthClientMetadata = {
+ redirect_uris: ['https://example.com/callback'],
+ token_endpoint_auth_method: 'client_secret_basic',
+ grant_types: ['authorization_code', 'refresh_token'],
+ response_types: ['code'],
+ client_name: 'Test Client',
+ client_uri: 'https://example.com',
+ logo_uri: 'https://example.com/logo.png',
+ scope: 'profile email',
+ contacts: ['dev@example.com'],
+ tos_uri: 'https://example.com/tos',
+ policy_uri: 'https://example.com/privacy',
+ jwks_uri: 'https://example.com/jwks',
+ software_id: 'test-software',
+ software_version: '1.0.0'
+ };
+
+ const response = await supertest(app).post('/register').send(fullClientMetadata);
+
+ expect(response.status).toBe(201);
+
+ // Verify all metadata was preserved
+ Object.entries(fullClientMetadata).forEach(([key, value]) => {
+ expect(response.body[key]).toEqual(value);
+ });
+ });
+
+ it('includes CORS headers in response', async () => {
+ const response = await supertest(app)
+ .post('/register')
+ .set('Origin', 'https://example.com')
+ .send({
+ redirect_uris: ['https://example.com/callback']
+ });
+
+ expect(response.header['access-control-allow-origin']).toBe('*');
+ });
+ });
+});
diff --git a/typescript-sdk/src/server/auth/handlers/register.ts b/typescript-sdk/src/server/auth/handlers/register.ts
new file mode 100644
index 0000000..1830619
--- /dev/null
+++ b/typescript-sdk/src/server/auth/handlers/register.ts
@@ -0,0 +1,119 @@
+import express, { RequestHandler } from 'express';
+import { OAuthClientInformationFull, OAuthClientMetadataSchema } from '../../../shared/auth.js';
+import crypto from 'node:crypto';
+import cors from 'cors';
+import { OAuthRegisteredClientsStore } from '../clients.js';
+import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit';
+import { allowedMethods } from '../middleware/allowedMethods.js';
+import { InvalidClientMetadataError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js';
+
+export type ClientRegistrationHandlerOptions = {
+ /**
+ * A store used to save information about dynamically registered OAuth clients.
+ */
+ clientsStore: OAuthRegisteredClientsStore;
+
+ /**
+ * The number of seconds after which to expire issued client secrets, or 0 to prevent expiration of client secrets (not recommended).
+ *
+ * If not set, defaults to 30 days.
+ */
+ clientSecretExpirySeconds?: number;
+
+ /**
+ * Rate limiting configuration for the client registration endpoint.
+ * Set to false to disable rate limiting for this endpoint.
+ * Registration endpoints are particularly sensitive to abuse and should be rate limited.
+ */
+ rateLimit?: Partial | false;
+
+ /**
+ * Whether to generate a client ID before calling the client registration endpoint.
+ *
+ * If not set, defaults to true.
+ */
+ clientIdGeneration?: boolean;
+};
+
+const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days
+
+export function clientRegistrationHandler({
+ clientsStore,
+ clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS,
+ rateLimit: rateLimitConfig,
+ clientIdGeneration = true
+}: ClientRegistrationHandlerOptions): RequestHandler {
+ if (!clientsStore.registerClient) {
+ throw new Error('Client registration store does not support registering clients');
+ }
+
+ // Nested router so we can configure middleware and restrict HTTP method
+ const router = express.Router();
+
+ // Configure CORS to allow any origin, to make accessible to web-based MCP clients
+ router.use(cors());
+
+ router.use(allowedMethods(['POST']));
+ router.use(express.json());
+
+ // Apply rate limiting unless explicitly disabled - stricter limits for registration
+ if (rateLimitConfig !== false) {
+ router.use(
+ rateLimit({
+ windowMs: 60 * 60 * 1000, // 1 hour
+ max: 20, // 20 requests per hour - stricter as registration is sensitive
+ standardHeaders: true,
+ legacyHeaders: false,
+ message: new TooManyRequestsError('You have exceeded the rate limit for client registration requests').toResponseObject(),
+ ...rateLimitConfig
+ })
+ );
+ }
+
+ router.post('/', async (req, res) => {
+ res.setHeader('Cache-Control', 'no-store');
+
+ try {
+ const parseResult = OAuthClientMetadataSchema.safeParse(req.body);
+ if (!parseResult.success) {
+ throw new InvalidClientMetadataError(parseResult.error.message);
+ }
+
+ const clientMetadata = parseResult.data;
+ const isPublicClient = clientMetadata.token_endpoint_auth_method === 'none';
+
+ // Generate client credentials
+ const clientSecret = isPublicClient ? undefined : crypto.randomBytes(32).toString('hex');
+ const clientIdIssuedAt = Math.floor(Date.now() / 1000);
+
+ // Calculate client secret expiry time
+ const clientsDoExpire = clientSecretExpirySeconds > 0;
+ const secretExpiryTime = clientsDoExpire ? clientIdIssuedAt + clientSecretExpirySeconds : 0;
+ const clientSecretExpiresAt = isPublicClient ? undefined : secretExpiryTime;
+
+ let clientInfo: Omit & { client_id?: string } = {
+ ...clientMetadata,
+ client_secret: clientSecret,
+ client_secret_expires_at: clientSecretExpiresAt
+ };
+
+ if (clientIdGeneration) {
+ clientInfo.client_id = crypto.randomUUID();
+ clientInfo.client_id_issued_at = clientIdIssuedAt;
+ }
+
+ clientInfo = await clientsStore.registerClient!(clientInfo);
+ res.status(201).json(clientInfo);
+ } catch (error) {
+ if (error instanceof OAuthError) {
+ const status = error instanceof ServerError ? 500 : 400;
+ res.status(status).json(error.toResponseObject());
+ } else {
+ const serverError = new ServerError('Internal Server Error');
+ res.status(500).json(serverError.toResponseObject());
+ }
+ }
+ });
+
+ return router;
+}
diff --git a/typescript-sdk/src/server/auth/handlers/revoke.test.ts b/typescript-sdk/src/server/auth/handlers/revoke.test.ts
new file mode 100644
index 0000000..594b689
--- /dev/null
+++ b/typescript-sdk/src/server/auth/handlers/revoke.test.ts
@@ -0,0 +1,229 @@
+import { revocationHandler, RevocationHandlerOptions } from './revoke.js';
+import { OAuthServerProvider, AuthorizationParams } from '../provider.js';
+import { OAuthRegisteredClientsStore } from '../clients.js';
+import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../shared/auth.js';
+import express, { Response } from 'express';
+import supertest from 'supertest';
+import { AuthInfo } from '../types.js';
+import { InvalidTokenError } from '../errors.js';
+
+describe('Revocation Handler', () => {
+ // Mock client data
+ const validClient: OAuthClientInformationFull = {
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ redirect_uris: ['https://example.com/callback']
+ };
+
+ // Mock client store
+ const mockClientStore: OAuthRegisteredClientsStore = {
+ async getClient(clientId: string): Promise {
+ if (clientId === 'valid-client') {
+ return validClient;
+ }
+ return undefined;
+ }
+ };
+
+ // Mock provider with revocation capability
+ const mockProviderWithRevocation: OAuthServerProvider = {
+ clientsStore: mockClientStore,
+
+ async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise {
+ res.redirect('https://example.com/callback?code=mock_auth_code');
+ },
+
+ async challengeForAuthorizationCode(): Promise {
+ return 'mock_challenge';
+ },
+
+ async exchangeAuthorizationCode(): Promise {
+ return {
+ access_token: 'mock_access_token',
+ token_type: 'bearer',
+ expires_in: 3600,
+ refresh_token: 'mock_refresh_token'
+ };
+ },
+
+ async exchangeRefreshToken(): Promise {
+ return {
+ access_token: 'new_mock_access_token',
+ token_type: 'bearer',
+ expires_in: 3600,
+ refresh_token: 'new_mock_refresh_token'
+ };
+ },
+
+ async verifyAccessToken(token: string): Promise {
+ if (token === 'valid_token') {
+ return {
+ token,
+ clientId: 'valid-client',
+ scopes: ['read', 'write'],
+ expiresAt: Date.now() / 1000 + 3600
+ };
+ }
+ throw new InvalidTokenError('Token is invalid or expired');
+ },
+
+ async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise {
+ // Success - do nothing in mock
+ }
+ };
+
+ // Mock provider without revocation capability
+ const mockProviderWithoutRevocation: OAuthServerProvider = {
+ clientsStore: mockClientStore,
+
+ async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise {
+ res.redirect('https://example.com/callback?code=mock_auth_code');
+ },
+
+ async challengeForAuthorizationCode(): Promise {
+ return 'mock_challenge';
+ },
+
+ async exchangeAuthorizationCode(): Promise {
+ return {
+ access_token: 'mock_access_token',
+ token_type: 'bearer',
+ expires_in: 3600,
+ refresh_token: 'mock_refresh_token'
+ };
+ },
+
+ async exchangeRefreshToken(): Promise {
+ return {
+ access_token: 'new_mock_access_token',
+ token_type: 'bearer',
+ expires_in: 3600,
+ refresh_token: 'new_mock_refresh_token'
+ };
+ },
+
+ async verifyAccessToken(token: string): Promise {
+ if (token === 'valid_token') {
+ return {
+ token,
+ clientId: 'valid-client',
+ scopes: ['read', 'write'],
+ expiresAt: Date.now() / 1000 + 3600
+ };
+ }
+ throw new InvalidTokenError('Token is invalid or expired');
+ }
+ // No revokeToken method
+ };
+
+ describe('Handler creation', () => {
+ it('throws error if provider does not support token revocation', () => {
+ const options: RevocationHandlerOptions = { provider: mockProviderWithoutRevocation };
+ expect(() => revocationHandler(options)).toThrow('does not support revoking tokens');
+ });
+
+ it('creates handler if provider supports token revocation', () => {
+ const options: RevocationHandlerOptions = { provider: mockProviderWithRevocation };
+ expect(() => revocationHandler(options)).not.toThrow();
+ });
+ });
+
+ describe('Request handling', () => {
+ let app: express.Express;
+ let spyRevokeToken: jest.SpyInstance;
+
+ beforeEach(() => {
+ // Setup express app with revocation handler
+ app = express();
+ const options: RevocationHandlerOptions = { provider: mockProviderWithRevocation };
+ app.use('/revoke', revocationHandler(options));
+
+ // Spy on the revokeToken method
+ spyRevokeToken = jest.spyOn(mockProviderWithRevocation, 'revokeToken');
+ });
+
+ afterEach(() => {
+ spyRevokeToken.mockRestore();
+ });
+
+ it('requires POST method', async () => {
+ const response = await supertest(app).get('/revoke').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ token: 'token_to_revoke'
+ });
+
+ expect(response.status).toBe(405);
+ expect(response.headers.allow).toBe('POST');
+ expect(response.body).toEqual({
+ error: 'method_not_allowed',
+ error_description: 'The method GET is not allowed for this endpoint'
+ });
+ expect(spyRevokeToken).not.toHaveBeenCalled();
+ });
+
+ it('requires token parameter', async () => {
+ const response = await supertest(app).post('/revoke').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret'
+ // Missing token
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_request');
+ expect(spyRevokeToken).not.toHaveBeenCalled();
+ });
+
+ it('authenticates client before revoking token', async () => {
+ const response = await supertest(app).post('/revoke').type('form').send({
+ client_id: 'invalid-client',
+ client_secret: 'wrong-secret',
+ token: 'token_to_revoke'
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_client');
+ expect(spyRevokeToken).not.toHaveBeenCalled();
+ });
+
+ it('successfully revokes token', async () => {
+ const response = await supertest(app).post('/revoke').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ token: 'token_to_revoke'
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({}); // Empty response on success
+ expect(spyRevokeToken).toHaveBeenCalledTimes(1);
+ expect(spyRevokeToken).toHaveBeenCalledWith(validClient, {
+ token: 'token_to_revoke'
+ });
+ });
+
+ it('accepts optional token_type_hint', async () => {
+ const response = await supertest(app).post('/revoke').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ token: 'token_to_revoke',
+ token_type_hint: 'refresh_token'
+ });
+
+ expect(response.status).toBe(200);
+ expect(spyRevokeToken).toHaveBeenCalledWith(validClient, {
+ token: 'token_to_revoke',
+ token_type_hint: 'refresh_token'
+ });
+ });
+
+ it('includes CORS headers in response', async () => {
+ const response = await supertest(app).post('/revoke').type('form').set('Origin', 'https://example.com').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ token: 'token_to_revoke'
+ });
+
+ expect(response.header['access-control-allow-origin']).toBe('*');
+ });
+ });
+});
diff --git a/typescript-sdk/src/server/auth/handlers/revoke.ts b/typescript-sdk/src/server/auth/handlers/revoke.ts
new file mode 100644
index 0000000..da7ef04
--- /dev/null
+++ b/typescript-sdk/src/server/auth/handlers/revoke.ts
@@ -0,0 +1,79 @@
+import { OAuthServerProvider } from '../provider.js';
+import express, { RequestHandler } from 'express';
+import cors from 'cors';
+import { authenticateClient } from '../middleware/clientAuth.js';
+import { OAuthTokenRevocationRequestSchema } from '../../../shared/auth.js';
+import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit';
+import { allowedMethods } from '../middleware/allowedMethods.js';
+import { InvalidRequestError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js';
+
+export type RevocationHandlerOptions = {
+ provider: OAuthServerProvider;
+ /**
+ * Rate limiting configuration for the token revocation endpoint.
+ * Set to false to disable rate limiting for this endpoint.
+ */
+ rateLimit?: Partial | false;
+};
+
+export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler {
+ if (!provider.revokeToken) {
+ throw new Error('Auth provider does not support revoking tokens');
+ }
+
+ // Nested router so we can configure middleware and restrict HTTP method
+ const router = express.Router();
+
+ // Configure CORS to allow any origin, to make accessible to web-based MCP clients
+ router.use(cors());
+
+ router.use(allowedMethods(['POST']));
+ router.use(express.urlencoded({ extended: false }));
+
+ // 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
+ })
+ );
+ }
+
+ // Authenticate and extract client details
+ router.use(authenticateClient({ clientsStore: provider.clientsStore }));
+
+ router.post('/', async (req, res) => {
+ res.setHeader('Cache-Control', 'no-store');
+
+ try {
+ const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body);
+ if (!parseResult.success) {
+ throw new InvalidRequestError(parseResult.error.message);
+ }
+
+ const client = req.client;
+ if (!client) {
+ // This should never happen
+ throw new ServerError('Internal Server Error');
+ }
+
+ await provider.revokeToken!(client, parseResult.data);
+ res.status(200).json({});
+ } catch (error) {
+ if (error instanceof OAuthError) {
+ const status = error instanceof ServerError ? 500 : 400;
+ res.status(status).json(error.toResponseObject());
+ } else {
+ const serverError = new ServerError('Internal Server Error');
+ res.status(500).json(serverError.toResponseObject());
+ }
+ }
+ });
+
+ return router;
+}
diff --git a/typescript-sdk/src/server/auth/handlers/token.test.ts b/typescript-sdk/src/server/auth/handlers/token.test.ts
new file mode 100644
index 0000000..e0338f0
--- /dev/null
+++ b/typescript-sdk/src/server/auth/handlers/token.test.ts
@@ -0,0 +1,478 @@
+import { tokenHandler, TokenHandlerOptions } from './token.js';
+import { OAuthServerProvider, AuthorizationParams } from '../provider.js';
+import { OAuthRegisteredClientsStore } from '../clients.js';
+import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../shared/auth.js';
+import express, { Response } from 'express';
+import supertest from 'supertest';
+import * as pkceChallenge from 'pkce-challenge';
+import { InvalidGrantError, InvalidTokenError } from '../errors.js';
+import { AuthInfo } from '../types.js';
+import { ProxyOAuthServerProvider } from '../providers/proxyProvider.js';
+
+// Mock pkce-challenge
+jest.mock('pkce-challenge', () => ({
+ verifyChallenge: jest.fn().mockImplementation(async (verifier, challenge) => {
+ return verifier === 'valid_verifier' && challenge === 'mock_challenge';
+ })
+}));
+
+const mockTokens = {
+ access_token: 'mock_access_token',
+ token_type: 'bearer',
+ expires_in: 3600,
+ refresh_token: 'mock_refresh_token'
+};
+
+const mockTokensWithIdToken = {
+ ...mockTokens,
+ id_token: 'mock_id_token'
+};
+
+describe('Token Handler', () => {
+ // Mock client data
+ const validClient: OAuthClientInformationFull = {
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ redirect_uris: ['https://example.com/callback']
+ };
+
+ // Mock client store
+ const mockClientStore: OAuthRegisteredClientsStore = {
+ async getClient(clientId: string): Promise {
+ if (clientId === 'valid-client') {
+ return validClient;
+ }
+ return undefined;
+ }
+ };
+
+ // Mock provider
+ let mockProvider: OAuthServerProvider;
+ let app: express.Express;
+
+ beforeEach(() => {
+ // Create fresh mocks for each test
+ mockProvider = {
+ clientsStore: mockClientStore,
+
+ async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise {
+ res.redirect('https://example.com/callback?code=mock_auth_code');
+ },
+
+ async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise {
+ if (authorizationCode === 'valid_code') {
+ return 'mock_challenge';
+ } else if (authorizationCode === 'expired_code') {
+ throw new InvalidGrantError('The authorization code has expired');
+ }
+ throw new InvalidGrantError('The authorization code is invalid');
+ },
+
+ async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise {
+ if (authorizationCode === 'valid_code') {
+ return mockTokens;
+ }
+ throw new InvalidGrantError('The authorization code is invalid or has expired');
+ },
+
+ async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise {
+ if (refreshToken === 'valid_refresh_token') {
+ const response: OAuthTokens = {
+ access_token: 'new_mock_access_token',
+ token_type: 'bearer',
+ expires_in: 3600,
+ refresh_token: 'new_mock_refresh_token'
+ };
+
+ if (scopes) {
+ response.scope = scopes.join(' ');
+ }
+
+ return response;
+ }
+ throw new InvalidGrantError('The refresh token is invalid or has expired');
+ },
+
+ async verifyAccessToken(token: string): Promise {
+ if (token === 'valid_token') {
+ return {
+ token,
+ clientId: 'valid-client',
+ scopes: ['read', 'write'],
+ expiresAt: Date.now() / 1000 + 3600
+ };
+ }
+ throw new InvalidTokenError('Token is invalid or expired');
+ },
+
+ async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise {
+ // Do nothing in mock
+ }
+ };
+
+ // Mock PKCE verification
+ (pkceChallenge.verifyChallenge as jest.Mock).mockImplementation(async (verifier: string, challenge: string) => {
+ return verifier === 'valid_verifier' && challenge === 'mock_challenge';
+ });
+
+ // Setup express app with token handler
+ app = express();
+ const options: TokenHandlerOptions = { provider: mockProvider };
+ app.use('/token', tokenHandler(options));
+ });
+
+ describe('Basic request validation', () => {
+ it('requires POST method', async () => {
+ const response = await supertest(app).get('/token').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ grant_type: 'authorization_code'
+ });
+
+ expect(response.status).toBe(405);
+ expect(response.headers.allow).toBe('POST');
+ expect(response.body).toEqual({
+ error: 'method_not_allowed',
+ error_description: 'The method GET is not allowed for this endpoint'
+ });
+ });
+
+ it('requires grant_type parameter', async () => {
+ const response = await supertest(app).post('/token').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret'
+ // Missing grant_type
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_request');
+ });
+
+ it('rejects unsupported grant types', async () => {
+ const response = await supertest(app).post('/token').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ grant_type: 'password' // Unsupported grant type
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('unsupported_grant_type');
+ });
+ });
+
+ describe('Client authentication', () => {
+ it('requires valid client credentials', async () => {
+ const response = await supertest(app).post('/token').type('form').send({
+ client_id: 'invalid-client',
+ client_secret: 'wrong-secret',
+ grant_type: 'authorization_code'
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_client');
+ });
+
+ it('accepts valid client credentials', async () => {
+ const response = await supertest(app).post('/token').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ grant_type: 'authorization_code',
+ code: 'valid_code',
+ code_verifier: 'valid_verifier'
+ });
+
+ expect(response.status).toBe(200);
+ });
+ });
+
+ describe('Authorization code grant', () => {
+ it('requires code parameter', async () => {
+ const response = await supertest(app).post('/token').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ grant_type: 'authorization_code',
+ // Missing code
+ code_verifier: 'valid_verifier'
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_request');
+ });
+
+ it('requires code_verifier parameter', async () => {
+ const response = await supertest(app).post('/token').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ grant_type: 'authorization_code',
+ code: 'valid_code'
+ // Missing code_verifier
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_request');
+ });
+
+ it('verifies code_verifier against challenge', async () => {
+ // Setup invalid verifier
+ (pkceChallenge.verifyChallenge as jest.Mock).mockResolvedValueOnce(false);
+
+ const response = await supertest(app).post('/token').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ grant_type: 'authorization_code',
+ code: 'valid_code',
+ code_verifier: 'invalid_verifier'
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_grant');
+ expect(response.body.error_description).toContain('code_verifier');
+ });
+
+ it('rejects expired or invalid authorization codes', async () => {
+ const response = await supertest(app).post('/token').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ grant_type: 'authorization_code',
+ code: 'expired_code',
+ code_verifier: 'valid_verifier'
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_grant');
+ });
+
+ 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'
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.access_token).toBe('mock_access_token');
+ 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('returns id token in code exchange if provided', async () => {
+ mockProvider.exchangeAuthorizationCode = async (
+ client: OAuthClientInformationFull,
+ authorizationCode: string
+ ): Promise => {
+ if (authorizationCode === 'valid_code') {
+ return mockTokensWithIdToken;
+ }
+ throw new InvalidGrantError('The authorization code is invalid or has expired');
+ };
+
+ const response = await supertest(app).post('/token').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ grant_type: 'authorization_code',
+ code: 'valid_code',
+ code_verifier: 'valid_verifier'
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.id_token).toBe('mock_id_token');
+ });
+
+ it('passes through code verifier when using proxy provider', async () => {
+ const originalFetch = global.fetch;
+
+ try {
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve(mockTokens)
+ });
+
+ const proxyProvider = new ProxyOAuthServerProvider({
+ endpoints: {
+ authorizationUrl: 'https://example.com/authorize',
+ tokenUrl: 'https://example.com/token'
+ },
+ verifyAccessToken: async token => ({
+ token,
+ clientId: 'valid-client',
+ scopes: ['read', 'write'],
+ expiresAt: Date.now() / 1000 + 3600
+ }),
+ getClient: async clientId => (clientId === 'valid-client' ? validClient : undefined)
+ });
+
+ const proxyApp = express();
+ const options: TokenHandlerOptions = { provider: proxyProvider };
+ proxyApp.use('/token', tokenHandler(options));
+
+ const response = await supertest(proxyApp).post('/token').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ grant_type: 'authorization_code',
+ code: 'valid_code',
+ code_verifier: 'any_verifier',
+ redirect_uri: 'https://example.com/callback'
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.access_token).toBe('mock_access_token');
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'https://example.com/token',
+ expect.objectContaining({
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: expect.stringContaining('code_verifier=any_verifier')
+ })
+ );
+ } finally {
+ global.fetch = originalFetch;
+ }
+ });
+
+ it('passes through redirect_uri when using proxy provider', async () => {
+ const originalFetch = global.fetch;
+
+ try {
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve(mockTokens)
+ });
+
+ const proxyProvider = new ProxyOAuthServerProvider({
+ endpoints: {
+ authorizationUrl: 'https://example.com/authorize',
+ tokenUrl: 'https://example.com/token'
+ },
+ verifyAccessToken: async token => ({
+ token,
+ clientId: 'valid-client',
+ scopes: ['read', 'write'],
+ expiresAt: Date.now() / 1000 + 3600
+ }),
+ getClient: async clientId => (clientId === 'valid-client' ? validClient : undefined)
+ });
+
+ const proxyApp = express();
+ const options: TokenHandlerOptions = { provider: proxyProvider };
+ proxyApp.use('/token', tokenHandler(options));
+
+ const redirectUri = 'https://example.com/callback';
+ const response = await supertest(proxyApp).post('/token').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ grant_type: 'authorization_code',
+ code: 'valid_code',
+ code_verifier: 'any_verifier',
+ redirect_uri: redirectUri
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.access_token).toBe('mock_access_token');
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'https://example.com/token',
+ expect.objectContaining({
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`)
+ })
+ );
+ } finally {
+ global.fetch = originalFetch;
+ }
+ });
+ });
+
+ describe('Refresh token grant', () => {
+ it('requires refresh_token parameter', async () => {
+ const response = await supertest(app).post('/token').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ grant_type: 'refresh_token'
+ // Missing refresh_token
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_request');
+ });
+
+ it('rejects invalid refresh tokens', async () => {
+ const response = await supertest(app).post('/token').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ grant_type: 'refresh_token',
+ refresh_token: 'invalid_refresh_token'
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_grant');
+ });
+
+ 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'
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.access_token).toBe('new_mock_access_token');
+ 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 () => {
+ const response = await supertest(app).post('/token').type('form').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ grant_type: 'refresh_token',
+ refresh_token: 'valid_refresh_token',
+ scope: 'profile email'
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.scope).toBe('profile email');
+ });
+ });
+
+ describe('CORS support', () => {
+ it('includes CORS headers in response', async () => {
+ const response = await supertest(app).post('/token').type('form').set('Origin', 'https://example.com').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ grant_type: 'authorization_code',
+ code: 'valid_code',
+ code_verifier: 'valid_verifier'
+ });
+
+ expect(response.header['access-control-allow-origin']).toBe('*');
+ });
+ });
+});
diff --git a/typescript-sdk/src/server/auth/handlers/token.ts b/typescript-sdk/src/server/auth/handlers/token.ts
new file mode 100644
index 0000000..c387ff7
--- /dev/null
+++ b/typescript-sdk/src/server/auth/handlers/token.ts
@@ -0,0 +1,157 @@
+import { z } from 'zod';
+import express, { RequestHandler } from 'express';
+import { OAuthServerProvider } from '../provider.js';
+import cors from 'cors';
+import { verifyChallenge } from 'pkce-challenge';
+import { authenticateClient } from '../middleware/clientAuth.js';
+import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit';
+import { allowedMethods } from '../middleware/allowedMethods.js';
+import {
+ InvalidRequestError,
+ InvalidGrantError,
+ UnsupportedGrantTypeError,
+ ServerError,
+ TooManyRequestsError,
+ OAuthError
+} from '../errors.js';
+
+export type TokenHandlerOptions = {
+ provider: OAuthServerProvider;
+ /**
+ * Rate limiting configuration for the token endpoint.
+ * Set to false to disable rate limiting for this endpoint.
+ */
+ rateLimit?: Partial | false;
+};
+
+const TokenRequestSchema = z.object({
+ grant_type: z.string()
+});
+
+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 {
+ // Nested router so we can configure middleware and restrict HTTP method
+ const router = express.Router();
+
+ // Configure CORS to allow any origin, to make accessible to web-based MCP clients
+ router.use(cors());
+
+ router.use(allowedMethods(['POST']));
+ router.use(express.urlencoded({ extended: false }));
+
+ // 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 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');
+
+ try {
+ const parseResult = TokenRequestSchema.safeParse(req.body);
+ if (!parseResult.success) {
+ throw new InvalidRequestError(parseResult.error.message);
+ }
+
+ const { grant_type } = parseResult.data;
+
+ const client = req.client;
+ if (!client) {
+ // This should never happen
+ throw new ServerError('Internal Server Error');
+ }
+
+ switch (grant_type) {
+ case 'authorization_code': {
+ const parseResult = AuthorizationCodeGrantSchema.safeParse(req.body);
+ if (!parseResult.success) {
+ throw new InvalidRequestError(parseResult.error.message);
+ }
+
+ const { code, code_verifier, redirect_uri, resource } = parseResult.data;
+
+ const skipLocalPkceValidation = provider.skipLocalPkceValidation;
+
+ // Perform local PKCE validation unless explicitly skipped
+ // (e.g. to validate code_verifier in upstream server)
+ if (!skipLocalPkceValidation) {
+ const codeChallenge = await provider.challengeForAuthorizationCode(client, code);
+ if (!(await verifyChallenge(code_verifier, codeChallenge))) {
+ throw new InvalidGrantError('code_verifier does not match the challenge');
+ }
+ }
+
+ // Passes the code_verifier to the provider if PKCE validation didn't occur locally
+ const tokens = await provider.exchangeAuthorizationCode(
+ client,
+ code,
+ skipLocalPkceValidation ? code_verifier : undefined,
+ redirect_uri,
+ resource ? new URL(resource) : undefined
+ );
+ res.status(200).json(tokens);
+ break;
+ }
+
+ case 'refresh_token': {
+ const parseResult = RefreshTokenGrantSchema.safeParse(req.body);
+ if (!parseResult.success) {
+ throw new InvalidRequestError(parseResult.error.message);
+ }
+
+ const { refresh_token, scope, resource } = parseResult.data;
+
+ const scopes = scope?.split(' ');
+ const tokens = await provider.exchangeRefreshToken(
+ client,
+ refresh_token,
+ scopes,
+ resource ? new URL(resource) : undefined
+ );
+ res.status(200).json(tokens);
+ break;
+ }
+
+ // Not supported right now
+ //case "client_credentials":
+
+ default:
+ throw new UnsupportedGrantTypeError('The grant type is not supported by this authorization server.');
+ }
+ } catch (error) {
+ if (error instanceof OAuthError) {
+ const status = error instanceof ServerError ? 500 : 400;
+ res.status(status).json(error.toResponseObject());
+ } else {
+ const serverError = new ServerError('Internal Server Error');
+ res.status(500).json(serverError.toResponseObject());
+ }
+ }
+ });
+
+ return router;
+}
diff --git a/typescript-sdk/src/server/auth/middleware/allowedMethods.test.ts b/typescript-sdk/src/server/auth/middleware/allowedMethods.test.ts
new file mode 100644
index 0000000..1f30fea
--- /dev/null
+++ b/typescript-sdk/src/server/auth/middleware/allowedMethods.test.ts
@@ -0,0 +1,75 @@
+import { allowedMethods } from './allowedMethods.js';
+import express, { Request, Response } from 'express';
+import request from 'supertest';
+
+describe('allowedMethods', () => {
+ let app: express.Express;
+
+ beforeEach(() => {
+ app = express();
+
+ // Set up a test router with a GET handler and 405 middleware
+ const router = express.Router();
+
+ router.get('/test', (req, res) => {
+ res.status(200).send('GET success');
+ });
+
+ // Add method not allowed middleware for all other methods
+ router.all('/test', allowedMethods(['GET']));
+
+ app.use(router);
+ });
+
+ test('allows specified HTTP method', async () => {
+ const response = await request(app).get('/test');
+ expect(response.status).toBe(200);
+ expect(response.text).toBe('GET success');
+ });
+
+ test('returns 405 for unspecified HTTP methods', async () => {
+ const methods = ['post', 'put', 'delete', 'patch'];
+
+ for (const method of methods) {
+ // @ts-expect-error - dynamic method call
+ const response = await request(app)[method]('/test');
+ expect(response.status).toBe(405);
+ expect(response.body).toEqual({
+ error: 'method_not_allowed',
+ error_description: `The method ${method.toUpperCase()} is not allowed for this endpoint`
+ });
+ }
+ });
+
+ test('includes Allow header with specified methods', async () => {
+ const response = await request(app).post('/test');
+ expect(response.headers.allow).toBe('GET');
+ });
+
+ test('works with multiple allowed methods', async () => {
+ const multiMethodApp = express();
+ const router = express.Router();
+
+ router.get('/multi', (req: Request, res: Response) => {
+ res.status(200).send('GET');
+ });
+ router.post('/multi', (req: Request, res: Response) => {
+ res.status(200).send('POST');
+ });
+ router.all('/multi', allowedMethods(['GET', 'POST']));
+
+ multiMethodApp.use(router);
+
+ // Allowed methods should work
+ const getResponse = await request(multiMethodApp).get('/multi');
+ expect(getResponse.status).toBe(200);
+
+ const postResponse = await request(multiMethodApp).post('/multi');
+ expect(postResponse.status).toBe(200);
+
+ // Unallowed methods should return 405
+ const putResponse = await request(multiMethodApp).put('/multi');
+ expect(putResponse.status).toBe(405);
+ expect(putResponse.headers.allow).toBe('GET, POST');
+ });
+});
diff --git a/typescript-sdk/src/server/auth/middleware/allowedMethods.ts b/typescript-sdk/src/server/auth/middleware/allowedMethods.ts
new file mode 100644
index 0000000..74633aa
--- /dev/null
+++ b/typescript-sdk/src/server/auth/middleware/allowedMethods.ts
@@ -0,0 +1,20 @@
+import { RequestHandler } from 'express';
+import { MethodNotAllowedError } from '../errors.js';
+
+/**
+ * Middleware to handle unsupported HTTP methods with a 405 Method Not Allowed response.
+ *
+ * @param allowedMethods Array of allowed HTTP methods for this endpoint (e.g., ['GET', 'POST'])
+ * @returns Express middleware that returns a 405 error if method not in allowed list
+ */
+export function allowedMethods(allowedMethods: string[]): RequestHandler {
+ return (req, res, next) => {
+ if (allowedMethods.includes(req.method)) {
+ next();
+ return;
+ }
+
+ const error = new MethodNotAllowedError(`The method ${req.method} is not allowed for this endpoint`);
+ res.status(405).set('Allow', allowedMethods.join(', ')).json(error.toResponseObject());
+ };
+}
diff --git a/typescript-sdk/src/server/auth/middleware/bearerAuth.test.ts b/typescript-sdk/src/server/auth/middleware/bearerAuth.test.ts
new file mode 100644
index 0000000..5790a0e
--- /dev/null
+++ b/typescript-sdk/src/server/auth/middleware/bearerAuth.test.ts
@@ -0,0 +1,438 @@
+import { Request, Response } from 'express';
+import { requireBearerAuth } from './bearerAuth.js';
+import { AuthInfo } from '../types.js';
+import { InsufficientScopeError, InvalidTokenError, CustomOAuthError, ServerError } from '../errors.js';
+import { OAuthTokenVerifier } from '../provider.js';
+
+// Mock verifier
+const mockVerifyAccessToken = jest.fn();
+const mockVerifier: OAuthTokenVerifier = {
+ verifyAccessToken: mockVerifyAccessToken
+};
+
+describe('requireBearerAuth middleware', () => {
+ let mockRequest: Partial;
+ let mockResponse: Partial;
+ let nextFunction: jest.Mock;
+
+ beforeEach(() => {
+ mockRequest = {
+ headers: {}
+ };
+ mockResponse = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ set: jest.fn().mockReturnThis()
+ };
+ nextFunction = jest.fn();
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should call next when token is valid', async () => {
+ const validAuthInfo: AuthInfo = {
+ token: 'valid-token',
+ clientId: 'client-123',
+ scopes: ['read', 'write'],
+ expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour
+ };
+ mockVerifyAccessToken.mockResolvedValue(validAuthInfo);
+
+ mockRequest.headers = {
+ authorization: 'Bearer valid-token'
+ };
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
+ expect(mockRequest.auth).toEqual(validAuthInfo);
+ expect(nextFunction).toHaveBeenCalled();
+ expect(mockResponse.status).not.toHaveBeenCalled();
+ expect(mockResponse.json).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ [100], // Token expired 100 seconds ago
+ [0] // Token expires at the same time as now
+ ])('should reject expired tokens (expired %s seconds ago)', async (expiredSecondsAgo: number) => {
+ const expiresAt = Math.floor(Date.now() / 1000) - expiredSecondsAgo;
+ const expiredAuthInfo: AuthInfo = {
+ token: 'expired-token',
+ clientId: 'client-123',
+ scopes: ['read', 'write'],
+ expiresAt
+ };
+ mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo);
+
+ mockRequest.headers = {
+ authorization: 'Bearer expired-token'
+ };
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockVerifyAccessToken).toHaveBeenCalledWith('expired-token');
+ expect(mockResponse.status).toHaveBeenCalledWith(401);
+ expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
+ expect(mockResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({ error: 'invalid_token', error_description: 'Token has expired' })
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ [undefined], // Token has no expiration time
+ [NaN] // Token has no expiration time
+ ])('should reject tokens with no expiration time (expiresAt: %s)', async (expiresAt: number | undefined) => {
+ const noExpirationAuthInfo: AuthInfo = {
+ token: 'no-expiration-token',
+ clientId: 'client-123',
+ scopes: ['read', 'write'],
+ expiresAt
+ };
+ mockVerifyAccessToken.mockResolvedValue(noExpirationAuthInfo);
+
+ mockRequest.headers = {
+ authorization: 'Bearer expired-token'
+ };
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockVerifyAccessToken).toHaveBeenCalledWith('expired-token');
+ expect(mockResponse.status).toHaveBeenCalledWith(401);
+ expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
+ expect(mockResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({ error: 'invalid_token', error_description: 'Token has no expiration time' })
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ it('should accept non-expired tokens', async () => {
+ const nonExpiredAuthInfo: AuthInfo = {
+ token: 'valid-token',
+ clientId: 'client-123',
+ scopes: ['read', 'write'],
+ expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour
+ };
+ mockVerifyAccessToken.mockResolvedValue(nonExpiredAuthInfo);
+
+ mockRequest.headers = {
+ authorization: 'Bearer valid-token'
+ };
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
+ expect(mockRequest.auth).toEqual(nonExpiredAuthInfo);
+ expect(nextFunction).toHaveBeenCalled();
+ expect(mockResponse.status).not.toHaveBeenCalled();
+ expect(mockResponse.json).not.toHaveBeenCalled();
+ });
+
+ it('should require specific scopes when configured', async () => {
+ const authInfo: AuthInfo = {
+ token: 'valid-token',
+ clientId: 'client-123',
+ scopes: ['read']
+ };
+ mockVerifyAccessToken.mockResolvedValue(authInfo);
+
+ mockRequest.headers = {
+ authorization: 'Bearer valid-token'
+ };
+
+ const middleware = requireBearerAuth({
+ verifier: mockVerifier,
+ requiredScopes: ['read', 'write']
+ });
+
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
+ expect(mockResponse.status).toHaveBeenCalledWith(403);
+ expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="insufficient_scope"'));
+ expect(mockResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({ error: 'insufficient_scope', error_description: 'Insufficient scope' })
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ it('should accept token with all required scopes', async () => {
+ const authInfo: AuthInfo = {
+ token: 'valid-token',
+ clientId: 'client-123',
+ scopes: ['read', 'write', 'admin'],
+ expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour
+ };
+ mockVerifyAccessToken.mockResolvedValue(authInfo);
+
+ mockRequest.headers = {
+ authorization: 'Bearer valid-token'
+ };
+
+ const middleware = requireBearerAuth({
+ verifier: mockVerifier,
+ requiredScopes: ['read', 'write']
+ });
+
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
+ expect(mockRequest.auth).toEqual(authInfo);
+ expect(nextFunction).toHaveBeenCalled();
+ expect(mockResponse.status).not.toHaveBeenCalled();
+ expect(mockResponse.json).not.toHaveBeenCalled();
+ });
+
+ it('should return 401 when no Authorization header is present', async () => {
+ const middleware = requireBearerAuth({ verifier: mockVerifier });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockVerifyAccessToken).not.toHaveBeenCalled();
+ expect(mockResponse.status).toHaveBeenCalledWith(401);
+ expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
+ expect(mockResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({ error: 'invalid_token', error_description: 'Missing Authorization header' })
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ it('should return 401 when Authorization header format is invalid', async () => {
+ mockRequest.headers = {
+ authorization: 'InvalidFormat'
+ };
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockVerifyAccessToken).not.toHaveBeenCalled();
+ expect(mockResponse.status).toHaveBeenCalledWith(401);
+ expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
+ expect(mockResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: 'invalid_token',
+ error_description: "Invalid Authorization header format, expected 'Bearer TOKEN'"
+ })
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ it('should return 401 when token verification fails with InvalidTokenError', async () => {
+ mockRequest.headers = {
+ authorization: 'Bearer invalid-token'
+ };
+
+ mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError('Token expired'));
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockVerifyAccessToken).toHaveBeenCalledWith('invalid-token');
+ expect(mockResponse.status).toHaveBeenCalledWith(401);
+ expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
+ expect(mockResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({ error: 'invalid_token', error_description: 'Token expired' })
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ it('should return 403 when access token has insufficient scopes', async () => {
+ mockRequest.headers = {
+ authorization: 'Bearer valid-token'
+ };
+
+ mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError('Required scopes: read, write'));
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
+ expect(mockResponse.status).toHaveBeenCalledWith(403);
+ expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="insufficient_scope"'));
+ expect(mockResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({ error: 'insufficient_scope', error_description: 'Required scopes: read, write' })
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ it('should return 500 when a ServerError occurs', async () => {
+ mockRequest.headers = {
+ authorization: 'Bearer valid-token'
+ };
+
+ mockVerifyAccessToken.mockRejectedValue(new ServerError('Internal server issue'));
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
+ expect(mockResponse.status).toHaveBeenCalledWith(500);
+ expect(mockResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({ error: 'server_error', error_description: 'Internal server issue' })
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 for generic OAuthError', async () => {
+ mockRequest.headers = {
+ authorization: 'Bearer valid-token'
+ };
+
+ mockVerifyAccessToken.mockRejectedValue(new CustomOAuthError('custom_error', 'Some OAuth error'));
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
+ expect(mockResponse.status).toHaveBeenCalledWith(400);
+ expect(mockResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({ error: 'custom_error', error_description: 'Some OAuth error' })
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ it('should return 500 when unexpected error occurs', async () => {
+ mockRequest.headers = {
+ authorization: 'Bearer valid-token'
+ };
+
+ mockVerifyAccessToken.mockRejectedValue(new Error('Unexpected error'));
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
+ expect(mockResponse.status).toHaveBeenCalledWith(500);
+ expect(mockResponse.json).toHaveBeenCalledWith(
+ expect.objectContaining({ error: 'server_error', error_description: 'Internal Server Error' })
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ describe('with resourceMetadataUrl', () => {
+ const resourceMetadataUrl = 'https://api.example.com/.well-known/oauth-protected-resource';
+
+ it('should include resource_metadata in WWW-Authenticate header for 401 responses', async () => {
+ mockRequest.headers = {};
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockResponse.status).toHaveBeenCalledWith(401);
+ expect(mockResponse.set).toHaveBeenCalledWith(
+ 'WWW-Authenticate',
+ `Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="${resourceMetadataUrl}"`
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ it('should include resource_metadata in WWW-Authenticate header when token verification fails', async () => {
+ mockRequest.headers = {
+ authorization: 'Bearer invalid-token'
+ };
+
+ mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError('Token expired'));
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockResponse.status).toHaveBeenCalledWith(401);
+ expect(mockResponse.set).toHaveBeenCalledWith(
+ 'WWW-Authenticate',
+ `Bearer error="invalid_token", error_description="Token expired", resource_metadata="${resourceMetadataUrl}"`
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ it('should include resource_metadata in WWW-Authenticate header for insufficient scope errors', async () => {
+ mockRequest.headers = {
+ authorization: 'Bearer valid-token'
+ };
+
+ mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError('Required scopes: admin'));
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockResponse.status).toHaveBeenCalledWith(403);
+ expect(mockResponse.set).toHaveBeenCalledWith(
+ 'WWW-Authenticate',
+ `Bearer error="insufficient_scope", error_description="Required scopes: admin", resource_metadata="${resourceMetadataUrl}"`
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ it('should include resource_metadata when token is expired', async () => {
+ const expiredAuthInfo: AuthInfo = {
+ token: 'expired-token',
+ clientId: 'client-123',
+ scopes: ['read', 'write'],
+ expiresAt: Math.floor(Date.now() / 1000) - 100
+ };
+ mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo);
+
+ mockRequest.headers = {
+ authorization: 'Bearer expired-token'
+ };
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockResponse.status).toHaveBeenCalledWith(401);
+ expect(mockResponse.set).toHaveBeenCalledWith(
+ 'WWW-Authenticate',
+ `Bearer error="invalid_token", error_description="Token has expired", resource_metadata="${resourceMetadataUrl}"`
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ it('should include resource_metadata when scope check fails', async () => {
+ const authInfo: AuthInfo = {
+ token: 'valid-token',
+ clientId: 'client-123',
+ scopes: ['read']
+ };
+ mockVerifyAccessToken.mockResolvedValue(authInfo);
+
+ mockRequest.headers = {
+ authorization: 'Bearer valid-token'
+ };
+
+ const middleware = requireBearerAuth({
+ verifier: mockVerifier,
+ requiredScopes: ['read', 'write'],
+ resourceMetadataUrl
+ });
+
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockResponse.status).toHaveBeenCalledWith(403);
+ expect(mockResponse.set).toHaveBeenCalledWith(
+ 'WWW-Authenticate',
+ `Bearer error="insufficient_scope", error_description="Insufficient scope", resource_metadata="${resourceMetadataUrl}"`
+ );
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+
+ it('should not affect server errors (no WWW-Authenticate header)', async () => {
+ mockRequest.headers = {
+ authorization: 'Bearer valid-token'
+ };
+
+ mockVerifyAccessToken.mockRejectedValue(new ServerError('Internal server issue'));
+
+ const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
+ await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
+
+ expect(mockResponse.status).toHaveBeenCalledWith(500);
+ expect(mockResponse.set).not.toHaveBeenCalledWith('WWW-Authenticate', expect.anything());
+ expect(nextFunction).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/typescript-sdk/src/server/auth/middleware/bearerAuth.ts b/typescript-sdk/src/server/auth/middleware/bearerAuth.ts
new file mode 100644
index 0000000..363fd7a
--- /dev/null
+++ b/typescript-sdk/src/server/auth/middleware/bearerAuth.ts
@@ -0,0 +1,96 @@
+import { RequestHandler } from 'express';
+import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '../errors.js';
+import { OAuthTokenVerifier } from '../provider.js';
+import { AuthInfo } from '../types.js';
+
+export type BearerAuthMiddlewareOptions = {
+ /**
+ * A provider used to verify tokens.
+ */
+ verifier: OAuthTokenVerifier;
+
+ /**
+ * Optional scopes that the token must have.
+ */
+ requiredScopes?: string[];
+
+ /**
+ * Optional resource metadata URL to include in WWW-Authenticate header.
+ */
+ resourceMetadataUrl?: string;
+};
+
+declare module 'express-serve-static-core' {
+ interface Request {
+ /**
+ * Information about the validated access token, if the `requireBearerAuth` middleware was used.
+ */
+ auth?: AuthInfo;
+ }
+}
+
+/**
+ * Middleware that requires a valid Bearer token in the Authorization header.
+ *
+ * This will validate the token with the auth provider and add the resulting auth info to the request object.
+ *
+ * If resourceMetadataUrl is provided, it will be included in the WWW-Authenticate header
+ * for 401 responses as per the OAuth 2.0 Protected Resource Metadata spec.
+ */
+export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler {
+ return async (req, res, next) => {
+ try {
+ const authHeader = req.headers.authorization;
+ if (!authHeader) {
+ throw new InvalidTokenError('Missing Authorization header');
+ }
+
+ const [type, token] = authHeader.split(' ');
+ if (type.toLowerCase() !== 'bearer' || !token) {
+ throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'");
+ }
+
+ const authInfo = await verifier.verifyAccessToken(token);
+
+ // Check if token has the required scopes (if any)
+ if (requiredScopes.length > 0) {
+ const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope));
+
+ if (!hasAllScopes) {
+ throw new InsufficientScopeError('Insufficient scope');
+ }
+ }
+
+ // Check if the token is set to expire or if it is expired
+ if (typeof authInfo.expiresAt !== 'number' || isNaN(authInfo.expiresAt)) {
+ throw new InvalidTokenError('Token has no expiration time');
+ } else if (authInfo.expiresAt < Date.now() / 1000) {
+ throw new InvalidTokenError('Token has expired');
+ }
+
+ req.auth = authInfo;
+ next();
+ } catch (error) {
+ if (error instanceof InvalidTokenError) {
+ const wwwAuthValue = resourceMetadataUrl
+ ? `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"`
+ : `Bearer error="${error.errorCode}", error_description="${error.message}"`;
+ res.set('WWW-Authenticate', wwwAuthValue);
+ res.status(401).json(error.toResponseObject());
+ } else if (error instanceof InsufficientScopeError) {
+ const wwwAuthValue = resourceMetadataUrl
+ ? `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"`
+ : `Bearer error="${error.errorCode}", error_description="${error.message}"`;
+ res.set('WWW-Authenticate', wwwAuthValue);
+ res.status(403).json(error.toResponseObject());
+ } else if (error instanceof ServerError) {
+ res.status(500).json(error.toResponseObject());
+ } else if (error instanceof OAuthError) {
+ res.status(400).json(error.toResponseObject());
+ } else {
+ const serverError = new ServerError('Internal Server Error');
+ res.status(500).json(serverError.toResponseObject());
+ }
+ }
+ };
+}
diff --git a/typescript-sdk/src/server/auth/middleware/clientAuth.test.ts b/typescript-sdk/src/server/auth/middleware/clientAuth.test.ts
new file mode 100644
index 0000000..5ad6f30
--- /dev/null
+++ b/typescript-sdk/src/server/auth/middleware/clientAuth.test.ts
@@ -0,0 +1,132 @@
+import { authenticateClient, ClientAuthenticationMiddlewareOptions } from './clientAuth.js';
+import { OAuthRegisteredClientsStore } from '../clients.js';
+import { OAuthClientInformationFull } from '../../../shared/auth.js';
+import express from 'express';
+import supertest from 'supertest';
+
+describe('clientAuth middleware', () => {
+ // Mock client store
+ const mockClientStore: OAuthRegisteredClientsStore = {
+ async getClient(clientId: string): Promise {
+ if (clientId === 'valid-client') {
+ return {
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ redirect_uris: ['https://example.com/callback']
+ };
+ } else if (clientId === 'expired-client') {
+ // Client with no secret
+ return {
+ client_id: 'expired-client',
+ redirect_uris: ['https://example.com/callback']
+ };
+ } else if (clientId === 'client-with-expired-secret') {
+ // Client with an expired secret
+ return {
+ client_id: 'client-with-expired-secret',
+ client_secret: 'expired-secret',
+ client_secret_expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
+ redirect_uris: ['https://example.com/callback']
+ };
+ }
+ return undefined;
+ }
+ };
+
+ // Setup Express app with middleware
+ let app: express.Express;
+ let options: ClientAuthenticationMiddlewareOptions;
+
+ beforeEach(() => {
+ app = express();
+ app.use(express.json());
+
+ options = {
+ clientsStore: mockClientStore
+ };
+
+ // Setup route with client auth
+ app.post('/protected', authenticateClient(options), (req, res) => {
+ res.status(200).json({ success: true, client: req.client });
+ });
+ });
+
+ it('authenticates valid client credentials', async () => {
+ const response = await supertest(app).post('/protected').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret'
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body.success).toBe(true);
+ expect(response.body.client.client_id).toBe('valid-client');
+ });
+
+ it('rejects invalid client_id', async () => {
+ const response = await supertest(app).post('/protected').send({
+ client_id: 'non-existent-client',
+ client_secret: 'some-secret'
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_client');
+ expect(response.body.error_description).toBe('Invalid client_id');
+ });
+
+ it('rejects invalid client_secret', async () => {
+ const response = await supertest(app).post('/protected').send({
+ client_id: 'valid-client',
+ client_secret: 'wrong-secret'
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_client');
+ expect(response.body.error_description).toBe('Invalid client_secret');
+ });
+
+ it('rejects missing client_id', async () => {
+ const response = await supertest(app).post('/protected').send({
+ client_secret: 'valid-secret'
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_request');
+ });
+
+ it('allows missing client_secret if client has none', async () => {
+ const response = await supertest(app).post('/protected').send({
+ client_id: 'expired-client'
+ });
+
+ // Since the client has no secret, this should pass without providing one
+ expect(response.status).toBe(200);
+ });
+
+ it('rejects request when client secret has expired', async () => {
+ const response = await supertest(app).post('/protected').send({
+ client_id: 'client-with-expired-secret',
+ client_secret: 'expired-secret'
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.error).toBe('invalid_client');
+ expect(response.body.error_description).toBe('Client secret has expired');
+ });
+
+ it('handles malformed request body', async () => {
+ const response = await supertest(app).post('/protected').send('not-json-format');
+
+ expect(response.status).toBe(400);
+ });
+
+ // Testing request with extra fields to ensure they're ignored
+ it('ignores extra fields in request', async () => {
+ const response = await supertest(app).post('/protected').send({
+ client_id: 'valid-client',
+ client_secret: 'valid-secret',
+ extra_field: 'should be ignored'
+ });
+
+ expect(response.status).toBe(200);
+ });
+});
diff --git a/typescript-sdk/src/server/auth/middleware/clientAuth.ts b/typescript-sdk/src/server/auth/middleware/clientAuth.ts
new file mode 100644
index 0000000..9969b87
--- /dev/null
+++ b/typescript-sdk/src/server/auth/middleware/clientAuth.ts
@@ -0,0 +1,72 @@
+import { z } from 'zod';
+import { RequestHandler } from 'express';
+import { OAuthRegisteredClientsStore } from '../clients.js';
+import { OAuthClientInformationFull } from '../../../shared/auth.js';
+import { InvalidRequestError, InvalidClientError, ServerError, OAuthError } from '../errors.js';
+
+export type ClientAuthenticationMiddlewareOptions = {
+ /**
+ * A store used to read information about registered OAuth clients.
+ */
+ clientsStore: OAuthRegisteredClientsStore;
+};
+
+const ClientAuthenticatedRequestSchema = z.object({
+ client_id: z.string(),
+ client_secret: z.string().optional()
+});
+
+declare module 'express-serve-static-core' {
+ interface Request {
+ /**
+ * The authenticated client for this request, if the `authenticateClient` middleware was used.
+ */
+ client?: OAuthClientInformationFull;
+ }
+}
+
+export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlewareOptions): RequestHandler {
+ return async (req, res, next) => {
+ try {
+ const result = ClientAuthenticatedRequestSchema.safeParse(req.body);
+ if (!result.success) {
+ throw new InvalidRequestError(String(result.error));
+ }
+
+ const { client_id, client_secret } = result.data;
+ const client = await clientsStore.getClient(client_id);
+ if (!client) {
+ throw new InvalidClientError('Invalid client_id');
+ }
+
+ // If client has a secret, validate it
+ if (client.client_secret) {
+ // Check if client_secret is required but not provided
+ if (!client_secret) {
+ throw new InvalidClientError('Client secret is required');
+ }
+
+ // Check if client_secret matches
+ if (client.client_secret !== client_secret) {
+ throw new InvalidClientError('Invalid client_secret');
+ }
+
+ // Check if client_secret has expired
+ if (client.client_secret_expires_at && client.client_secret_expires_at < Math.floor(Date.now() / 1000)) {
+ throw new InvalidClientError('Client secret has expired');
+ }
+ }
+
+ req.client = client;
+ next();
+ } catch (error) {
+ if (error instanceof OAuthError) {
+ const status = error instanceof ServerError ? 500 : 400;
+ res.status(status).json(error.toResponseObject());
+ } else {
+ const serverError = new ServerError('Internal Server Error');
+ res.status(500).json(serverError.toResponseObject());
+ }
+ }
+ };
+}
diff --git a/typescript-sdk/src/server/auth/provider.ts b/typescript-sdk/src/server/auth/provider.ts
new file mode 100644
index 0000000..cf1c306
--- /dev/null
+++ b/typescript-sdk/src/server/auth/provider.ts
@@ -0,0 +1,83 @@
+import { Response } from 'express';
+import { OAuthRegisteredClientsStore } from './clients.js';
+import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js';
+import { AuthInfo } from './types.js';
+
+export type AuthorizationParams = {
+ state?: string;
+ scopes?: string[];
+ codeChallenge: string;
+ redirectUri: string;
+ resource?: URL;
+};
+
+/**
+ * Implements an end-to-end OAuth server.
+ */
+export interface OAuthServerProvider {
+ /**
+ * A store used to read information about registered OAuth clients.
+ */
+ get clientsStore(): OAuthRegisteredClientsStore;
+
+ /**
+ * Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server.
+ *
+ * This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1:
+ * - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters.
+ * - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter.
+ */
+ authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise;
+
+ /**
+ * Returns the `codeChallenge` that was used when the indicated authorization began.
+ */
+ challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise;
+
+ /**
+ * Exchanges an authorization code for an access token.
+ */
+ exchangeAuthorizationCode(
+ client: OAuthClientInformationFull,
+ authorizationCode: string,
+ codeVerifier?: string,
+ redirectUri?: string,
+ resource?: URL
+ ): Promise;
+
+ /**
+ * Exchanges a refresh token for an access token.
+ */
+ exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise;
+
+ /**
+ * Verifies an access token and returns information about it.
+ */
+ verifyAccessToken(token: string): Promise;
+
+ /**
+ * Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended).
+ *
+ * If the given token is invalid or already revoked, this method should do nothing.
+ */
+ revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise;
+
+ /**
+ * Whether to skip local PKCE validation.
+ *
+ * If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server.
+ *
+ * NOTE: This should only be true if the upstream server is performing the actual PKCE validation.
+ */
+ skipLocalPkceValidation?: boolean;
+}
+
+/**
+ * Slim implementation useful for token verification
+ */
+export interface OAuthTokenVerifier {
+ /**
+ * Verifies an access token and returns information about it.
+ */
+ verifyAccessToken(token: string): Promise;
+}
diff --git a/typescript-sdk/src/server/auth/providers/proxyProvider.test.ts b/typescript-sdk/src/server/auth/providers/proxyProvider.test.ts
new file mode 100644
index 0000000..97069ca
--- /dev/null
+++ b/typescript-sdk/src/server/auth/providers/proxyProvider.test.ts
@@ -0,0 +1,343 @@
+import { Response } from 'express';
+import { ProxyOAuthServerProvider, ProxyOptions } from './proxyProvider.js';
+import { AuthInfo } from '../types.js';
+import { OAuthClientInformationFull, OAuthTokens } from '../../../shared/auth.js';
+import { ServerError } from '../errors.js';
+import { InvalidTokenError } from '../errors.js';
+import { InsufficientScopeError } from '../errors.js';
+
+describe('Proxy OAuth Server Provider', () => {
+ // Mock client data
+ const validClient: OAuthClientInformationFull = {
+ client_id: 'test-client',
+ client_secret: 'test-secret',
+ redirect_uris: ['https://example.com/callback']
+ };
+
+ // Mock response object
+ const mockResponse = {
+ redirect: jest.fn()
+ } as unknown as Response;
+
+ // Mock provider functions
+ const mockVerifyToken = jest.fn();
+ const mockGetClient = jest.fn();
+
+ // Base provider options
+ const baseOptions: ProxyOptions = {
+ endpoints: {
+ authorizationUrl: 'https://auth.example.com/authorize',
+ tokenUrl: 'https://auth.example.com/token',
+ revocationUrl: 'https://auth.example.com/revoke',
+ registrationUrl: 'https://auth.example.com/register'
+ },
+ verifyAccessToken: mockVerifyToken,
+ getClient: mockGetClient
+ };
+
+ let provider: ProxyOAuthServerProvider;
+ let originalFetch: typeof global.fetch;
+
+ beforeEach(() => {
+ provider = new ProxyOAuthServerProvider(baseOptions);
+ originalFetch = global.fetch;
+ global.fetch = jest.fn();
+
+ // Setup mock implementations
+ mockVerifyToken.mockImplementation(async (token: string) => {
+ if (token === 'valid-token') {
+ return {
+ token,
+ clientId: 'test-client',
+ scopes: ['read', 'write'],
+ expiresAt: Date.now() / 1000 + 3600
+ } as AuthInfo;
+ }
+ throw new InvalidTokenError('Invalid token');
+ });
+
+ mockGetClient.mockImplementation(async (clientId: string) => {
+ if (clientId === 'test-client') {
+ return validClient;
+ }
+ return undefined;
+ });
+ });
+
+ // Add helper function for failed responses
+ const mockFailedResponse = () => {
+ (global.fetch as jest.Mock).mockImplementation(() =>
+ Promise.resolve({
+ ok: false,
+ status: 400
+ })
+ );
+ };
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ jest.clearAllMocks();
+ });
+
+ describe('authorization', () => {
+ it('redirects to authorization endpoint with correct parameters', async () => {
+ await provider.authorize(
+ validClient,
+ {
+ redirectUri: 'https://example.com/callback',
+ codeChallenge: 'test-challenge',
+ state: 'test-state',
+ scopes: ['read', 'write'],
+ resource: new URL('https://api.example.com/resource')
+ },
+ mockResponse
+ );
+
+ const expectedUrl = new URL('https://auth.example.com/authorize');
+ expectedUrl.searchParams.set('client_id', 'test-client');
+ expectedUrl.searchParams.set('response_type', 'code');
+ expectedUrl.searchParams.set('redirect_uri', 'https://example.com/callback');
+ expectedUrl.searchParams.set('code_challenge', 'test-challenge');
+ 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());
+ });
+ });
+
+ describe('token exchange', () => {
+ const mockTokenResponse: OAuthTokens = {
+ access_token: 'new-access-token',
+ token_type: 'Bearer',
+ expires_in: 3600,
+ refresh_token: 'new-refresh-token'
+ };
+
+ beforeEach(() => {
+ (global.fetch as jest.Mock).mockImplementation(() =>
+ Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(mockTokenResponse)
+ })
+ );
+ });
+
+ it('exchanges authorization code for tokens', async () => {
+ const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier');
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'https://auth.example.com/token',
+ expect.objectContaining({
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: expect.stringContaining('grant_type=authorization_code')
+ })
+ );
+ expect(tokens).toEqual(mockTokenResponse);
+ });
+
+ it('includes redirect_uri in token request when provided', async () => {
+ const redirectUri = 'https://example.com/callback';
+ const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier', redirectUri);
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'https://auth.example.com/token',
+ expect.objectContaining({
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`)
+ })
+ );
+ 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, 'test-refresh-token', ['read', 'write']);
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'https://auth.example.com/token',
+ expect.objectContaining({
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: expect.stringContaining('grant_type=refresh_token')
+ })
+ );
+ 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', () => {
+ it('registers new client', async () => {
+ const newClient: OAuthClientInformationFull = {
+ client_id: 'new-client',
+ redirect_uris: ['https://new-client.com/callback']
+ };
+
+ (global.fetch as jest.Mock).mockImplementation(() =>
+ Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(newClient)
+ })
+ );
+
+ const result = await provider.clientsStore.registerClient!(newClient);
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'https://auth.example.com/register',
+ expect.objectContaining({
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(newClient)
+ })
+ );
+ expect(result).toEqual(newClient);
+ });
+
+ it('handles registration failure', async () => {
+ mockFailedResponse();
+ const newClient: OAuthClientInformationFull = {
+ client_id: 'new-client',
+ redirect_uris: ['https://new-client.com/callback']
+ };
+
+ await expect(provider.clientsStore.registerClient!(newClient)).rejects.toThrow(ServerError);
+ });
+ });
+
+ describe('token revocation', () => {
+ it('revokes token', async () => {
+ (global.fetch as jest.Mock).mockImplementation(() =>
+ Promise.resolve({
+ ok: true
+ })
+ );
+
+ await provider.revokeToken!(validClient, {
+ token: 'token-to-revoke',
+ token_type_hint: 'access_token'
+ });
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'https://auth.example.com/revoke',
+ expect.objectContaining({
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: expect.stringContaining('token=token-to-revoke')
+ })
+ );
+ });
+
+ it('handles revocation failure', async () => {
+ mockFailedResponse();
+ await expect(
+ provider.revokeToken!(validClient, {
+ token: 'invalid-token'
+ })
+ ).rejects.toThrow(ServerError);
+ });
+ });
+
+ describe('token verification', () => {
+ it('verifies valid token', async () => {
+ const validAuthInfo: AuthInfo = {
+ token: 'valid-token',
+ clientId: 'test-client',
+ scopes: ['read', 'write'],
+ expiresAt: Date.now() / 1000 + 3600
+ };
+ mockVerifyToken.mockResolvedValue(validAuthInfo);
+
+ const authInfo = await provider.verifyAccessToken('valid-token');
+ expect(authInfo).toEqual(validAuthInfo);
+ expect(mockVerifyToken).toHaveBeenCalledWith('valid-token');
+ });
+
+ it('passes through InvalidTokenError', async () => {
+ const error = new InvalidTokenError('Token expired');
+ mockVerifyToken.mockRejectedValue(error);
+
+ await expect(provider.verifyAccessToken('invalid-token')).rejects.toBe(error);
+ expect(mockVerifyToken).toHaveBeenCalledWith('invalid-token');
+ });
+
+ it('passes through InsufficientScopeError', async () => {
+ const error = new InsufficientScopeError('Required scopes: read, write');
+ mockVerifyToken.mockRejectedValue(error);
+
+ await expect(provider.verifyAccessToken('token-with-insufficient-scope')).rejects.toBe(error);
+ expect(mockVerifyToken).toHaveBeenCalledWith('token-with-insufficient-scope');
+ });
+
+ it('passes through unexpected errors', async () => {
+ const error = new Error('Unexpected error');
+ mockVerifyToken.mockRejectedValue(error);
+
+ await expect(provider.verifyAccessToken('valid-token')).rejects.toBe(error);
+ expect(mockVerifyToken).toHaveBeenCalledWith('valid-token');
+ });
+ });
+});
diff --git a/typescript-sdk/src/server/auth/providers/proxyProvider.ts b/typescript-sdk/src/server/auth/providers/proxyProvider.ts
new file mode 100644
index 0000000..32f2564
--- /dev/null
+++ b/typescript-sdk/src/server/auth/providers/proxyProvider.ts
@@ -0,0 +1,234 @@
+import { Response } from 'express';
+import { OAuthRegisteredClientsStore } from '../clients.js';
+import {
+ OAuthClientInformationFull,
+ OAuthClientInformationFullSchema,
+ OAuthTokenRevocationRequest,
+ OAuthTokens,
+ OAuthTokensSchema
+} from '../../../shared/auth.js';
+import { AuthInfo } from '../types.js';
+import { AuthorizationParams, OAuthServerProvider } from '../provider.js';
+import { ServerError } from '../errors.js';
+import { FetchLike } from '../../../shared/transport.js';
+
+export type ProxyEndpoints = {
+ authorizationUrl: string;
+ tokenUrl: string;
+ revocationUrl?: string;
+ registrationUrl?: string;
+};
+
+export type ProxyOptions = {
+ /**
+ * Individual endpoint URLs for proxying specific OAuth operations
+ */
+ endpoints: ProxyEndpoints;
+
+ /**
+ * Function to verify access tokens and return auth info
+ */
+ verifyAccessToken: (token: string) => Promise