Skip to content

Commit 3ddd619

Browse files
APIbaseclaude
andcommitted
Add Streamable HTTP transport for MCP (protocol 2025-11-25)
Primary /mcp endpoint now uses StreamableHTTPServerTransport with session management via mcp-session-id header. SSE transport moved to /sse + /messages for backward compatibility. Nginx configs updated for new routes. Smoke test updated to test auth rejection via Streamable HTTP POST. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0bcf172 commit 3ddd619

4 files changed

Lines changed: 248 additions & 24 deletions

File tree

nginx/apibase-host.conf

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,29 @@ server {
6464
proxy_cache off;
6565
proxy_read_timeout 300s;
6666
}
67+
68+
# SSE transport (deprecated, backward compat) — no buffering
69+
location /sse {
70+
proxy_pass http://127.0.0.1:8880;
71+
proxy_http_version 1.1;
72+
proxy_set_header Host $host;
73+
proxy_set_header X-Real-IP $remote_addr;
74+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
75+
proxy_set_header X-Forwarded-Proto $scheme;
76+
proxy_set_header Connection '';
77+
chunked_transfer_encoding off;
78+
proxy_buffering off;
79+
proxy_cache off;
80+
proxy_read_timeout 300s;
81+
}
82+
83+
location /messages {
84+
proxy_pass http://127.0.0.1:8880;
85+
proxy_http_version 1.1;
86+
proxy_set_header Host $host;
87+
proxy_set_header X-Real-IP $remote_addr;
88+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
89+
proxy_set_header X-Forwarded-Proto $scheme;
90+
proxy_read_timeout 60s;
91+
}
6792
}

nginx/nginx.conf

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ http {
8181
proxy_set_header X-Request-ID $req_id;
8282
}
8383

84-
# --- MCP Routes (SSE, no buffering) ---
84+
# --- MCP Routes (Streamable HTTP + SSE, no buffering) ---
8585
location /mcp {
8686
proxy_pass http://api_backend;
8787
proxy_set_header Host $host;
@@ -95,6 +95,28 @@ http {
9595
proxy_cache off;
9696
}
9797

98+
# --- SSE transport (deprecated, backward compat) ---
99+
location /sse {
100+
proxy_pass http://api_backend;
101+
proxy_set_header Host $host;
102+
proxy_set_header X-Real-IP $remote_addr;
103+
proxy_set_header X-Forwarded-Proto $scheme;
104+
proxy_set_header X-Request-ID $req_id;
105+
proxy_set_header Connection '';
106+
proxy_http_version 1.1;
107+
chunked_transfer_encoding off;
108+
proxy_buffering off;
109+
proxy_cache off;
110+
}
111+
112+
location /messages {
113+
proxy_pass http://api_backend;
114+
proxy_set_header Host $host;
115+
proxy_set_header X-Real-IP $remote_addr;
116+
proxy_set_header X-Forwarded-Proto $scheme;
117+
proxy_set_header X-Request-ID $req_id;
118+
}
119+
98120
# --- Static Files (§12.51) ---
99121
location /.well-known/ {
100122
root /var/www/static;

scripts/smoke-test.sh

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,14 @@ else
143143
fi
144144

145145
# ---------------------------------------------------------------------------
146-
# 7. Auth rejection — GET /mcp without Authorization → 401
146+
# 7. Auth rejection — POST /mcp initialize without Authorization → 401
147147
# ---------------------------------------------------------------------------
148148
echo -n "7/8 Auth rejection..."
149149
AUTH_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
150+
-X POST \
151+
-H "Content-Type: application/json" \
152+
-H "Accept: application/json, text/event-stream" \
153+
-d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' \
150154
"$API_URL/mcp" 2>/dev/null || echo "000")
151155
if [ "$AUTH_CODE" = "401" ]; then
152156
pass

src/mcp/server.ts

Lines changed: 195 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,223 @@
11
/**
22
* MCP server factory and Express route handlers (§12.42, §6.14, §12.1).
33
*
4-
* SSE transport only (Phase 1 — no WebSocket per §12.42, §16).
5-
* One McpServer + SSEServerTransport per SSE connection (SDK 1:1 design).
6-
* All tool calls route through the full 13-stage pipeline.
4+
* Dual transport:
5+
* - Streamable HTTP (primary, protocol 2025-11-25) — /mcp
6+
* - SSE (deprecated, backward compat, protocol 2024-11-05) — /sse + /messages
7+
*
8+
* One McpServer + Transport per session. All tool calls route through the
9+
* full 13-stage pipeline.
710
*/
811

912
import express from 'express';
1013
import { randomUUID } from 'node:crypto';
1114
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1215
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
16+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
17+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
1318
import { logger } from '../config/logger';
1419
import { registerTools } from './tool-adapter';
1520

16-
/** Active SSE sessions: sessionId → transport */
17-
const sessions = new Map<string, SSEServerTransport>();
21+
/** Active sessions: sessionId → transport (both transport types) */
22+
const sessions = new Map<string, SSEServerTransport | StreamableHTTPServerTransport>();
23+
24+
/**
25+
* Extract Bearer API key from Authorization header.
26+
*/
27+
function extractApiKey(req: express.Request): string | null {
28+
const authHeader = req.headers.authorization;
29+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
30+
return null;
31+
}
32+
return authHeader.slice(7);
33+
}
1834

1935
/**
20-
* Create Express router for MCP endpoint.
36+
* Create Express router for MCP endpoints.
37+
*
38+
* Streamable HTTP (primary):
39+
* POST /mcp — JSON-RPC messages (initialize creates session)
40+
* GET /mcp — SSE subscription for server-initiated notifications
41+
* DELETE /mcp — close session
2142
*
22-
* GET /mcp — SSE stream (sends endpoint event with sessionId)
23-
* POST /mcp?sessionId=x — JSON-RPC messages routed to the correct transport
43+
* SSE (deprecated, backward compat):
44+
* GET /sse — SSE stream
45+
* POST /messages — JSON-RPC messages
2446
*/
2547
export function createMcpRouter(): express.Router {
2648
const router = express.Router();
2749

28-
// -------------------------------------------------------------------------
29-
// GET /mcp — SSE stream
30-
// -------------------------------------------------------------------------
31-
router.get('/mcp', (req: express.Request, res: express.Response) => {
32-
const authHeader = req.headers.authorization;
33-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
50+
// =========================================================================
51+
// Streamable HTTP transport — /mcp (primary, protocol 2025-11-25)
52+
// =========================================================================
53+
54+
// --- POST /mcp: JSON-RPC messages ---
55+
router.post('/mcp', async (req: express.Request, res: express.Response) => {
56+
try {
57+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
58+
59+
// Existing session: route to stored transport
60+
if (sessionId) {
61+
const transport = sessions.get(sessionId);
62+
if (!transport || !(transport instanceof StreamableHTTPServerTransport)) {
63+
res.status(404).json({
64+
jsonrpc: '2.0',
65+
error: { code: -32000, message: 'Session not found' },
66+
id: null,
67+
});
68+
return;
69+
}
70+
await transport.handleRequest(req, res, req.body);
71+
return;
72+
}
73+
74+
// New session: must be an initialize request
75+
if (!isInitializeRequest(req.body)) {
76+
res.status(400).json({
77+
jsonrpc: '2.0',
78+
error: {
79+
code: -32600,
80+
message: 'Bad Request: first request must be an initialize request',
81+
},
82+
id: null,
83+
});
84+
return;
85+
}
86+
87+
const apiKey = extractApiKey(req);
88+
if (!apiKey) {
89+
res.status(401).json({
90+
error: 'unauthorized',
91+
message: 'Missing or invalid Authorization header. Expected: Bearer <api_key>',
92+
});
93+
return;
94+
}
95+
96+
const requestId = (req.headers['x-request-id'] as string) || randomUUID();
97+
98+
const transport = new StreamableHTTPServerTransport({
99+
sessionIdGenerator: () => randomUUID(),
100+
onsessioninitialized: (sid: string) => {
101+
sessions.set(sid, transport);
102+
logger.info(
103+
{ request_id: requestId, session_id: sid },
104+
'MCP Streamable HTTP session created',
105+
);
106+
},
107+
onsessionclosed: (sid: string) => {
108+
sessions.delete(sid);
109+
logger.info(
110+
{ request_id: requestId, session_id: sid },
111+
'MCP Streamable HTTP session closed',
112+
);
113+
},
114+
});
115+
116+
transport.onerror = (error: Error) => {
117+
logger.error(
118+
{ request_id: requestId, err: error },
119+
'MCP Streamable HTTP transport error',
120+
);
121+
};
122+
123+
const mcpServer = new McpServer(
124+
{ name: 'APIbase', version: '1.0.0' },
125+
{ capabilities: { tools: {} } },
126+
);
127+
128+
registerTools(mcpServer, apiKey, requestId);
129+
await mcpServer.connect(transport);
130+
131+
await transport.handleRequest(req, res, req.body);
132+
} catch (error) {
133+
logger.error({ err: error }, 'MCP Streamable HTTP POST error');
134+
if (!res.headersSent) {
135+
res.status(500).json({ error: 'internal_error', message: 'MCP request handling failed' });
136+
}
137+
}
138+
});
139+
140+
// --- GET /mcp: SSE subscription for server-initiated notifications ---
141+
router.get('/mcp', async (req: express.Request, res: express.Response) => {
142+
try {
143+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
144+
if (!sessionId) {
145+
res.status(400).json({
146+
error: 'bad_request',
147+
message: 'Missing mcp-session-id header',
148+
});
149+
return;
150+
}
151+
152+
const transport = sessions.get(sessionId);
153+
if (!transport || !(transport instanceof StreamableHTTPServerTransport)) {
154+
res.status(404).json({
155+
jsonrpc: '2.0',
156+
error: { code: -32000, message: 'Session not found' },
157+
id: null,
158+
});
159+
return;
160+
}
161+
162+
await transport.handleRequest(req, res);
163+
} catch (error) {
164+
logger.error({ err: error }, 'MCP Streamable HTTP GET error');
165+
if (!res.headersSent) {
166+
res.status(500).json({ error: 'internal_error', message: 'MCP SSE subscription failed' });
167+
}
168+
}
169+
});
170+
171+
// --- DELETE /mcp: close session ---
172+
router.delete('/mcp', async (req: express.Request, res: express.Response) => {
173+
try {
174+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
175+
if (!sessionId) {
176+
res.status(400).json({
177+
error: 'bad_request',
178+
message: 'Missing mcp-session-id header',
179+
});
180+
return;
181+
}
182+
183+
const transport = sessions.get(sessionId);
184+
if (!transport || !(transport instanceof StreamableHTTPServerTransport)) {
185+
res.status(404).json({
186+
jsonrpc: '2.0',
187+
error: { code: -32000, message: 'Session not found' },
188+
id: null,
189+
});
190+
return;
191+
}
192+
193+
await transport.handleRequest(req, res);
194+
sessions.delete(sessionId);
195+
} catch (error) {
196+
logger.error({ err: error }, 'MCP Streamable HTTP DELETE error');
197+
if (!res.headersSent) {
198+
res.status(500).json({ error: 'internal_error', message: 'MCP session close failed' });
199+
}
200+
}
201+
});
202+
203+
// =========================================================================
204+
// SSE transport — /sse + /messages (deprecated, backward compat)
205+
// =========================================================================
206+
207+
// --- GET /sse: establish SSE stream ---
208+
router.get('/sse', (req: express.Request, res: express.Response) => {
209+
const apiKey = extractApiKey(req);
210+
if (!apiKey) {
34211
res.status(401).json({
35212
error: 'unauthorized',
36213
message: 'Missing or invalid Authorization header. Expected: Bearer <api_key>',
37214
});
38215
return;
39216
}
40217

41-
const apiKey = authHeader.slice(7);
42218
const requestId = (req.headers['x-request-id'] as string) || randomUUID();
43219

44-
const transport = new SSEServerTransport('/mcp', res);
220+
const transport = new SSEServerTransport('/messages', res);
45221
const sessionId = transport.sessionId;
46222

47223
const mcpServer = new McpServer(
@@ -54,7 +230,6 @@ export function createMcpRouter(): express.Router {
54230
sessions.set(sessionId, transport);
55231
logger.info({ request_id: requestId, session_id: sessionId }, 'MCP SSE session created');
56232

57-
// Cleanup on connection close
58233
const cleanup = (): void => {
59234
sessions.delete(sessionId);
60235
logger.info({ request_id: requestId, session_id: sessionId }, 'MCP SSE session closed');
@@ -82,10 +257,8 @@ export function createMcpRouter(): express.Router {
82257
});
83258
});
84259

85-
// -------------------------------------------------------------------------
86-
// POST /mcp — JSON-RPC messages
87-
// -------------------------------------------------------------------------
88-
router.post('/mcp', (req: express.Request, res: express.Response) => {
260+
// --- POST /messages: JSON-RPC messages for SSE transport ---
261+
router.post('/messages', (req: express.Request, res: express.Response) => {
89262
const sessionId = req.query.sessionId as string | undefined;
90263
if (!sessionId) {
91264
res.status(400).json({
@@ -96,7 +269,7 @@ export function createMcpRouter(): express.Router {
96269
}
97270

98271
const transport = sessions.get(sessionId);
99-
if (!transport) {
272+
if (!transport || !(transport instanceof SSEServerTransport)) {
100273
res.status(400).json({
101274
error: 'bad_request',
102275
message: 'Unknown or expired sessionId',

0 commit comments

Comments
 (0)