|
1 | 1 | #!/usr/bin/env node |
2 | 2 |
|
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) |
5 | 5 |
|
6 | 6 | const express = require('express'); |
7 | 7 | const helmet = require('helmet'); |
8 | 8 | const cors = require('cors'); |
9 | 9 | const morgan = require('morgan'); |
10 | | -const Joi = require('joi'); |
11 | 10 | require('dotenv').config(); |
12 | 11 |
|
| 12 | +const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js'); |
| 13 | +const { isInitializeRequest } = require('@modelcontextprotocol/sdk/types.js'); |
13 | 14 | const { FaxMcpServer } = require('./mcp_server.js'); |
14 | | -const { McpError, ErrorCode } = require('@modelcontextprotocol/sdk/types.js'); |
15 | 15 |
|
16 | 16 | const app = express(); |
17 | 17 | app.use(helmet()); |
18 | | -app.use(cors()); |
19 | 18 | 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 | +})); |
20 | 25 | app.use(morgan('dev')); |
21 | 26 |
|
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 | +}); |
24 | 31 |
|
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 (_) {} |
73 | 45 | }; |
| 46 | + await server.connect(transport); |
| 47 | + return server; |
74 | 48 | } |
75 | 49 |
|
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) => { |
113 | 52 | 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 | + }); |
118 | 64 | } |
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 }); |
122 | 71 | } |
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; |
125 | 75 | } |
| 76 | + |
| 77 | + // Existing session: forward request |
| 78 | + await session.transport.handleRequest(req, res, req.body); |
126 | 79 | } 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 }); |
136 | 83 | } |
| 84 | + } |
| 85 | +}); |
137 | 86 |
|
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; |
142 | 94 | } |
| 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); |
143 | 107 | }); |
144 | 108 |
|
145 | 109 | // Start server |
146 | 110 | const port = parseInt(process.env.MCP_HTTP_PORT || '3001', 10); |
147 | 111 | 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}`); |
150 | 113 | }); |
151 | | - |
0 commit comments