Skip to content

Commit f306a22

Browse files
committed
- Added /mcp endpoint for JSON-RPC requests, supporting POST, GET (SSE notifications), and DELETE (session termination).
- Updated documentation in `MCP_INTEGRATION.md` to reflect new protocol and usage instructions. - Enhanced session management for improved client-server interaction.
1 parent dcc7e85 commit f306a22

File tree

3 files changed

+90
-135
lines changed

3 files changed

+90
-135
lines changed

TODO.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ This is a focused, prioritized backlog based on the latest audit.
2020
- [COMPLETED] Redact sensitive tokens from logs
2121
- Do not log `pdf_url` tokens
2222
- Ensure all logs contain job IDs, not secrets
23+
- [COMPLETED] Streamable HTTP MCP server
24+
- Implemented `/mcp` POST/GET/DELETE with `StreamableHTTPServerTransport`
25+
- Session management and SSE notifications supported
26+
- Docs updated; scripts and Makefile remain valid
2327

2428
## Medium Priority
2529
- Skip TIFF generation for Phaxio path

api/mcp_http_server.js

Lines changed: 79 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,151 +1,113 @@
11
#!/usr/bin/env node
22

3-
// HTTP transport for Faxbot MCP tools
4-
// Exposes health, capabilities, and tool invocation over HTTP
3+
// MCP Streamable HTTP transport for Faxbot
4+
// Implements the standard /mcp POST (requests), GET (SSE notifications), and DELETE (session end)
55

66
const express = require('express');
77
const helmet = require('helmet');
88
const cors = require('cors');
99
const morgan = require('morgan');
10-
const Joi = require('joi');
1110
require('dotenv').config();
1211

12+
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
13+
const { isInitializeRequest } = require('@modelcontextprotocol/sdk/types.js');
1314
const { FaxMcpServer } = require('./mcp_server.js');
14-
const { McpError, ErrorCode } = require('@modelcontextprotocol/sdk/types.js');
1515

1616
const app = express();
1717
app.use(helmet());
18-
app.use(cors());
1918
app.use(express.json({ limit: '10mb' }));
19+
// Allow browser-based MCP clients to read session header
20+
app.use(cors({
21+
origin: '*',
22+
exposedHeaders: ['Mcp-Session-Id'],
23+
allowedHeaders: ['Content-Type', 'mcp-session-id'],
24+
}));
2025
app.use(morgan('dev'));
2126

22-
// Instantiate the MCP logic holder (but do not start stdio transport)
23-
const mcp = new FaxMcpServer();
27+
// Health (optional convenience)
28+
app.get('/health', (_req, res) => {
29+
res.json({ status: 'ok', transport: 'streamable-http', server: 'faxbot-mcp', version: '2.0.0' });
30+
});
2431

25-
// Tool definitions (keep in sync with mcp_server.js)
26-
function listTools() {
27-
return {
28-
tools: [
29-
{
30-
name: 'send_fax',
31-
description:
32-
'Send a fax to a recipient using T.38 protocol via Asterisk or cloud provider. Supports PDF and TXT files.',
33-
inputSchema: {
34-
type: 'object',
35-
properties: {
36-
to: {
37-
type: 'string',
38-
description:
39-
'The fax number to send to (e.g., "+1234567890" or "555-1234")',
40-
},
41-
fileContent: {
42-
type: 'string',
43-
description: 'Base64 encoded file content (PDF or plain text)',
44-
},
45-
fileName: {
46-
type: 'string',
47-
description: 'Name of the file being sent (e.g., "document.pdf")',
48-
},
49-
fileType: {
50-
type: 'string',
51-
enum: ['pdf', 'txt'],
52-
description: 'Type of file being sent (pdf or txt)',
53-
},
54-
},
55-
required: ['to', 'fileContent', 'fileName'],
56-
},
57-
},
58-
{
59-
name: 'get_fax_status',
60-
description: 'Check the status of a previously sent fax job',
61-
inputSchema: {
62-
type: 'object',
63-
properties: {
64-
jobId: {
65-
type: 'string',
66-
description: 'The job ID returned from send_fax',
67-
},
68-
},
69-
required: ['jobId'],
70-
},
71-
},
72-
],
32+
// Session store
33+
const sessions = Object.create(null); // sessionId -> { transport, server }
34+
35+
// Helper to create a new Faxbot MCP server and connect it to a transport
36+
async function initServerWithTransport(transport) {
37+
const fax = new FaxMcpServer();
38+
const server = fax.server; // underlying MCP server instance
39+
// Clean up when transport closes
40+
transport.onclose = () => {
41+
if (transport.sessionId && sessions[transport.sessionId]) {
42+
delete sessions[transport.sessionId];
43+
}
44+
try { server.close(); } catch (_) {}
7345
};
46+
await server.connect(transport);
47+
return server;
7448
}
7549

76-
// Health endpoint
77-
app.get('/health', (req, res) => {
78-
res.json({
79-
status: 'ok',
80-
transport: 'http',
81-
server: 'faxbot-mcp',
82-
version: '2.0.0',
83-
apiUrl: mcp.apiUrl,
84-
});
85-
});
86-
87-
// Capabilities (tools) endpoint
88-
app.get('/mcp/capabilities', (req, res) => {
89-
res.json(listTools());
90-
});
91-
92-
// Optional: list tools alias
93-
app.get('/mcp/tools', (req, res) => {
94-
res.json(listTools());
95-
});
96-
97-
// Tool invocation endpoint
98-
const callSchema = Joi.object({
99-
name: Joi.string().required(),
100-
arguments: Joi.object().default({}),
101-
});
102-
103-
app.post('/mcp/call', async (req, res) => {
104-
const { error, value } = callSchema.validate(req.body || {});
105-
if (error) {
106-
return res.status(400).json({
107-
error: { code: 'InvalidParams', message: error.message },
108-
});
109-
}
110-
111-
const { name, arguments: args } = value;
112-
50+
// POST /mcp - client->server JSON requests
51+
app.post('/mcp', async (req, res) => {
11352
try {
114-
switch (name) {
115-
case 'send_fax': {
116-
const result = await mcp.handleSendFax(args);
117-
return res.json(result);
53+
const sessionId = req.headers['mcp-session-id'];
54+
let session = sessionId ? sessions[sessionId] : undefined;
55+
56+
if (!session) {
57+
// No session: only allow if this is an initialize request
58+
if (!isInitializeRequest(req.body)) {
59+
return res.status(400).json({
60+
jsonrpc: '2.0',
61+
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
62+
id: null,
63+
});
11864
}
119-
case 'get_fax_status': {
120-
const result = await mcp.handleGetFaxStatus(args);
121-
return res.json(result);
65+
// Create transport with auto session ID
66+
const transport = new StreamableHTTPServerTransport({});
67+
const server = await initServerWithTransport(transport);
68+
// After connect, transport.sessionId should be set
69+
if (!transport.sessionId) {
70+
return res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Failed to initialize session' }, id: null });
12271
}
123-
default:
124-
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
72+
sessions[transport.sessionId] = { transport, server };
73+
await transport.handleRequest(req, res, req.body);
74+
return;
12575
}
76+
77+
// Existing session: forward request
78+
await session.transport.handleRequest(req, res, req.body);
12679
} catch (err) {
127-
if (err instanceof McpError) {
128-
const code = err.code || ErrorCode.InternalError;
129-
const status =
130-
code === ErrorCode.InvalidParams
131-
? 400
132-
: code === ErrorCode.MethodNotFound
133-
? 404
134-
: 500;
135-
return res.status(status).json({ error: { code, message: err.message } });
80+
console.error('MCP HTTP POST error:', err);
81+
if (!res.headersSent) {
82+
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null });
13683
}
84+
}
85+
});
13786

138-
console.error('HTTP MCP call error:', err);
139-
return res
140-
.status(500)
141-
.json({ error: { code: 'InternalError', message: 'Unexpected error' } });
87+
// GET /mcp - SSE notifications
88+
app.get('/mcp', async (req, res) => {
89+
const sessionId = req.headers['mcp-session-id'];
90+
const session = sessionId ? sessions[sessionId] : undefined;
91+
if (!session) {
92+
res.status(400).send('Invalid or missing session ID');
93+
return;
14294
}
95+
await session.transport.handleRequest(req, res);
96+
});
97+
98+
// DELETE /mcp - end session
99+
app.delete('/mcp', async (req, res) => {
100+
const sessionId = req.headers['mcp-session-id'];
101+
const session = sessionId ? sessions[sessionId] : undefined;
102+
if (!session) {
103+
res.status(400).send('Invalid or missing session ID');
104+
return;
105+
}
106+
await session.transport.handleRequest(req, res);
143107
});
144108

145109
// Start server
146110
const port = parseInt(process.env.MCP_HTTP_PORT || '3001', 10);
147111
app.listen(port, () => {
148-
console.log(`Faxbot MCP HTTP server listening on http://localhost:${port}`);
149-
console.log(`Upstream API: ${mcp.apiUrl}`);
112+
console.log(`Faxbot MCP HTTP (streamable) on http://localhost:${port}`);
150113
});
151-

docs/MCP_INTEGRATION.md

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -77,24 +77,13 @@ cd api && node setup-mcp.js
7777
```
7878
cd api && npm run start:http
7979
```
80-
- Endpoints:
81-
- GET `/health` – liveness
82-
- GET `/mcp/capabilities` – tool list
83-
- POST `/mcp/call` – invoke tool with JSON body
84-
- Example request (send fax):
85-
```
86-
curl -X POST http://localhost:3001/mcp/call \
87-
-H 'Content-Type: application/json' \
88-
-d '{
89-
"name": "send_fax",
90-
"arguments": {
91-
"to": "+15551234567",
92-
"fileName": "note.txt",
93-
"fileType": "txt",
94-
"fileContent": "SGVsbG8gV29ybGQh"
95-
}
96-
}'
97-
```
80+
- Protocol: Streamable HTTP with session management
81+
- POST `/mcp` handles JSON-RPC requests. The first request must be an Initialize request if no `Mcp-Session-Id` is provided.
82+
- GET `/mcp` establishes an SSE stream for server-to-client notifications. Include `Mcp-Session-Id` header.
83+
- DELETE `/mcp` terminates the session. Include `Mcp-Session-Id` header.
84+
- CORS: The server exposes `Mcp-Session-Id` and allows `mcp-session-id` header for browser clients.
85+
86+
Note: Streamable HTTP is intended for MCP-aware clients (Claude Desktop, Cursor/Cline, etc.). Manual curl testing requires constructing JSON-RPC requests and handling SSE, which is beyond the scope of this guide.
9887

9988
Docker option (profile `mcp`):
10089
```

0 commit comments

Comments
 (0)