Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 51 additions & 32 deletions tools/modelcontextprotocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -29,32 +28,61 @@ export async function main(): Promise<void> {
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);
}
};

Expand All @@ -63,21 +91,12 @@ export async function main(): Promise<void> {
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)
Expand Down
96 changes: 96 additions & 0 deletions tools/modelcontextprotocol/src/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
36 changes: 36 additions & 0 deletions tools/modelcontextprotocol/src/userAgent.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading