diff --git a/tools/modelcontextprotocol/src/index.ts b/tools/modelcontextprotocol/src/index.ts index 95ac4394..f4f83cec 100644 --- a/tools/modelcontextprotocol/src/index.ts +++ b/tools/modelcontextprotocol/src/index.ts @@ -9,10 +9,9 @@ import { validateStripeAccount, buildHeaders, } from './cli'; +import {extractClientName, buildUserAgent} from './userAgent'; const MCP_SERVER_URL = 'https://mcp.stripe.com'; -const VERSION = '0.3.1'; -const USER_AGENT = `stripe-mcp-local/${VERSION}`; function handleError(error: unknown): void { const message = error instanceof Error ? error.message : String(error); @@ -29,32 +28,61 @@ export async function main(): Promise { validateStripeAccount(options.stripeAccount); } - const headers = buildHeaders(options, USER_AGENT); - - // Create stdio transport (listens for messages from Claude Desktop) + // Create stdio transport (listens for messages from MCP clients) const stdioTransport = new StdioServerTransport(); - // Create HTTP transport (connects to remote MCP server) - const httpTransport = new StreamableHTTPClientTransport( - new URL(MCP_SERVER_URL), - {requestInit: {headers}} - ); + let httpTransport: StreamableHTTPClientTransport | null = null; + + function createHttpTransport( + userAgent: string + ): StreamableHTTPClientTransport { + const headers = buildHeaders(options, userAgent); + const transport = new StreamableHTTPClientTransport( + new URL(MCP_SERVER_URL), + {requestInit: {headers}} + ); + + // Wire up message forwarding: HTTP -> stdio + transport.onmessage = async (message) => { + try { + await stdioTransport.send(message); + } catch (error) { + console.error(red('Error forwarding message to client:'), error); + } + }; + + transport.onerror = (error) => { + console.error(red('HTTP transport error:'), error); + }; + + transport.onclose = () => { + stdioTransport.close(); + }; + + return transport; + } // Wire up message forwarding: stdio -> HTTP + // The first message is inspected for clientInfo to build the User-Agent. + let initialized = false; + stdioTransport.onmessage = async (message) => { try { - await httpTransport.send(message); - } catch (error) { - console.error(red('Error forwarding message to server:'), error); - } - }; + if (!initialized) { + initialized = true; - // Wire up message forwarding: HTTP -> stdio - httpTransport.onmessage = async (message) => { - try { - await stdioTransport.send(message); + // Extract client name from the initialize request (if present) + const clientName = extractClientName(message); + const userAgent = buildUserAgent(clientName); + + // Create and start the HTTP transport with the enriched User-Agent + httpTransport = createHttpTransport(userAgent); + await httpTransport.start(); + } + + await httpTransport!.send(message); } catch (error) { - console.error(red('Error forwarding message to client:'), error); + console.error(red('Error forwarding message to server:'), error); } }; @@ -63,21 +91,12 @@ export async function main(): Promise { console.error(red('Stdio transport error:'), error); }; - httpTransport.onerror = (error) => { - console.error(red('HTTP transport error:'), error); - }; - - // Handle transport close - just close the other transport + // Handle transport close stdioTransport.onclose = () => { - httpTransport.close(); - }; - - httpTransport.onclose = () => { - stdioTransport.close(); + httpTransport?.close(); }; - // Start both transports - await httpTransport.start(); + // Start stdio transport (HTTP transport starts on first message) await stdioTransport.start(); // Log success to stderr (stdout is reserved for MCP messages) diff --git a/tools/modelcontextprotocol/src/test/index.test.ts b/tools/modelcontextprotocol/src/test/index.test.ts index 1c50931b..c09bf114 100644 --- a/tools/modelcontextprotocol/src/test/index.test.ts +++ b/tools/modelcontextprotocol/src/test/index.test.ts @@ -1,4 +1,100 @@ import {parseArgs} from '../cli'; +import {extractClientName, buildUserAgent} from '../userAgent'; + +describe('extractClientName', () => { + it('should extract client name from a valid initialize request', () => { + const message = { + jsonrpc: '2.0' as const, + id: 0, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { + name: 'cursor', + version: '1.0.0', + }, + }, + }; + expect(extractClientName(message)).toBe('cursor'); + }); + + it('should extract client name from Claude Desktop initialize request', () => { + const message = { + jsonrpc: '2.0' as const, + id: 0, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { + name: 'claude-ai', + version: '0.1.0', + }, + }, + }; + expect(extractClientName(message)).toBe('claude-ai'); + }); + + it('should return undefined for non-initialize messages', () => { + const message = { + jsonrpc: '2.0' as const, + id: 1, + method: 'tools/list', + params: {}, + }; + expect(extractClientName(message)).toBeUndefined(); + }); + + it('should return undefined when clientInfo is missing', () => { + const message = { + jsonrpc: '2.0' as const, + id: 0, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + }, + }; + expect(extractClientName(message)).toBeUndefined(); + }); + + it('should return undefined for response messages', () => { + const message = { + jsonrpc: '2.0' as const, + id: 0, + result: { + protocolVersion: '2024-11-05', + serverInfo: {name: 'Stripe', version: '0.4.0'}, + }, + }; + expect(extractClientName(message)).toBeUndefined(); + }); + + it('should return undefined for notification messages', () => { + const message = { + jsonrpc: '2.0' as const, + method: 'notifications/initialized', + }; + expect(extractClientName(message)).toBeUndefined(); + }); +}); + +describe('buildUserAgent', () => { + it('should append client name in parentheses when provided', () => { + expect(buildUserAgent('cursor')).toMatch( + /^stripe-mcp-local\/[\d.]+ \(cursor\)$/ + ); + }); + + it('should return base user agent when no client name provided', () => { + expect(buildUserAgent()).toMatch(/^stripe-mcp-local\/[\d.]+$/); + }); + + it('should return base user agent when client name is undefined', () => { + expect(buildUserAgent(undefined)).toMatch(/^stripe-mcp-local\/[\d.]+$/); + }); +}); describe('parseArgs function', () => { const originalEnv = process.env.STRIPE_SECRET_KEY; diff --git a/tools/modelcontextprotocol/src/userAgent.ts b/tools/modelcontextprotocol/src/userAgent.ts new file mode 100644 index 00000000..29edc777 --- /dev/null +++ b/tools/modelcontextprotocol/src/userAgent.ts @@ -0,0 +1,36 @@ +const VERSION = '0.3.1'; +const BASE_USER_AGENT = `stripe-mcp-local/${VERSION}`; + +/** + * Extract the client name from an MCP initialize request message. + * Returns undefined if the message is not an initialize request or has no clientInfo. + */ +export function extractClientName(message: { + method?: string; + params?: unknown; + [key: string]: unknown; +}): string | undefined { + if ( + message.method === 'initialize' && + message.params != null && + typeof message.params === 'object' && + 'clientInfo' in message.params && + message.params.clientInfo != null && + typeof message.params.clientInfo === 'object' && + 'name' in message.params.clientInfo && + typeof message.params.clientInfo.name === 'string' + ) { + return message.params.clientInfo.name; + } + return undefined; +} + +/** + * Build the User-Agent string, appending the MCP client name if available. + */ +export function buildUserAgent(clientName?: string): string { + if (clientName) { + return `${BASE_USER_AGENT} (${clientName})`; + } + return BASE_USER_AGENT; +}