faxseal-mcp 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/index.js +46 -1
- package/package.json +6 -3
- package/server-http.js +398 -0
- package/server.json +34 -0
- package/smithery.yaml +13 -0
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ Send, receive, and verify faxes from AI assistants and agents via the [Model Con
|
|
|
8
8
|
|---|---|
|
|
9
9
|
| `send_fax` | Send a fax from a publicly accessible PDF URL |
|
|
10
10
|
| `send_fax_text` | Type content directly — FaxSeal converts it to a PDF and sends |
|
|
11
|
+
| `quote_fax` | Estimate pages and credits before sending |
|
|
11
12
|
| `get_fax_status` | Check delivery status of a sent fax |
|
|
12
13
|
| `get_credits` | Check your FaxSeal credit balance |
|
|
13
14
|
| `list_received_faxes` | List faxes in your inbox with OCR text |
|
package/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
const BASE_URL = (process.env.FAXSEAL_BASE_URL ?? 'https://faxseal.com').replace(/\/+$/, '');
|
|
10
10
|
const API_KEY = process.env.FAXSEAL_API_KEY ?? '';
|
|
11
11
|
const FETCH_TIMEOUT_MS = 30_000;
|
|
12
|
+
const SERVER_VERSION = '2.1.0';
|
|
12
13
|
|
|
13
14
|
if (!API_KEY) {
|
|
14
15
|
process.stderr.write('Error: FAXSEAL_API_KEY environment variable is required.\n');
|
|
@@ -113,6 +114,34 @@ const handlers = {
|
|
|
113
114
|
]);
|
|
114
115
|
},
|
|
115
116
|
|
|
117
|
+
quote_fax: async (args) => {
|
|
118
|
+
if (!args.to) throw new Error('to is required');
|
|
119
|
+
if (!args.pages && !args.content && !args.file_url) {
|
|
120
|
+
throw new Error('one of pages, content, or file_url is required');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const data = await apiFetch('/api/v1/fax/quote', {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: authHeaders(),
|
|
126
|
+
body: JSON.stringify({
|
|
127
|
+
to: args.to,
|
|
128
|
+
pages: args.pages,
|
|
129
|
+
content: args.content,
|
|
130
|
+
file_url: args.file_url,
|
|
131
|
+
subject: args.subject,
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return ok([
|
|
136
|
+
`To: ${data.to_number}`,
|
|
137
|
+
`Zone: ${data.zone}`,
|
|
138
|
+
`Pages: ${data.pages}`,
|
|
139
|
+
`Credits required: ${data.credits_required}`,
|
|
140
|
+
`Credits available: ${data.credits_available}`,
|
|
141
|
+
`Can send: ${data.can_send}`,
|
|
142
|
+
]);
|
|
143
|
+
},
|
|
144
|
+
|
|
116
145
|
get_fax_status: async (args) => {
|
|
117
146
|
if (!args.job_id) throw new Error('job_id is required');
|
|
118
147
|
|
|
@@ -242,6 +271,22 @@ const TOOL_DEFS = [
|
|
|
242
271
|
},
|
|
243
272
|
},
|
|
244
273
|
},
|
|
274
|
+
{
|
|
275
|
+
name: 'quote_fax',
|
|
276
|
+
description:
|
|
277
|
+
'Estimate pages and credits for a fax before sending. Accepts known page count, plain text content, or a PDF URL.',
|
|
278
|
+
inputSchema: {
|
|
279
|
+
type: 'object',
|
|
280
|
+
required: ['to'],
|
|
281
|
+
properties: {
|
|
282
|
+
to: { type: 'string', description: 'Destination fax number in E.164 format, e.g. +12025551234' },
|
|
283
|
+
pages: { type: 'number', description: 'Known page count, if already available' },
|
|
284
|
+
content: { type: 'string', description: 'Plain-text fax body to render and quote' },
|
|
285
|
+
file_url: { type: 'string', description: 'Publicly accessible HTTPS URL to a PDF file to quote' },
|
|
286
|
+
subject: { type: 'string', description: 'Optional subject when quoting plain text content' },
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
},
|
|
245
290
|
{
|
|
246
291
|
name: 'get_fax_status',
|
|
247
292
|
description:
|
|
@@ -296,7 +341,7 @@ const TOOL_DEFS = [
|
|
|
296
341
|
// ---------------------------------------------------------------------------
|
|
297
342
|
|
|
298
343
|
const server = new Server(
|
|
299
|
-
{ name: 'faxseal-mcp', version:
|
|
344
|
+
{ name: 'faxseal-mcp', version: SERVER_VERSION },
|
|
300
345
|
{ capabilities: { tools: {} } },
|
|
301
346
|
);
|
|
302
347
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faxseal-mcp",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "MCP server for FaxSeal — send, receive, and verify faxes from AI assistants and agents",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
10
|
"files": [
|
|
11
|
-
"index.js"
|
|
11
|
+
"index.js",
|
|
12
|
+
"server-http.js",
|
|
13
|
+
"server.json",
|
|
14
|
+
"smithery.yaml"
|
|
12
15
|
],
|
|
13
16
|
"engines": {
|
|
14
17
|
"node": ">=20"
|
|
@@ -33,7 +36,7 @@
|
|
|
33
36
|
"homepage": "https://faxseal.com/api",
|
|
34
37
|
"repository": {
|
|
35
38
|
"type": "git",
|
|
36
|
-
"url": "git+https://github.com/
|
|
39
|
+
"url": "git+https://github.com/grichardomi/mcp-server.git"
|
|
37
40
|
},
|
|
38
41
|
"dependencies": {
|
|
39
42
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
package/server-http.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* FaxSeal MCP HTTP Server (Railway / cloud deployment)
|
|
4
|
+
* Implements MCP Streamable HTTP transport — one session per caller.
|
|
5
|
+
* Callers authenticate with their own FAXSEAL_API_KEY via Bearer token.
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { createServer } from 'node:http';
|
|
9
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
10
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
11
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
12
|
+
|
|
13
|
+
const PORT = parseInt(process.env.PORT ?? '3001', 10);
|
|
14
|
+
const BASE_URL = (process.env.FAXSEAL_BASE_URL ?? 'https://faxseal.com').replace(/\/+$/, '');
|
|
15
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
16
|
+
const SERVER_VERSION = '2.1.0';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// HTTP helper — per-request API key, hard timeout, no timer leaks
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
function makeApiFetch(apiKey) {
|
|
23
|
+
return async function apiFetch(path, options = {}) {
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
28
|
+
...options,
|
|
29
|
+
signal: controller.signal,
|
|
30
|
+
headers: {
|
|
31
|
+
Authorization: `Bearer ${apiKey}`,
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
...options.headers,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
37
|
+
const body = contentType.includes('application/json')
|
|
38
|
+
? await res.json().catch(() => null)
|
|
39
|
+
: await res.text().catch(() => '');
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
const message =
|
|
42
|
+
body && typeof body === 'object' && 'error' in body
|
|
43
|
+
? body.error
|
|
44
|
+
: typeof body === 'string' && body.trim()
|
|
45
|
+
? body.trim()
|
|
46
|
+
: `HTTP ${res.status}`;
|
|
47
|
+
throw new Error(String(message));
|
|
48
|
+
}
|
|
49
|
+
return body && typeof body === 'object' ? body : {};
|
|
50
|
+
} finally {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ok(lines) {
|
|
57
|
+
return { content: [{ type: 'text', text: lines.filter(Boolean).join('\n') }] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toolErr(message) {
|
|
61
|
+
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Tool handlers — factory bound to a single session's API key
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
function makeHandlers(apiFetch) {
|
|
69
|
+
return {
|
|
70
|
+
send_fax: async (args) => {
|
|
71
|
+
if (!args.to) throw new Error('to is required');
|
|
72
|
+
if (!args.file_url) throw new Error('file_url is required');
|
|
73
|
+
const data = await apiFetch('/api/zapier/send', {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
to: args.to,
|
|
77
|
+
file_url: args.file_url,
|
|
78
|
+
from_name: args.from_name,
|
|
79
|
+
from_company: args.from_company,
|
|
80
|
+
subject: args.subject,
|
|
81
|
+
notes: args.notes,
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
return ok([
|
|
85
|
+
'Fax queued.',
|
|
86
|
+
`Job ID: ${data.id}`,
|
|
87
|
+
`To: ${data.to_number}`,
|
|
88
|
+
`Pages: ${data.pages}`,
|
|
89
|
+
`Credits used: ${data.credits_used} (${data.credits_remaining} remaining)`,
|
|
90
|
+
`Track: ${data.track_url}`,
|
|
91
|
+
]);
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
send_fax_text: async (args) => {
|
|
95
|
+
if (!args.to) throw new Error('to is required');
|
|
96
|
+
if (!args.content) throw new Error('content is required');
|
|
97
|
+
const data = await apiFetch('/api/v1/fax/text', {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
to: args.to,
|
|
101
|
+
content: args.content,
|
|
102
|
+
subject: args.subject,
|
|
103
|
+
from_name: args.from_name,
|
|
104
|
+
from_company: args.from_company,
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
return ok([
|
|
108
|
+
'Fax queued.',
|
|
109
|
+
`Job ID: ${data.id}`,
|
|
110
|
+
`To: ${data.to_number}`,
|
|
111
|
+
`Pages: ${data.pages}`,
|
|
112
|
+
`Credits used: ${data.credits_used} (${data.credits_remaining} remaining)`,
|
|
113
|
+
`Track: ${data.track_url}`,
|
|
114
|
+
]);
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
quote_fax: async (args) => {
|
|
118
|
+
if (!args.to) throw new Error('to is required');
|
|
119
|
+
if (!args.pages && !args.content && !args.file_url) {
|
|
120
|
+
throw new Error('one of pages, content, or file_url is required');
|
|
121
|
+
}
|
|
122
|
+
const data = await apiFetch('/api/v1/fax/quote', {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
to: args.to,
|
|
126
|
+
pages: args.pages,
|
|
127
|
+
content: args.content,
|
|
128
|
+
file_url: args.file_url,
|
|
129
|
+
subject: args.subject,
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
return ok([
|
|
133
|
+
`To: ${data.to_number}`,
|
|
134
|
+
`Zone: ${data.zone}`,
|
|
135
|
+
`Pages: ${data.pages}`,
|
|
136
|
+
`Credits required: ${data.credits_required}`,
|
|
137
|
+
`Credits available: ${data.credits_available}`,
|
|
138
|
+
`Can send: ${data.can_send}`,
|
|
139
|
+
]);
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
get_fax_status: async (args) => {
|
|
143
|
+
if (!args.job_id) throw new Error('job_id is required');
|
|
144
|
+
const data = await apiFetch(`/api/v1/fax/${encodeURIComponent(args.job_id)}`);
|
|
145
|
+
return ok([
|
|
146
|
+
`Status: ${data.status}`,
|
|
147
|
+
`To: ${data.toNumber}`,
|
|
148
|
+
`Pages: ${data.pages}`,
|
|
149
|
+
data.creditsUsed != null && `Credits used: ${data.creditsUsed}`,
|
|
150
|
+
data.failureReason && `Failure reason: ${data.failureReason}`,
|
|
151
|
+
data.refundStatus && `Refund: ${data.refundStatus}`,
|
|
152
|
+
data.certReceiptUrl && `Receipt: ${data.certReceiptUrl}`,
|
|
153
|
+
`Sent: ${data.createdAt}`,
|
|
154
|
+
`Updated: ${data.updatedAt}`,
|
|
155
|
+
]);
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
get_credits: async () => {
|
|
159
|
+
const data = await apiFetch('/api/user/credits');
|
|
160
|
+
return ok([`Credit balance: ${data.credits}`]);
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
list_received_faxes: async (args) => {
|
|
164
|
+
const params = new URLSearchParams();
|
|
165
|
+
if (args.since) params.set('since', args.since);
|
|
166
|
+
const qs = params.size ? `?${params}` : '';
|
|
167
|
+
const faxes = await apiFetch(`/api/zapier/inbox${qs}`);
|
|
168
|
+
if (!faxes.length) return ok(['No received faxes found.']);
|
|
169
|
+
const lines = [`${faxes.length} received fax(es):`, ''];
|
|
170
|
+
for (let i = 0; i < faxes.length; i++) {
|
|
171
|
+
const f = faxes[i];
|
|
172
|
+
if (i > 0) lines.push('─'.repeat(40));
|
|
173
|
+
lines.push(
|
|
174
|
+
`ID: ${f.id}`,
|
|
175
|
+
`From: ${f.from_number} To: ${f.to_number} Pages: ${f.pages}`,
|
|
176
|
+
`Received: ${f.received_at}`,
|
|
177
|
+
f.ocr_text
|
|
178
|
+
? `Text preview: ${f.ocr_text.slice(0, 200)}${f.ocr_text.length > 200 ? '…' : ''}`
|
|
179
|
+
: 'OCR: not available',
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return ok(lines);
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
verify_fax: async (args) => {
|
|
186
|
+
if (!args.job_id) throw new Error('job_id is required');
|
|
187
|
+
const data = await apiFetch(`/api/v1/fax/${encodeURIComponent(args.job_id)}/verify`);
|
|
188
|
+
if (!data.attested) {
|
|
189
|
+
return ok([`Job ${args.job_id}: not yet attested (status: ${data.status})`]);
|
|
190
|
+
}
|
|
191
|
+
const lines = [
|
|
192
|
+
`Job ID: ${data.jobId}`,
|
|
193
|
+
`Status: ${data.status}`,
|
|
194
|
+
data.documentHash && `Document hash (SHA-256): ${data.documentHash}`,
|
|
195
|
+
];
|
|
196
|
+
if (data.send) {
|
|
197
|
+
lines.push('', 'Send attestation:',
|
|
198
|
+
` Verified: ${data.send.verified}`,
|
|
199
|
+
` Rekor: ${data.send.rekorUrl}`,
|
|
200
|
+
data.send.error && ` Error: ${data.send.error}`);
|
|
201
|
+
}
|
|
202
|
+
if (data.delivery) {
|
|
203
|
+
lines.push('', 'Delivery attestation:',
|
|
204
|
+
` Verified: ${data.delivery.verified}`,
|
|
205
|
+
` Rekor: ${data.delivery.rekorUrl}`,
|
|
206
|
+
data.delivery.error && ` Error: ${data.delivery.error}`);
|
|
207
|
+
}
|
|
208
|
+
return ok(lines);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Tool definitions
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
const TOOL_DEFS = [
|
|
218
|
+
{
|
|
219
|
+
name: 'send_fax',
|
|
220
|
+
description: 'Send a fax from a publicly accessible PDF URL. Returns a job ID for status tracking.',
|
|
221
|
+
inputSchema: {
|
|
222
|
+
type: 'object', required: ['to', 'file_url'],
|
|
223
|
+
properties: {
|
|
224
|
+
to: { type: 'string', description: 'Destination fax number in E.164 format, e.g. +12025551234' },
|
|
225
|
+
file_url: { type: 'string', description: 'Publicly accessible HTTPS URL to a PDF file' },
|
|
226
|
+
from_name: { type: 'string', description: 'Sender name for the cover page (optional)' },
|
|
227
|
+
from_company: { type: 'string', description: 'Sender company for the cover page (optional)' },
|
|
228
|
+
subject: { type: 'string', description: 'Fax subject line (optional)' },
|
|
229
|
+
notes: { type: 'string', description: 'Cover page notes (optional)' },
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: 'send_fax_text',
|
|
235
|
+
description: 'Convert plain text to a PDF and send it as a fax. Ideal when the content is generated at runtime.',
|
|
236
|
+
inputSchema: {
|
|
237
|
+
type: 'object', required: ['to', 'content'],
|
|
238
|
+
properties: {
|
|
239
|
+
to: { type: 'string', description: 'Destination fax number in E.164 format, e.g. +12025551234' },
|
|
240
|
+
content: { type: 'string', description: 'Plain-text body of the fax (max 50,000 characters)' },
|
|
241
|
+
subject: { type: 'string', description: 'Fax subject line (optional)' },
|
|
242
|
+
from_name: { type: 'string', description: 'Sender name (optional)' },
|
|
243
|
+
from_company: { type: 'string', description: 'Sender company (optional)' },
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: 'quote_fax',
|
|
249
|
+
description: 'Estimate pages and credits for a fax before sending. Accepts known page count, plain text content, or a PDF URL.',
|
|
250
|
+
inputSchema: {
|
|
251
|
+
type: 'object', required: ['to'],
|
|
252
|
+
properties: {
|
|
253
|
+
to: { type: 'string', description: 'Destination fax number in E.164 format, e.g. +12025551234' },
|
|
254
|
+
pages: { type: 'number', description: 'Known page count, if already available' },
|
|
255
|
+
content: { type: 'string', description: 'Plain-text fax body to render and quote' },
|
|
256
|
+
file_url: { type: 'string', description: 'Publicly accessible HTTPS URL to a PDF file to quote' },
|
|
257
|
+
subject: { type: 'string', description: 'Optional subject when quoting plain text content' },
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: 'get_fax_status',
|
|
263
|
+
description: 'Check the delivery status of a sent fax. Status: queued → sending → delivered or failed.',
|
|
264
|
+
inputSchema: {
|
|
265
|
+
type: 'object', required: ['job_id'],
|
|
266
|
+
properties: {
|
|
267
|
+
job_id: { type: 'string', description: 'Fax job ID returned by send_fax or send_fax_text' },
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: 'get_credits',
|
|
273
|
+
description: 'Check the current FaxSeal credit balance for this API token.',
|
|
274
|
+
inputSchema: { type: 'object', properties: {} },
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
name: 'list_received_faxes',
|
|
278
|
+
description: 'List faxes received in the inbox, newest first (up to 50). Includes OCR text when available.',
|
|
279
|
+
inputSchema: {
|
|
280
|
+
type: 'object',
|
|
281
|
+
properties: {
|
|
282
|
+
since: { type: 'string', description: 'ISO 8601 date-time — return only faxes received after this point. Defaults to last 7 days.' },
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: 'verify_fax',
|
|
288
|
+
description: 'Verify the cryptographic attestation of a fax via Sigstore/Rekor. Confirms delivery without tampering.',
|
|
289
|
+
inputSchema: {
|
|
290
|
+
type: 'object', required: ['job_id'],
|
|
291
|
+
properties: {
|
|
292
|
+
job_id: { type: 'string', description: 'Fax job ID to verify' },
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// MCP server factory — one instance per session
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
function buildMcpServer(apiKey) {
|
|
303
|
+
const apiFetch = makeApiFetch(apiKey);
|
|
304
|
+
const handlers = makeHandlers(apiFetch);
|
|
305
|
+
|
|
306
|
+
const server = new Server(
|
|
307
|
+
{ name: 'faxseal-mcp', version: SERVER_VERSION },
|
|
308
|
+
{ capabilities: { tools: {} } },
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }));
|
|
312
|
+
|
|
313
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
314
|
+
const { name, arguments: args = {} } = req.params;
|
|
315
|
+
const handler = handlers[name];
|
|
316
|
+
if (!handler) return toolErr(`Unknown tool: ${name}`);
|
|
317
|
+
try {
|
|
318
|
+
return await handler(args);
|
|
319
|
+
} catch (e) {
|
|
320
|
+
return toolErr(e instanceof Error ? e.message : String(e));
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
return server;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// HTTP server — session registry with cleanup
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
// sessionId -> StreamableHTTPServerTransport
|
|
332
|
+
const sessions = new Map();
|
|
333
|
+
|
|
334
|
+
const httpServer = createServer(async (req, res) => {
|
|
335
|
+
const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
|
|
336
|
+
|
|
337
|
+
if (url.pathname === '/health') {
|
|
338
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
339
|
+
res.end(JSON.stringify({ ok: true, sessions: sessions.size }));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// OAuth Resource Server metadata (RFC 9728) — tells clients we use bearer tokens, not OAuth
|
|
344
|
+
if (url.pathname === '/.well-known/oauth-protected-resource') {
|
|
345
|
+
const resource = `https://${req.headers.host}`;
|
|
346
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
347
|
+
res.end(JSON.stringify({
|
|
348
|
+
resource,
|
|
349
|
+
bearer_methods_supported: ['header', 'query'],
|
|
350
|
+
resource_documentation: 'https://faxseal.com/docs/api',
|
|
351
|
+
}));
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (url.pathname === '/mcp') {
|
|
356
|
+
const authHeader = req.headers['authorization'] ?? '';
|
|
357
|
+
const apiKey = authHeader.startsWith('Bearer ')
|
|
358
|
+
? authHeader.slice(7)
|
|
359
|
+
: url.searchParams.get('apiKey') ?? '';
|
|
360
|
+
|
|
361
|
+
if (!apiKey) {
|
|
362
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
363
|
+
res.end(JSON.stringify({ error: 'Authorization: Bearer <FAXSEAL_API_KEY> required' }));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Re-use existing session if client sends Mcp-Session-Id
|
|
368
|
+
const existingId = req.headers['mcp-session-id'];
|
|
369
|
+
if (typeof existingId === 'string' && sessions.has(existingId)) {
|
|
370
|
+
await sessions.get(existingId).handleRequest(req, res);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// New session
|
|
375
|
+
const transport = new StreamableHTTPServerTransport({
|
|
376
|
+
sessionIdGenerator: () => randomUUID(),
|
|
377
|
+
onsessioninitialized: (sessionId) => {
|
|
378
|
+
sessions.set(sessionId, transport);
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
transport.onclose = () => {
|
|
383
|
+
if (transport.sessionId) sessions.delete(transport.sessionId);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const server = buildMcpServer(apiKey);
|
|
387
|
+
await server.connect(transport);
|
|
388
|
+
await transport.handleRequest(req, res);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
393
|
+
res.end('Not found');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
httpServer.listen(PORT, '0.0.0.0', () => {
|
|
397
|
+
process.stderr.write(`FaxSeal MCP HTTP server listening on port ${PORT}\n`);
|
|
398
|
+
});
|
package/server.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.grichardomi/faxseal",
|
|
4
|
+
"title": "FaxSeal",
|
|
5
|
+
"description": "Send, receive, and verify faxes from AI assistants and agents via FaxSeal.",
|
|
6
|
+
"version": "2.1.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"url": "https://github.com/grichardomi/mcp-server",
|
|
9
|
+
"source": "github"
|
|
10
|
+
},
|
|
11
|
+
"remotes": [
|
|
12
|
+
{
|
|
13
|
+
"type": "streamable-http",
|
|
14
|
+
"url": "https://mcp-server-production-8f66.up.railway.app/mcp"
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"packages": [
|
|
18
|
+
{
|
|
19
|
+
"registryType": "npm",
|
|
20
|
+
"name": "faxseal-mcp",
|
|
21
|
+
"identifier": "faxseal-mcp",
|
|
22
|
+
"version": "2.1.0",
|
|
23
|
+
"runtime": "node",
|
|
24
|
+
"transport": { "type": "stdio" },
|
|
25
|
+
"environmentVariables": [
|
|
26
|
+
{
|
|
27
|
+
"name": "FAXSEAL_API_KEY",
|
|
28
|
+
"description": "Your FaxSeal API key (fsx_...). Generate one at https://faxseal.com/dashboard/api-tokens",
|
|
29
|
+
"required": true
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
package/smithery.yaml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
startCommand:
|
|
2
|
+
type: http
|
|
3
|
+
url: https://mcp-server-production-8f66.up.railway.app/mcp
|
|
4
|
+
configSchema:
|
|
5
|
+
type: object
|
|
6
|
+
required:
|
|
7
|
+
- apiKey
|
|
8
|
+
properties:
|
|
9
|
+
apiKey:
|
|
10
|
+
type: string
|
|
11
|
+
description: Your FaxSeal API key (fsx_...). Generate one at https://faxseal.com/dashboard/api-tokens
|
|
12
|
+
exampleConfig:
|
|
13
|
+
apiKey: fsx_your_token_here
|