Skip to content

Commit c0aee92

Browse files
committed
docs: update HIPAA requirements and MCP integration guide to include OAuth2 support for SSE transport; enhance README with new MCP server options
1 parent f960bab commit c0aee92

File tree

11 files changed

+702
-9
lines changed

11 files changed

+702
-9
lines changed

HIPAA_REQUIREMENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,16 @@ Implement the following as minimum controls:
9494
## MCP (AI Assistant) Considerations
9595
- MCP transmits base64 file content for the `send_fax` tool. Treat the MCP server with the same controls as the API (auth, network restrictions, audit logging).
9696
- Do not send PHI to LLMs or external services unless you have a signed BAA and an approved use case.
97-
- Keep MCP HTTP behind authentication and rate limiting.
97+
- All MCP servers must require authentication:
98+
- REST API: `X-API-Key` for /fax endpoints.
99+
- MCP HTTP/SSE: `Authorization: Bearer <JWT>` verified against your OIDC JWKS.
100+
- Serve MCP over TLS. Never log PHI (file content, rendered pages). Log only job IDs and metadata.
98101

99102
## Operational Checklist (Minimum)
100103
- [ ] Signed BAA with Phaxio (if using cloud backend).
101104
- [ ] TLS everywhere (HTTPS for public endpoints; VPN/private link for SIP media).
102105
- [ ] API auth enabled (`API_KEY` set). Reverse proxy with IP allowlist + rate limiting.
106+
- [ ] MCP auth enforced (OAuth2 Bearer required for HTTP/SSE MCP).
103107
- [ ] Callback signature verification enabled (`PHAXIO_VERIFY_SIGNATURE=true`).
104108
- [ ] Tokenized PDF access enabled with short TTL (`PDF_TOKEN_TTL_MINUTES`).
105109
- [ ] Logs do not contain PHI; tokens redacted; job IDs only.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Simple fax-sending API with AI integration. Choose your backend:
2424
[→ MCP Integration Guide](docs/MCP_INTEGRATION.md)
2525

2626
- Quick start: use the scripts in `api/scripts` (`start-mcp.sh`, `start-mcp-http.sh`) or `make mcp-stdio` / `make mcp-http`.
27+
- New: OAuth2‑protected SSE MCP servers for Node and Python — see the SSE sections in the MCP guide.
2728

2829
## Client SDKs
2930
- Python: `pip install faxbot`

api/mcp_sse_server.js

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/usr/bin/env node
2+
3+
// MCP SSE transport for Faxbot with OAuth2 (JWT) Bearer authentication.
4+
// Requires valid JWTs issued by an OIDC provider and verified via JWKS.
5+
6+
require('dotenv').config();
7+
const express = require('express');
8+
const helmet = require('helmet');
9+
const cors = require('cors');
10+
const morgan = require('morgan');
11+
const { jwtVerify, createRemoteJWKSet } = require('jose');
12+
13+
const { SSEServerTransport } = require('@modelcontextprotocol/sdk/server/sse.js');
14+
const { FaxMcpServer } = require('./mcp_server.js');
15+
16+
const app = express();
17+
app.use(helmet());
18+
app.use(express.json({ limit: '10mb' }));
19+
app.use(cors({ origin: '*', exposedHeaders: ['Mcp-Session-Id'] }));
20+
app.use(morgan('dev'));
21+
22+
// Health route
23+
app.get('/health', (_req, res) => {
24+
res.json({ status: 'ok', transport: 'sse', server: 'faxbot-mcp', version: '2.0.0' });
25+
});
26+
27+
// OAuth2 / JWT verification
28+
const issuer = (process.env.OAUTH_ISSUER || '').replace(/\/$/, '');
29+
const audience = process.env.OAUTH_AUDIENCE || '';
30+
const jwksUrl = process.env.OAUTH_JWKS_URL || (issuer ? `${issuer}/.well-known/jwks.json` : '');
31+
32+
if (!issuer || !audience || !jwksUrl) {
33+
console.warn('[mcp-sse] OAuth2 env not fully configured. Set OAUTH_ISSUER, OAUTH_AUDIENCE, and optionally OAUTH_JWKS_URL.');
34+
}
35+
36+
let JWKS; // initialized lazily on first request
37+
38+
async function authenticate(req, res, next) {
39+
try {
40+
const auth = req.headers['authorization'] || '';
41+
const [scheme, token] = auth.split(' ');
42+
if (scheme !== 'Bearer' || !token) {
43+
return res.status(401).json({ error: 'Unauthorized' });
44+
}
45+
if (!JWKS) {
46+
JWKS = createRemoteJWKSet(new URL(jwksUrl));
47+
}
48+
const { payload } = await jwtVerify(token, JWKS, {
49+
issuer,
50+
audience,
51+
});
52+
req.user = payload;
53+
return next();
54+
} catch (err) {
55+
return res.status(401).json({ error: 'Unauthorized' });
56+
}
57+
}
58+
59+
// In-memory session store: sessionId -> { transport, server }
60+
const sessions = Object.create(null);
61+
62+
async function initServerWithTransport(transport) {
63+
const fax = new FaxMcpServer();
64+
const server = fax.server;
65+
transport.onclose = () => {
66+
if (transport.sessionId && sessions[transport.sessionId]) {
67+
delete sessions[transport.sessionId];
68+
}
69+
try { server.close(); } catch (_) {}
70+
};
71+
await server.connect(transport);
72+
return server;
73+
}
74+
75+
// GET /sse - establish SSE session
76+
app.get('/sse', authenticate, async (req, res) => {
77+
try {
78+
const existingId = req.query.sessionId || req.headers['mcp-session-id'];
79+
if (existingId && sessions[existingId]) {
80+
return sessions[existingId].transport.handleRequest(req, res);
81+
}
82+
const transport = new SSEServerTransport({});
83+
const server = await initServerWithTransport(transport);
84+
if (!transport.sessionId) {
85+
return res.status(500).json({ error: 'Failed to initialize session' });
86+
}
87+
sessions[transport.sessionId] = { transport, server };
88+
return transport.handleRequest(req, res);
89+
} catch (err) {
90+
console.error('SSE /sse error:', err);
91+
if (!res.headersSent) {
92+
res.status(500).json({ error: 'Internal server error' });
93+
}
94+
}
95+
});
96+
97+
// POST /messages - deliver client messages into a session
98+
app.post('/messages', authenticate, async (req, res) => {
99+
try {
100+
const sessionId = req.query.sessionId || req.headers['mcp-session-id'] || req.body.sessionId;
101+
if (!sessionId || !sessions[sessionId]) {
102+
return res.status(400).json({ error: 'Invalid or missing sessionId' });
103+
}
104+
const session = sessions[sessionId];
105+
return session.transport.handlePostMessage(req, res);
106+
} catch (err) {
107+
console.error('SSE POST /messages error:', err);
108+
if (!res.headersSent) {
109+
res.status(500).json({ error: 'Internal server error' });
110+
}
111+
}
112+
});
113+
114+
// DELETE /messages - close a session
115+
app.delete('/messages', authenticate, async (req, res) => {
116+
try {
117+
const sessionId = req.query.sessionId || req.headers['mcp-session-id'];
118+
if (!sessionId || !sessions[sessionId]) {
119+
return res.status(400).json({ error: 'Invalid or missing sessionId' });
120+
}
121+
const session = sessions[sessionId];
122+
await session.transport.handleClose();
123+
delete sessions[sessionId];
124+
res.status(204).end();
125+
} catch (err) {
126+
console.error('SSE DELETE /messages error:', err);
127+
if (!res.headersSent) {
128+
res.status(500).json({ error: 'Internal server error' });
129+
}
130+
}
131+
});
132+
133+
const port = parseInt(process.env.PORT || '3002', 10);
134+
app.listen(port, () => {
135+
console.log(`Faxbot MCP SSE (OAuth2) on http://localhost:${port}`);
136+
});
137+

api/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
"main": "mcp_server.js",
66
"bin": {
77
"faxbot-mcp": "./mcp_server.js",
8-
"faxbot-mcp-http": "./mcp_http_server.js"
8+
"faxbot-mcp-http": "./mcp_http_server.js",
9+
"faxbot-mcp-sse": "./mcp_sse_server.js"
910
},
1011
"scripts": {
1112
"start": "node mcp_server.js",
1213
"start:mcp": "node mcp_server.js",
1314
"start:http": "node mcp_http_server.js",
15+
"start:sse": "node mcp_sse_server.js",
1416
"dev:mcp": "nodemon mcp_server.js",
1517
"dev:http": "nodemon mcp_http_server.js",
1618
"install-global": "npm install -g .",
@@ -39,7 +41,8 @@
3941
"cors": "^2.8.5",
4042
"helmet": "^7.1.0",
4143
"joi": "^17.12.0",
42-
"dotenv": "^16.4.0"
44+
"dotenv": "^16.4.0",
45+
"jose": "^5.2.0"
4346
},
4447
"devDependencies": {
4548
"nodemon": "^3.0.0"

docs/MCP_INTEGRATION.md

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
## What is MCP?
44
- Model Context Protocol (2024/2025) lets AI assistants use external tools safely.
55
- Here, Faxbot exposes two tools: `send_fax` and `get_fax_status`.
6-
- Transports supported: stdio (desktop) and HTTP (server).
6+
- Transports supported: stdio (desktop), HTTP (server), and SSE with OAuth2 (server).
77

88
## Architecture
99
Assistant → MCP Server → Faxbot API → Backend (Phaxio or SIP/Asterisk)
@@ -62,7 +62,7 @@ FAX_API_URL=http://localhost:8080 API_KEY=$API_KEY MCP_HTTP_PORT=3001 faxbot-mcp
6262
```
6363

6464
## Stdio Transport (Claude Desktop, Cursor)
65-
- Start stdio MCP:
65+
- Node (stdio) start:
6666
```
6767
cd api && npm run start:mcp
6868
```
@@ -72,8 +72,18 @@ cd api && node setup-mcp.js
7272
```
7373
- Claude Desktop/Cursor will reference the generated configs to launch `mcp_server.js`.
7474

75+
- Python (stdio) start:
76+
```
77+
cd python_mcp
78+
python -m venv .venv && source .venv/bin/activate
79+
pip install -r requirements.txt
80+
export FAX_API_URL=http://localhost:8080
81+
export API_KEY=your_api_key
82+
python stdio_server.py
83+
```
84+
7585
## HTTP Transport (Cloud/Local)
76-
- Start HTTP MCP:
86+
- Node (HTTP) start:
7787
```
7888
cd api && npm run start:http
7989
```
@@ -85,6 +95,16 @@ cd api && npm run start:http
8595

8696
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.
8797

98+
- Python (HTTP) start:
99+
```
100+
cd python_mcp
101+
python -m venv .venv && source .venv/bin/activate
102+
pip install -r requirements.txt
103+
export FAX_API_URL=http://localhost:8080
104+
export API_KEY=your_api_key
105+
uvicorn http_server:app --host 0.0.0.0 --port 3004
106+
```
107+
88108
Docker option (profile `mcp`):
89109
```
90110
make mcp-up # builds and starts the faxbot-mcp service
@@ -96,5 +116,62 @@ make mcp-down # stop MCP service
96116
- If the API uses `X-API-Key`, set `API_KEY` for MCP so it forwards the header.
97117
- For HTTP transport, place behind auth and rate limits.
98118

119+
## SSE Transport with OAuth2 (Node)
120+
- Requirements:
121+
- Node 18+
122+
- Dependencies: `npm install jose`
123+
- Configure OAuth2 (resource server):
124+
- `OAUTH_ISSUER` (e.g., `https://your-tenant.auth0.com`)
125+
- `OAUTH_AUDIENCE` (e.g., `faxbot-mcp`)
126+
- `OAUTH_JWKS_URL` (optional override; defaults to `${OAUTH_ISSUER}/.well-known/jwks.json`)
127+
- `FAX_API_URL` (Faxbot REST API base URL)
128+
- `API_KEY` (Faxbot API key if enabled)
129+
- Start the server:
130+
```
131+
cd api
132+
npm install # ensure deps
133+
OAUTH_ISSUER=https://example.auth0.com \
134+
OAUTH_AUDIENCE=faxbot-mcp \
135+
OAUTH_JWKS_URL=https://example.auth0.com/.well-known/jwks.json \
136+
FAX_API_URL=http://localhost:8080 \
137+
API_KEY=your_api_key \
138+
PORT=3002 \
139+
npm run start:sse
140+
```
141+
- Endpoints:
142+
- `GET /health``{ status: 'ok', transport: 'sse', server: 'faxbot-mcp', version: '2.0.0' }`
143+
- `GET /sse` → starts an SSE session (requires `Authorization: Bearer <JWT>`) and returns events
144+
- `POST /messages` → send client messages (requires Bearer token and `sessionId`)
145+
- `DELETE /messages` → close a session
146+
- Notes:
147+
- JWTs are verified against the JWKS (signature + `iss`/`aud`/`exp`/`nbf`).
148+
- Sessions are kept in-memory (stateless JWT + stateful transport).
149+
150+
## SSE Transport with OAuth2 (Python)
151+
- Requirements:
152+
- Python 3.9+
153+
- Dependencies: see `python_mcp/requirements.txt`
154+
- Configure OAuth2 / Env:
155+
- `OAUTH_ISSUER`, `OAUTH_AUDIENCE`, `OAUTH_JWKS_URL`
156+
- `FAX_API_URL`, `API_KEY`
157+
- `PORT` (default 3003)
158+
- Run:
159+
```
160+
cd python_mcp
161+
python -m venv .venv && source .venv/bin/activate
162+
pip install -r requirements.txt
163+
export OAUTH_ISSUER=https://example.auth0.com
164+
export OAUTH_AUDIENCE=faxbot-mcp
165+
export OAUTH_JWKS_URL=https://example.auth0.com/.well-known/jwks.json
166+
export FAX_API_URL=http://localhost:8080
167+
export API_KEY=my_api_key
168+
uvicorn server:app --host 0.0.0.0 --port 3003
169+
```
170+
- Endpoints:
171+
- `GET /health``{ status: 'ok', transport: 'sse', server: 'faxbot-mcp', version: '2.0.0' }`
172+
- SSE endpoints are mounted at `/` by the MCP SSE app and protected by Bearer auth middleware.
173+
- Notes:
174+
- The Python MCP server bridges SSE + OAuth2 until official Python MCP SDKs add built-in HTTP/OAuth support.
175+
99176
## Voice Examples
100177
- You can say: “Send this PDF to +1555… using fax tools.” The assistant will call `send_fax` with base64 content, then `get_fax_status`.

docs/SDKS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Client SDKs
22

3-
Thin, official clients for the Faxbot API. They call the unified Faxbot REST API (no direct Phaxio/Asterisk calls).
3+
Thin, official clients for the Faxbot API. They call the unified Faxbot REST API (no direct Phaxio/Asterisk calls). Current version alignment: Python 1.0.2, Node 1.0.2.
44

55
- Python: `faxbot`
66
- Node.js: `faxbot`

0 commit comments

Comments
 (0)