Skip to content

Commit e21cf77

Browse files
committed
feat: support http transport
1 parent aa134f6 commit e21cf77

File tree

13 files changed

+1234
-18
lines changed

13 files changed

+1234
-18
lines changed

src/__tests__/options.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,62 @@ describe('parseCliOptions', () => {
2727

2828
expect(result).toMatchSnapshot();
2929
});
30+
31+
describe('HTTP transport options', () => {
32+
it.each([
33+
{
34+
description: 'with --http flag',
35+
args: ['node', 'script.js', '--http'],
36+
expected: { http: true, port: 3000, host: 'localhost' }
37+
},
38+
{
39+
description: 'with --http and --port',
40+
args: ['node', 'script.js', '--http', '--port', '8080'],
41+
expected: { http: true, port: 8080, host: 'localhost' }
42+
},
43+
{
44+
description: 'with --http and --host',
45+
args: ['node', 'script.js', '--http', '--host', '0.0.0.0'],
46+
expected: { http: true, port: 3000, host: '0.0.0.0' }
47+
},
48+
{
49+
description: 'with --allowed-origins',
50+
args: ['node', 'script.js', '--http', '--allowed-origins', 'https://app.com,https://admin.app.com'],
51+
expected: {
52+
http: true,
53+
port: 3000,
54+
host: 'localhost',
55+
allowedOrigins: ['https://app.com', 'https://admin.app.com']
56+
}
57+
},
58+
{
59+
description: 'with --allowed-hosts',
60+
args: ['node', 'script.js', '--http', '--allowed-hosts', 'localhost,127.0.0.1'],
61+
expected: {
62+
http: true,
63+
port: 3000,
64+
host: 'localhost',
65+
allowedHosts: ['localhost', '127.0.0.1']
66+
}
67+
}
68+
])('should parse HTTP options $description', ({ args, expected }) => {
69+
process.argv = args;
70+
71+
const result = parseCliOptions();
72+
73+
expect(result).toMatchObject(expected);
74+
});
75+
76+
it('should throw error for invalid port', () => {
77+
process.argv = ['node', 'script.js', '--http', '--port', '99999'];
78+
79+
expect(() => parseCliOptions()).toThrow('Invalid port: 99999. Must be between 1 and 65535.');
80+
});
81+
82+
it('should throw error for invalid port (negative)', () => {
83+
process.argv = ['node', 'script.js', '--http', '--port', '-1'];
84+
85+
expect(() => parseCliOptions()).toThrow('Invalid port: -1. Must be between 1 and 65535.');
86+
});
87+
});
3088
});

src/__tests__/server.http.test.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { createServer } from 'node:http';
2+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4+
import { startHttpTransport } from '../server.http';
5+
6+
// Mock dependencies
7+
jest.mock('@modelcontextprotocol/sdk/server/mcp.js');
8+
jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js');
9+
jest.mock('node:http');
10+
11+
const MockMcpServer = McpServer as jest.MockedClass<typeof McpServer>;
12+
const MockStreamableHTTPServerTransport = StreamableHTTPServerTransport as jest.MockedClass<typeof StreamableHTTPServerTransport>;
13+
const MockCreateServer = createServer as jest.MockedFunction<typeof createServer>;
14+
15+
describe('HTTP Transport', () => {
16+
let mockServer: any;
17+
let mockTransport: any;
18+
let mockHttpServer: any;
19+
20+
beforeEach(() => {
21+
mockServer = {
22+
connect: jest.fn(),
23+
registerTool: jest.fn()
24+
};
25+
mockTransport = {
26+
handleRequest: jest.fn(),
27+
sessionId: 'test-session-123'
28+
};
29+
mockHttpServer = {
30+
on: jest.fn(),
31+
listen: jest.fn().mockImplementation((_port: any, _host: any, callback: any) => {
32+
// Immediately call the callback to simulate successful server start
33+
if (callback) callback();
34+
}),
35+
close: jest.fn()
36+
};
37+
38+
MockMcpServer.mockImplementation(() => mockServer);
39+
MockStreamableHTTPServerTransport.mockImplementation(() => mockTransport);
40+
MockCreateServer.mockReturnValue(mockHttpServer);
41+
});
42+
43+
afterEach(() => {
44+
jest.clearAllMocks();
45+
});
46+
47+
describe('startHttpTransport', () => {
48+
it('should start HTTP server on specified port and host', async () => {
49+
// Uses default parameter pattern - no need to pass options explicitly
50+
await startHttpTransport(mockServer);
51+
52+
expect(MockCreateServer).toHaveBeenCalled();
53+
expect(mockHttpServer.listen).toHaveBeenCalledWith(3000, 'localhost', expect.any(Function));
54+
});
55+
56+
it('should create StreamableHTTPServerTransport with correct options', async () => {
57+
await startHttpTransport(mockServer);
58+
59+
expect(MockStreamableHTTPServerTransport).toHaveBeenCalledWith({
60+
sessionIdGenerator: expect.any(Function),
61+
enableJsonResponse: false,
62+
allowedOrigins: undefined,
63+
allowedHosts: undefined,
64+
enableDnsRebindingProtection: true,
65+
onsessioninitialized: expect.any(Function),
66+
onsessionclosed: expect.any(Function)
67+
});
68+
});
69+
70+
it('should connect MCP server to transport', async () => {
71+
await startHttpTransport(mockServer);
72+
73+
expect(mockServer.connect).toHaveBeenCalledWith(mockTransport);
74+
});
75+
76+
it('should handle server errors', async () => {
77+
const error = new Error('Server error');
78+
79+
mockHttpServer.listen.mockImplementation((_port: any, _host: any, _callback: any) => {
80+
mockHttpServer.on.mockImplementation((event: any, handler: any) => {
81+
if (event === 'error') {
82+
handler(error);
83+
}
84+
});
85+
throw error;
86+
});
87+
88+
await expect(startHttpTransport(mockServer)).rejects.toThrow('Server error');
89+
});
90+
91+
it('should set up request handler', async () => {
92+
await startHttpTransport(mockServer);
93+
94+
// StreamableHTTPServerTransport handles requests directly
95+
expect(MockStreamableHTTPServerTransport).toHaveBeenCalled();
96+
});
97+
});
98+
99+
describe('HTTP request handling', () => {
100+
it('should delegate requests to StreamableHTTPServerTransport', async () => {
101+
await startHttpTransport(mockServer);
102+
103+
// Mock request and response
104+
const mockReq = {
105+
method: 'GET',
106+
url: '/mcp',
107+
headers: { host: 'localhost:3000' }
108+
};
109+
const mockRes = {
110+
setHeader: jest.fn(),
111+
writeHead: jest.fn(),
112+
end: jest.fn()
113+
};
114+
115+
// Call the transport's handleRequest method directly
116+
await mockTransport.handleRequest(mockReq, mockRes);
117+
118+
// Verify transport handles the request
119+
expect(mockTransport.handleRequest).toHaveBeenCalledWith(mockReq, mockRes);
120+
});
121+
122+
it('should handle all HTTP methods through transport', async () => {
123+
await startHttpTransport(mockServer);
124+
125+
// Test different HTTP methods
126+
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'];
127+
128+
for (const method of methods) {
129+
const mockReq = {
130+
method,
131+
url: '/mcp',
132+
headers: { host: 'localhost:3000' }
133+
};
134+
const mockRes = {
135+
setHeader: jest.fn(),
136+
writeHead: jest.fn(),
137+
end: jest.fn()
138+
};
139+
140+
await mockTransport.handleRequest(mockReq, mockRes);
141+
expect(mockTransport.handleRequest).toHaveBeenCalledWith(mockReq, mockRes);
142+
}
143+
});
144+
145+
it('should handle transport errors gracefully', async () => {
146+
await startHttpTransport(mockServer);
147+
148+
// Mock transport error
149+
const transportError = new Error('Transport error');
150+
151+
mockTransport.handleRequest.mockRejectedValue(transportError);
152+
153+
const mockReq = {
154+
method: 'GET',
155+
url: '/mcp',
156+
headers: { host: 'localhost:3000' }
157+
};
158+
const mockRes = {
159+
setHeader: jest.fn(),
160+
writeHead: jest.fn(),
161+
end: jest.fn()
162+
};
163+
164+
// Should throw - transport errors are propagated
165+
await expect(mockTransport.handleRequest(mockReq, mockRes)).rejects.toThrow('Transport error');
166+
});
167+
});
168+
169+
describe('StreamableHTTPServerTransport configuration', () => {
170+
it('should use crypto.randomUUID for session ID generation', async () => {
171+
await startHttpTransport(mockServer);
172+
173+
const transportOptions = MockStreamableHTTPServerTransport.mock.calls[0]?.[0];
174+
175+
expect(transportOptions?.sessionIdGenerator).toBeDefined();
176+
expect(typeof transportOptions?.sessionIdGenerator).toBe('function');
177+
178+
// Test that it generates UUIDs
179+
const sessionId = transportOptions?.sessionIdGenerator?.();
180+
181+
expect(sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
182+
});
183+
184+
it('should configure session callbacks', async () => {
185+
await startHttpTransport(mockServer);
186+
187+
const transportOptions = MockStreamableHTTPServerTransport.mock.calls[0]?.[0];
188+
189+
expect(transportOptions?.onsessioninitialized).toBeDefined();
190+
expect(transportOptions?.onsessionclosed).toBeDefined();
191+
expect(typeof transportOptions?.onsessioninitialized).toBe('function');
192+
expect(typeof transportOptions?.onsessionclosed).toBe('function');
193+
});
194+
195+
it('should enable SSE streaming', async () => {
196+
await startHttpTransport(mockServer);
197+
198+
const transportOptions = MockStreamableHTTPServerTransport.mock.calls[0]?.[0];
199+
200+
expect(transportOptions?.enableJsonResponse).toBe(false);
201+
});
202+
203+
it('should enable DNS rebinding protection', async () => {
204+
await startHttpTransport(mockServer);
205+
206+
const transportOptions = MockStreamableHTTPServerTransport.mock.calls[0]?.[0];
207+
208+
expect(transportOptions?.enableDnsRebindingProtection).toBe(true);
209+
});
210+
});
211+
});

src/options.ts

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { type DefaultOptions } from './options.defaults';
55
*/
66
interface CliOptions {
77
docsHost?: boolean;
8-
// Future CLI options can be added here
8+
http?: boolean;
9+
port?: number;
10+
host?: string;
11+
allowedOrigins?: string[];
12+
allowedHosts?: string[];
913
}
1014

1115
/**
@@ -15,16 +19,85 @@ interface GlobalOptions extends CliOptions, DefaultOptions {
1519
// Combined DefaultOptions and CliOptions
1620
}
1721

22+
/**
23+
24+
/**
25+
* Get argument value from process.argv
26+
*
27+
* @param flag - CLI flag to search for
28+
* @param defaultValue - Default value if flag not found
29+
*/
30+
const getArgValue = (flag: string, defaultValue?: any): any => {
31+
const index = process.argv.indexOf(flag);
32+
33+
if (index === -1) return defaultValue;
34+
35+
const value = process.argv[index + 1];
36+
37+
if (!value || value.startsWith('--')) return defaultValue;
38+
39+
// Type conversion based on defaultValue
40+
if (defaultValue !== undefined) {
41+
if (typeof defaultValue === 'number') {
42+
const num = parseInt(value, 10);
43+
44+
return isNaN(num) ? defaultValue : num;
45+
}
46+
}
47+
48+
return value;
49+
};
50+
51+
/**
52+
* Validate CLI options
53+
*
54+
* @param options - Parsed CLI options
55+
*/
56+
const validateCliOptions = (options: CliOptions): void => {
57+
if (options.port !== undefined) {
58+
if (options.port < 1 || options.port > 65535) {
59+
throw new Error(`Invalid port: ${options.port}. Must be between 1 and 65535.`);
60+
}
61+
}
62+
63+
if (options.allowedOrigins) {
64+
const filteredOrigins = options.allowedOrigins.filter(origin => origin.trim());
65+
66+
// eslint-disable-next-line no-param-reassign
67+
options.allowedOrigins = filteredOrigins;
68+
}
69+
70+
if (options.allowedHosts) {
71+
const filteredHosts = options.allowedHosts.filter(host => host.trim());
72+
73+
// eslint-disable-next-line no-param-reassign
74+
options.allowedHosts = filteredHosts;
75+
}
76+
};
77+
1878
/**
1979
* Parse CLI arguments and return CLI options
2080
*/
21-
const parseCliOptions = (): CliOptions => ({
22-
docsHost: process.argv.includes('--docs-host')
23-
// Future CLI options can be added here
24-
});
81+
const parseCliOptions = (): CliOptions => {
82+
const options: CliOptions = {
83+
docsHost: process.argv.includes('--docs-host'),
84+
http: process.argv.includes('--http'),
85+
port: getArgValue('--port', 3000),
86+
host: getArgValue('--host', 'localhost'),
87+
allowedOrigins: getArgValue('--allowed-origins')?.split(','),
88+
allowedHosts: getArgValue('--allowed-hosts')?.split(',')
89+
};
90+
91+
// Validate options
92+
validateCliOptions(options);
93+
94+
return options;
95+
};
2596

2697
export {
2798
parseCliOptions,
99+
getArgValue,
100+
validateCliOptions,
28101
type CliOptions,
29102
type DefaultOptions,
30103
type GlobalOptions

0 commit comments

Comments
 (0)