Skip to content

Commit afb99f1

Browse files
committed
feat: add HTTP transport support with new StreamableHTTPServerTransport class
1 parent b5464a0 commit afb99f1

File tree

3 files changed

+303
-8
lines changed

3 files changed

+303
-8
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
"version:major": "node scripts/bump-version.js major",
2424
"prepublishOnly": "npm run build",
2525
"start": "node dist/index.js",
26+
"start:http": "node dist/index.js --http",
27+
"start:both": "node dist/index.js --both",
2628
"cli": "node dist/index.js",
2729
"dev": "tsx src/index.ts",
2830
"test": "npm run build && node dist/tests/memory-server-tests.js",

src/index.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { toolRegistry } from './tools/index.js';
1717
import type { ToolContext } from './types/tools.js';
1818
import { debugLog } from './utils/debug.js';
1919
import { checkDatabaseIntegrity, rebuildHashIndex } from './utils/db-integrity-check.js';
20+
import { StreamableHTTPServerTransport } from './transports/streamable-http.js';
2021

2122
// Initialize server
2223
const server = new Server(
@@ -117,6 +118,14 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
117118
async function main() {
118119
const args = process.argv.slice(2);
119120

121+
// Check for --http or --both flags
122+
const useHttp = args.includes('--http') || args.includes('--both');
123+
const useBoth = args.includes('--both');
124+
const useStdio = !args.includes('--http') || useBoth;
125+
126+
// Remove transport flags from args
127+
const cliArgs = args.filter(arg => arg !== '--http' && arg !== '--both');
128+
120129
// Initialize services (backup auto-configures from env vars)
121130
memoryService = initializeServices();
122131

@@ -126,9 +135,9 @@ async function main() {
126135
config: {}
127136
};
128137

129-
if (args.length > 0) {
138+
if (cliArgs.length > 0) {
130139
// CLI mode - check for integrity commands first
131-
if (args[0] === 'check-integrity') {
140+
if (cliArgs[0] === 'check-integrity') {
132141
const dbPath = process.env.MEMORY_DB || './memory.db';
133142
console.log('Running database integrity check...\n');
134143
const result = checkDatabaseIntegrity(dbPath);
@@ -151,14 +160,14 @@ async function main() {
151160
}
152161

153162
process.exit(result.orphanedMemories.length > 0 ? 1 : 0);
154-
} else if (args[0] === 'rebuild-index') {
163+
} else if (cliArgs[0] === 'rebuild-index') {
155164
const dbPath = process.env.MEMORY_DB || './memory.db';
156165
rebuildHashIndex(dbPath);
157166
process.exit(0);
158167
}
159168

160169
// CLI mode - handle tool execution
161-
const [toolName, ...toolArgs] = args;
170+
const [toolName, ...toolArgs] = cliArgs;
162171

163172
if (!toolRegistry.hasTool(toolName)) {
164173
console.error(`Unknown tool: ${toolName}`);
@@ -176,10 +185,40 @@ async function main() {
176185
process.exit(1);
177186
}
178187
} else {
179-
// MCP mode
180-
const transport = new StdioServerTransport();
181-
await server.connect(transport);
182-
debugLog('Simple Memory MCP server running on stdio');
188+
// MCP mode - connect transport(s)
189+
if (useHttp) {
190+
// HTTP transport mode
191+
const httpPort = parseInt(process.env.MCP_PORT || '3000', 10);
192+
const httpHost = process.env.MCP_HOST || 'localhost';
193+
194+
const httpTransport = new StreamableHTTPServerTransport({
195+
port: httpPort,
196+
host: httpHost
197+
});
198+
199+
try {
200+
await server.connect(httpTransport);
201+
console.log(`✅ Simple Memory MCP server running on HTTP: http://${httpHost}:${httpPort}/mcp`);
202+
} catch (error) {
203+
console.error('Failed to start HTTP transport:', error instanceof Error ? error.message : error);
204+
if (error instanceof Error && error.stack) {
205+
debugLog('Stack trace:', error.stack);
206+
}
207+
process.exit(1);
208+
}
209+
}
210+
211+
if (useStdio) {
212+
// Stdio transport mode (default or hybrid)
213+
if (useBoth) {
214+
debugLog('🔌 Hybrid mode: stdio + HTTP');
215+
// Servers are already connected above in HTTP block
216+
} else {
217+
const transport = new StdioServerTransport();
218+
await server.connect(transport);
219+
debugLog('🔌 Simple Memory MCP server running on stdio');
220+
}
221+
}
183222
}
184223
}
185224

src/transports/streamable-http.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
/**
2+
* StreamableHTTPServerTransport - MCP transport over HTTP
3+
*
4+
* Implements the MCP Server transport interface using HTTP instead of stdio.
5+
* Supports multiple clients connecting via HTTP POST requests.
6+
*/
7+
8+
import { createServer, IncomingMessage, ServerResponse } from 'http';
9+
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
10+
import { debugLog } from '../utils/debug.js';
11+
12+
interface HTTPTransportOptions {
13+
port?: number;
14+
host?: string;
15+
}
16+
17+
/**
18+
* Simple request-scoped response buffer for storing responses
19+
* Each HTTP request gets its own buffer to collect responses
20+
*/
21+
class ResponseBuffer {
22+
private responses: JSONRPCMessage[] = [];
23+
24+
add(message: JSONRPCMessage): void {
25+
this.responses.push(message);
26+
}
27+
28+
getAll(): JSONRPCMessage[] {
29+
return this.responses;
30+
}
31+
}
32+
33+
// Thread-local style buffer (request-scoped)
34+
let currentBuffer: ResponseBuffer | null = null;
35+
36+
/**
37+
* Helper functions for HTTP responses
38+
*/
39+
function sendJSON(res: ServerResponse, statusCode: number, data: any): void {
40+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
41+
res.end(JSON.stringify(data));
42+
}
43+
44+
function sendError(res: ServerResponse, statusCode: number, code: number, message: string, id: any = null): void {
45+
sendJSON(res, statusCode, {
46+
jsonrpc: '2.0',
47+
error: { code, message },
48+
id
49+
});
50+
}
51+
52+
function sendSuccess(res: ServerResponse, data: any): void {
53+
sendJSON(res, 200, data);
54+
}
55+
56+
function sendNotFound(res: ServerResponse): void {
57+
sendJSON(res, 404, { error: 'Not found' });
58+
}
59+
60+
function sendPayloadTooLarge(res: ServerResponse): void {
61+
sendJSON(res, 413, {
62+
error: 'Payload too large',
63+
code: -32600
64+
});
65+
}
66+
67+
function sendAccepted(res: ServerResponse): void {
68+
res.writeHead(202);
69+
res.end();
70+
}
71+
72+
export class StreamableHTTPServerTransport {
73+
private port: number;
74+
private host: string;
75+
private httpServer: any = null;
76+
77+
// Transport interface properties (standard MCP interface)
78+
onmessage?: (message: JSONRPCMessage) => void;
79+
onclose?: () => void;
80+
onerror?: (error: Error) => void;
81+
82+
constructor(options: HTTPTransportOptions = {}) {
83+
this.port = options.port || 3000;
84+
this.host = options.host || 'localhost';
85+
}
86+
87+
/**
88+
* Start the HTTP server
89+
*/
90+
async start(): Promise<void> {
91+
if (this.httpServer) {
92+
throw new Error('Server already started');
93+
}
94+
95+
return new Promise((resolve, reject) => {
96+
this.httpServer = createServer(this.handleRequest.bind(this));
97+
98+
this.httpServer.once('error', (error: any) => {
99+
this.httpServer = null;
100+
if (error.code === 'EADDRINUSE') {
101+
reject(new Error(`Port ${this.port} is already in use`));
102+
} else {
103+
reject(error);
104+
}
105+
});
106+
107+
this.httpServer.once('listening', () => {
108+
console.log(`✅ HTTP MCP Transport listening on http://${this.host}:${this.port}/mcp`);
109+
resolve();
110+
});
111+
112+
this.httpServer.listen(this.port, this.host);
113+
});
114+
}
115+
116+
/**
117+
* Close the HTTP server
118+
*/
119+
async close(): Promise<void> {
120+
return new Promise((resolve) => {
121+
if (this.httpServer) {
122+
this.httpServer.close(() => {
123+
console.log('✓ HTTP MCP Transport closed');
124+
resolve();
125+
});
126+
} else {
127+
resolve();
128+
}
129+
});
130+
}
131+
132+
/**
133+
* Send a message to the client
134+
* In HTTP mode, responses are buffered and sent in the response body
135+
*/
136+
async send(message: JSONRPCMessage): Promise<void> {
137+
debugLog('[HTTP Transport] Sending message:', message);
138+
139+
// If we're in a request context, buffer the response
140+
if (currentBuffer) {
141+
currentBuffer.add(message);
142+
} else {
143+
debugLog('[HTTP Transport] Warning: send() called outside of request context');
144+
}
145+
}
146+
147+
/**
148+
* Handle incoming HTTP requests
149+
*/
150+
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
151+
// Add CORS headers
152+
res.setHeader('Access-Control-Allow-Origin', '*');
153+
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
154+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
155+
156+
// Handle OPTIONS requests
157+
if (req.method === 'OPTIONS') {
158+
res.writeHead(200);
159+
res.end();
160+
return;
161+
}
162+
163+
// Health check endpoint
164+
if (req.url === '/health' && req.method === 'GET') {
165+
sendSuccess(res, { status: 'ok', transport: 'http' });
166+
return;
167+
}
168+
169+
// MCP protocol endpoint
170+
if (req.url === '/mcp' && req.method === 'POST') {
171+
await this.handleMCPRequest(req, res);
172+
return;
173+
}
174+
175+
// Unknown endpoint
176+
sendNotFound(res);
177+
}
178+
179+
/**
180+
* Handle MCP protocol POST requests
181+
*/
182+
private async handleMCPRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
183+
let body = '';
184+
185+
// Read request body
186+
req.on('data', (chunk: Buffer) => {
187+
body += chunk.toString('utf-8');
188+
189+
// Prevent body from growing too large
190+
if (body.length > 1024 * 1024) {
191+
req.removeAllListeners('data');
192+
sendPayloadTooLarge(res);
193+
}
194+
});
195+
196+
req.on('end', async () => {
197+
try {
198+
// Parse JSON-RPC request
199+
let request: JSONRPCMessage;
200+
201+
try {
202+
request = JSON.parse(body);
203+
debugLog('[HTTP Transport] Received request:', request);
204+
} catch (parseError) {
205+
debugLog('[HTTP Transport] Parse error:', parseError);
206+
sendError(res, 400, -32700, 'Parse error');
207+
return;
208+
}
209+
210+
// Create response buffer for this request
211+
const buffer = new ResponseBuffer();
212+
currentBuffer = buffer;
213+
214+
try {
215+
// Call the message handler (set by MCP Server)
216+
if (this.onmessage) {
217+
this.onmessage(request);
218+
219+
// Wait for handler to process and buffer responses
220+
// TODO: Replace with proper async handling
221+
await new Promise(resolve => setTimeout(resolve, 100));
222+
}
223+
224+
// Send buffered responses
225+
const responses = buffer.getAll();
226+
227+
if (responses.length > 0) {
228+
sendJSON(res, 200, responses.length === 1 ? responses[0] : responses);
229+
} else {
230+
// No response (notification or async processing)
231+
sendAccepted(res);
232+
}
233+
} finally {
234+
currentBuffer = null;
235+
}
236+
} catch (error: any) {
237+
debugLog('[HTTP Transport] Request error:', error);
238+
239+
if (!res.headersSent) {
240+
sendError(res, 500, -32603, error.message || 'Internal server error');
241+
}
242+
}
243+
});
244+
245+
req.on('error', (error: Error) => {
246+
debugLog('[HTTP Transport] Request error:', error);
247+
if (!res.headersSent) {
248+
sendError(res, 400, -32600, 'Invalid request');
249+
}
250+
});
251+
}
252+
}
253+
254+
export default StreamableHTTPServerTransport;

0 commit comments

Comments
 (0)