Skip to content

Commit 40d637d

Browse files
authored
Add SSE transport message endpoint and HTTP-Streamable transport support (#15)
This adds two missing transport capabilities to mcp-front. First, SSE servers can now expose message endpoints for request/response patterns alongside their streaming connections. This was needed because some MCP servers use dual endpoints - one for SSE streaming and another for JSON-RPC requests. Second, we now support HTTP-Streamable transport as specified in the MCP spec. This transport uses a single endpoint that can return either JSON responses or SSE streams depending on the Accept header. POST requests handle tool calls and can stream responses, while GET requests establish SSE connections for server-initiated messages. Both transports maintain the same authentication and proxy behavior as existing SSE support.
1 parent 2f1c8c6 commit 40d637d

23 files changed

+2987
-404
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"version": "v0.0.1-DEV_EDITION_EXPECT_CHANGES",
3+
"proxy": {
4+
"baseURL": "http://localhost:8080",
5+
"addr": ":8080",
6+
"name": "mcp-front-sse-test",
7+
"auth": {
8+
"kind": "bearerToken",
9+
"tokens": {
10+
"test-sse": [
11+
"sse-test-token"
12+
]
13+
}
14+
}
15+
},
16+
"mcpServers": {
17+
"test-sse": {
18+
"transportType": "sse",
19+
"url": "http://localhost:3001/sse"
20+
}
21+
}
22+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"version": "v0.0.1-DEV_EDITION_EXPECT_CHANGES",
3+
"proxy": {
4+
"baseURL": "http://localhost:8080",
5+
"addr": ":8080",
6+
"name": "mcp-front-streamable-test",
7+
"auth": {
8+
"kind": "bearerToken",
9+
"tokens": {
10+
"test-streamable": [
11+
"streamable-test-token"
12+
]
13+
}
14+
}
15+
},
16+
"mcpServers": {
17+
"test-streamable": {
18+
"transportType": "streamable-http",
19+
"url": "http://localhost:3002"
20+
}
21+
}
22+
}

integration/docker-compose.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,33 @@ services:
1414
interval: 5s
1515
timeout: 5s
1616
retries: 5
17+
18+
test-sse-server:
19+
image: node:20-alpine
20+
command: node /app/mock-sse-server.js
21+
ports:
22+
- "3001:3001"
23+
environment:
24+
- PORT=3001
25+
volumes:
26+
- ./fixtures/mock-sse-server.js:/app/mock-sse-server.js
27+
healthcheck:
28+
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001"]
29+
interval: 5s
30+
timeout: 5s
31+
retries: 5
32+
33+
test-streamable-server:
34+
image: node:20-alpine
35+
command: node /app/mock-streamable-server.js
36+
ports:
37+
- "3002:3002"
38+
environment:
39+
- PORT=3002
40+
volumes:
41+
- ./fixtures/mock-streamable-server.js:/app/mock-streamable-server.js
42+
healthcheck:
43+
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002"]
44+
interval: 5s
45+
timeout: 5s
46+
retries: 5
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
const http = require('http');
2+
3+
const PORT = process.env.PORT || 3001;
4+
5+
// Simple mock SSE MCP server for testing
6+
const server = http.createServer((req, res) => {
7+
console.log(`${req.method} ${req.url}`);
8+
9+
if (req.url === '/' && req.method === 'GET') {
10+
res.writeHead(200, { 'Content-Type': 'text/plain' });
11+
res.end('Mock SSE MCP Server');
12+
} else if (req.url === '/sse' && req.method === 'GET') {
13+
// SSE endpoint
14+
res.writeHead(200, {
15+
'Content-Type': 'text/event-stream',
16+
'Cache-Control': 'no-cache',
17+
'Connection': 'keep-alive',
18+
'Access-Control-Allow-Origin': '*'
19+
});
20+
21+
// Send initial endpoint message
22+
res.write('data: {"jsonrpc":"2.0","method":"endpoint","params":{"type":"endpoint","url":"/message"}}\n\n');
23+
24+
// Keep connection alive
25+
const keepAlive = setInterval(() => {
26+
res.write(':keepalive\n\n');
27+
}, 30000);
28+
29+
req.on('close', () => {
30+
clearInterval(keepAlive);
31+
});
32+
} else if (req.url === '/message' && req.method === 'POST') {
33+
// Message endpoint
34+
let body = '';
35+
req.on('data', chunk => {
36+
body += chunk.toString();
37+
});
38+
req.on('end', () => {
39+
try {
40+
const request = JSON.parse(body);
41+
console.log('Received request:', request);
42+
43+
let response;
44+
if (request.method === 'tools/list') {
45+
response = {
46+
jsonrpc: '2.0',
47+
id: request.id,
48+
result: {
49+
tools: [
50+
{
51+
name: 'echo_text',
52+
description: 'Echo the provided text',
53+
inputSchema: {
54+
type: 'object',
55+
properties: {
56+
text: { type: 'string' }
57+
},
58+
required: ['text']
59+
}
60+
},
61+
{
62+
name: 'sample_stream',
63+
description: 'Sample streaming tool',
64+
inputSchema: {
65+
type: 'object',
66+
properties: {}
67+
}
68+
}
69+
]
70+
}
71+
};
72+
} else if (request.method === 'tools/call') {
73+
if (request.params.name === 'echo_text') {
74+
response = {
75+
jsonrpc: '2.0',
76+
id: request.id,
77+
result: {
78+
toolResult: request.params.arguments.text
79+
}
80+
};
81+
} else if (request.params.name === 'non_existent_tool_xyz') {
82+
response = {
83+
jsonrpc: '2.0',
84+
id: request.id,
85+
error: {
86+
code: -32601,
87+
message: 'Tool not found: ' + request.params.name
88+
}
89+
};
90+
} else {
91+
response = {
92+
jsonrpc: '2.0',
93+
id: request.id,
94+
result: {
95+
toolResult: 'Tool executed successfully'
96+
}
97+
};
98+
}
99+
} else {
100+
response = {
101+
jsonrpc: '2.0',
102+
id: request.id,
103+
result: {}
104+
};
105+
}
106+
107+
res.writeHead(200, { 'Content-Type': 'application/json' });
108+
res.end(JSON.stringify(response));
109+
} catch (e) {
110+
console.error('Error processing request:', e);
111+
res.writeHead(400, { 'Content-Type': 'application/json' });
112+
res.end(JSON.stringify({
113+
jsonrpc: '2.0',
114+
id: null,
115+
error: {
116+
code: -32700,
117+
message: 'Parse error'
118+
}
119+
}));
120+
}
121+
});
122+
} else {
123+
res.writeHead(404, { 'Content-Type': 'text/plain' });
124+
res.end('Not found');
125+
}
126+
});
127+
128+
server.listen(PORT, () => {
129+
console.log(`Mock SSE MCP server listening on port ${PORT}`);
130+
});
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
const http = require('http');
2+
3+
const PORT = process.env.PORT || 3002;
4+
5+
// Mock HTTP-Streamable MCP server for testing
6+
const server = http.createServer((req, res) => {
7+
console.log(`${req.method} ${req.url}`);
8+
9+
if (req.method === 'GET' && req.headers.accept?.includes('text/event-stream')) {
10+
// Handle GET requests for SSE streaming
11+
// Return SSE stream
12+
res.writeHead(200, {
13+
'Content-Type': 'text/event-stream',
14+
'Cache-Control': 'no-cache',
15+
'Connection': 'keep-alive'
16+
});
17+
18+
// Send initial endpoint message (expected by test client)
19+
res.write('data: {"jsonrpc":"2.0","method":"endpoint","params":{"type":"endpoint","url":"/message"}}\n\n');
20+
21+
// Keep connection alive with periodic messages
22+
const keepAlive = setInterval(() => {
23+
res.write(':keepalive\n\n');
24+
}, 30000);
25+
26+
// Send some server-initiated messages
27+
setTimeout(() => {
28+
res.write('data: {"jsonrpc":"2.0","method":"notification","params":{"type":"server_info","version":"1.0"}}\n\n');
29+
}, 1000);
30+
31+
req.on('close', () => {
32+
clearInterval(keepAlive);
33+
});
34+
} else if (req.url === '/' && req.method === 'GET') {
35+
// Health check endpoint
36+
res.writeHead(200, { 'Content-Type': 'text/plain' });
37+
res.end('Mock Streamable MCP Server');
38+
} else if (req.method === 'POST') {
39+
// Handle POST requests (single endpoint for streamable)
40+
let body = '';
41+
req.on('data', chunk => {
42+
body += chunk.toString();
43+
});
44+
req.on('end', () => {
45+
try {
46+
const request = JSON.parse(body);
47+
console.log('Received request:', request);
48+
49+
// Check Accept header to decide response type
50+
const acceptHeader = req.headers.accept || '';
51+
const wantsSSE = acceptHeader.includes('text/event-stream');
52+
53+
if (request.method === 'tools/list') {
54+
const response = {
55+
jsonrpc: '2.0',
56+
id: request.id,
57+
result: {
58+
tools: [
59+
{
60+
name: 'get_time',
61+
description: 'Get the current time',
62+
inputSchema: {
63+
type: 'object',
64+
properties: {}
65+
}
66+
},
67+
{
68+
name: 'echo_streamable',
69+
description: 'Echo text back',
70+
inputSchema: {
71+
type: 'object',
72+
properties: {
73+
text: { type: 'string' }
74+
},
75+
required: ['text']
76+
}
77+
}
78+
]
79+
}
80+
};
81+
82+
res.writeHead(200, { 'Content-Type': 'application/json' });
83+
res.end(JSON.stringify(response));
84+
} else if (request.method === 'tools/call') {
85+
if (request.params.name === 'get_time') {
86+
const response = {
87+
jsonrpc: '2.0',
88+
id: request.id,
89+
result: {
90+
toolResult: new Date().toISOString()
91+
}
92+
};
93+
res.writeHead(200, { 'Content-Type': 'application/json' });
94+
res.end(JSON.stringify(response));
95+
} else if (request.params.name === 'echo_streamable') {
96+
if (wantsSSE) {
97+
// Return SSE stream for this tool
98+
res.writeHead(200, {
99+
'Content-Type': 'text/event-stream',
100+
'Cache-Control': 'no-cache',
101+
'Connection': 'keep-alive'
102+
});
103+
104+
// Send multiple responses in SSE format
105+
const messages = [
106+
{ jsonrpc: '2.0', id: request.id, result: { toolResult: `Echo: ${request.params.arguments.text}` } },
107+
{ jsonrpc: '2.0', method: 'notification', params: { message: 'Processing complete' } }
108+
];
109+
110+
messages.forEach((msg, index) => {
111+
setTimeout(() => {
112+
res.write(`data: ${JSON.stringify(msg)}\n\n`);
113+
}, index * 100);
114+
});
115+
116+
setTimeout(() => {
117+
res.end();
118+
}, 300);
119+
} else {
120+
// Regular JSON response
121+
const response = {
122+
jsonrpc: '2.0',
123+
id: request.id,
124+
result: {
125+
toolResult: `Echo: ${request.params.arguments.text}`
126+
}
127+
};
128+
res.writeHead(200, { 'Content-Type': 'application/json' });
129+
res.end(JSON.stringify(response));
130+
}
131+
} else {
132+
const response = {
133+
jsonrpc: '2.0',
134+
id: request.id,
135+
error: {
136+
code: -32601,
137+
message: 'Tool not found'
138+
}
139+
};
140+
res.writeHead(200, { 'Content-Type': 'application/json' });
141+
res.end(JSON.stringify(response));
142+
}
143+
} else {
144+
// Default response for other methods
145+
const response = {
146+
jsonrpc: '2.0',
147+
id: request.id,
148+
result: {}
149+
};
150+
res.writeHead(200, { 'Content-Type': 'application/json' });
151+
res.end(JSON.stringify(response));
152+
}
153+
} catch (e) {
154+
console.error('Error processing request:', e);
155+
res.writeHead(400, { 'Content-Type': 'application/json' });
156+
res.end(JSON.stringify({
157+
jsonrpc: '2.0',
158+
id: null,
159+
error: {
160+
code: -32700,
161+
message: 'Parse error'
162+
}
163+
}));
164+
}
165+
});
166+
} else {
167+
res.writeHead(405, { 'Content-Type': 'text/plain' });
168+
res.end('Method not allowed');
169+
}
170+
});
171+
172+
server.listen(PORT, () => {
173+
console.log(`Mock Streamable MCP server listening on port ${PORT}`);
174+
});

0 commit comments

Comments
 (0)