@markmnl/fmsg-mcp 0.1.4 → 0.2.1
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/CHANGELOG.md +58 -0
- package/README.md +128 -26
- package/ROADMAP.md +52 -0
- package/SECURITY.md +78 -0
- package/dist/auth.d.ts +14 -6
- package/dist/auth.js +115 -49
- package/dist/client/client.d.ts +34 -24
- package/dist/client/client.js +152 -105
- package/dist/client/errors.d.ts +19 -0
- package/dist/client/errors.js +51 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/stream.d.ts +12 -0
- package/dist/client/stream.js +83 -0
- package/dist/client/token-provider.d.ts +27 -0
- package/dist/client/token-provider.js +54 -0
- package/dist/client/types.d.ts +8 -4
- package/dist/client/url.d.ts +4 -0
- package/dist/client/url.js +35 -0
- package/dist/client/ws.d.ts +2 -1
- package/dist/client/ws.js +9 -3
- package/dist/config.d.ts +9 -3
- package/dist/config.js +21 -6
- package/dist/context.d.ts +6 -3
- package/dist/context.js +9 -2
- package/dist/download.d.ts +11 -0
- package/dist/download.js +34 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +18 -26
- package/dist/http.d.ts +4 -3
- package/dist/http.js +246 -28
- package/dist/index.js +27 -11
- package/dist/instructions.js +12 -5
- package/dist/oauth/config.d.ts +11 -0
- package/dist/oauth/config.js +46 -0
- package/dist/oauth/errors.d.ts +18 -0
- package/dist/oauth/errors.js +40 -0
- package/dist/oauth/issuer.d.ts +31 -0
- package/dist/oauth/issuer.js +185 -0
- package/dist/oauth/provider.d.ts +23 -0
- package/dist/oauth/provider.js +95 -0
- package/dist/oauth/scopes.d.ts +6 -0
- package/dist/oauth/scopes.js +31 -0
- package/dist/render.d.ts +6 -1
- package/dist/render.js +27 -11
- package/dist/resources.js +18 -9
- package/dist/server.js +4 -0
- package/dist/thread.js +15 -14
- package/dist/tools/common.js +8 -3
- package/dist/tools/download.d.ts +2 -0
- package/dist/tools/download.js +38 -0
- package/dist/tools/identity.js +1 -1
- package/dist/tools/list.js +3 -3
- package/dist/tools/read.js +26 -43
- package/dist/tools/save.d.ts +2 -0
- package/dist/tools/save.js +84 -0
- package/dist/tools/send.js +9 -13
- package/dist/tools/wait.js +8 -6
- package/dist/wait.d.ts +1 -1
- package/dist/wait.js +161 -67
- package/docs/http-deployment.md +81 -0
- package/docs/oauth.md +169 -0
- package/docs/token-providers.md +94 -0
- package/package.json +8 -1
- package/server.json +54 -4
package/dist/wait.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import { FmsgHttpError } from "./client/client.js";
|
|
1
3
|
import { compareMessageIds, maxMessageId, minMessageId } from "./client/message-id.js";
|
|
2
|
-
import {
|
|
4
|
+
import { safeErrorMessage } from "./client/redact.js";
|
|
5
|
+
import { openFmsgWebSocket, parseWsEvent, webSocketTokenExpiresAt } from "./client/ws.js";
|
|
6
|
+
import { OAuthRequestError } from "./oauth/errors.js";
|
|
3
7
|
/**
|
|
4
8
|
* Block until the next qualifying inbound message (plus any that arrive on the
|
|
5
9
|
* same thread within the settle window), or until the deadline.
|
|
6
10
|
*/
|
|
7
11
|
export async function waitForMessage(client, self, options, signal, deps = {}) {
|
|
12
|
+
signal?.throwIfAborted();
|
|
8
13
|
const start = Date.now();
|
|
9
14
|
const deadline = start + options.timeoutMs;
|
|
10
15
|
const maxBatch = options.maxBatch ?? 20;
|
|
@@ -19,16 +24,35 @@ export async function waitForMessage(client, self, options, signal, deps = {}) {
|
|
|
19
24
|
}
|
|
20
25
|
let skippedMax = floor;
|
|
21
26
|
let finished = false;
|
|
27
|
+
const retryStop = new AbortController();
|
|
28
|
+
const retrySignal = signal ? AbortSignal.any([signal, retryStop.signal]) : retryStop.signal;
|
|
29
|
+
const authorizationFailure = (error) => error instanceof OAuthRequestError || (error instanceof FmsgHttpError &&
|
|
30
|
+
([401, 403].includes(error.status) || error.path === "/fmsg/token"));
|
|
31
|
+
const retryRead = async (read, attempts = 3) => {
|
|
32
|
+
for (let attempt = 0;; attempt++) {
|
|
33
|
+
retrySignal.throwIfAborted();
|
|
34
|
+
try {
|
|
35
|
+
return await read();
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (authorizationFailure(error) || attempt + 1 >= attempts)
|
|
39
|
+
throw error;
|
|
40
|
+
await delay(400 * (attempt + 1), undefined, { signal: retrySignal });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
22
44
|
const rootCache = new Map();
|
|
23
45
|
const lookupRoot = async (id) => {
|
|
24
46
|
try {
|
|
25
|
-
return (await client.getThreadMessages(id,
|
|
47
|
+
return (await client.getThreadMessages(id, retrySignal)).root_id;
|
|
26
48
|
}
|
|
27
49
|
catch (error) {
|
|
50
|
+
if (authorizationFailure(error))
|
|
51
|
+
throw error;
|
|
28
52
|
// Fall back to a bounded pid walk; any failure here propagates as "unknown".
|
|
29
53
|
let cur = id;
|
|
30
54
|
for (let i = 0; i < 100; i++) {
|
|
31
|
-
const m = await client.getMessage(cur,
|
|
55
|
+
const m = await client.getMessage(cur, retrySignal);
|
|
32
56
|
if (!m.pid)
|
|
33
57
|
return m.id;
|
|
34
58
|
cur = m.pid;
|
|
@@ -46,29 +70,18 @@ export async function waitForMessage(client, self, options, signal, deps = {}) {
|
|
|
46
70
|
const cached = rootCache.get(id);
|
|
47
71
|
if (cached !== undefined)
|
|
48
72
|
return cached;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
break;
|
|
53
|
-
try {
|
|
54
|
-
const root = await lookupRoot(id);
|
|
55
|
-
rootCache.set(id, root);
|
|
56
|
-
return root;
|
|
57
|
-
}
|
|
58
|
-
catch (error) {
|
|
59
|
-
lastError = error;
|
|
60
|
-
if (i + 1 < attempts)
|
|
61
|
-
await new Promise((r) => setTimeout(r, 400 * (i + 1)));
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
73
|
+
const root = await retryRead(() => lookupRoot(id), attempts);
|
|
74
|
+
rootCache.set(id, root);
|
|
75
|
+
return root;
|
|
65
76
|
};
|
|
66
77
|
let targetRoot;
|
|
67
78
|
if (options.threadOf) {
|
|
68
79
|
try {
|
|
69
80
|
targetRoot = await rootOf(options.threadOf, 1);
|
|
70
81
|
}
|
|
71
|
-
catch {
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (authorizationFailure(error))
|
|
84
|
+
throw error;
|
|
72
85
|
throw new Error(`could not determine the thread of message ${options.threadOf}`);
|
|
73
86
|
}
|
|
74
87
|
}
|
|
@@ -85,23 +98,32 @@ export async function waitForMessage(client, self, options, signal, deps = {}) {
|
|
|
85
98
|
let socket;
|
|
86
99
|
let pollTimer;
|
|
87
100
|
let settleTimer;
|
|
101
|
+
let recoveryTimer;
|
|
102
|
+
let openTimer;
|
|
103
|
+
let expiryTimer;
|
|
104
|
+
let reconnectTimer;
|
|
105
|
+
let reconnectDelay = 1000;
|
|
106
|
+
const disposeSocket = (ws) => {
|
|
107
|
+
ws.removeAllListeners();
|
|
108
|
+
// terminate/close during CONNECTING can emit an asynchronous error.
|
|
109
|
+
ws.on("error", () => undefined);
|
|
110
|
+
ws.terminate();
|
|
111
|
+
};
|
|
88
112
|
return new Promise((resolve, reject) => {
|
|
89
113
|
const cleanup = () => {
|
|
90
114
|
finished = true;
|
|
115
|
+
retryStop.abort();
|
|
91
116
|
clearTimeout(deadlineTimer);
|
|
92
117
|
clearTimeout(settleTimer);
|
|
118
|
+
clearTimeout(recoveryTimer);
|
|
119
|
+
clearTimeout(openTimer);
|
|
120
|
+
clearTimeout(expiryTimer);
|
|
121
|
+
clearTimeout(reconnectTimer);
|
|
93
122
|
clearInterval(pollTimer);
|
|
94
123
|
clearInterval(tickTimer);
|
|
95
124
|
signal?.removeEventListener("abort", onAbort);
|
|
96
|
-
if (socket)
|
|
97
|
-
socket
|
|
98
|
-
try {
|
|
99
|
-
socket.close();
|
|
100
|
-
}
|
|
101
|
-
catch {
|
|
102
|
-
/* ignore */
|
|
103
|
-
}
|
|
104
|
-
}
|
|
125
|
+
if (socket)
|
|
126
|
+
disposeSocket(socket);
|
|
105
127
|
};
|
|
106
128
|
const finish = () => {
|
|
107
129
|
if (finished)
|
|
@@ -143,19 +165,22 @@ export async function waitForMessage(client, self, options, signal, deps = {}) {
|
|
|
143
165
|
note = "cancelled";
|
|
144
166
|
finish();
|
|
145
167
|
};
|
|
146
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
147
|
-
if (signal?.aborted)
|
|
148
|
-
return onAbort();
|
|
149
168
|
const deadlineTimer = setTimeout(() => {
|
|
150
169
|
if (batch.length && settleTimer)
|
|
151
170
|
note = "the time limit cut the settle window short";
|
|
152
171
|
finish();
|
|
153
172
|
}, Math.max(0, deadline - Date.now()));
|
|
154
173
|
const tickTimer = setInterval(() => options.onTick?.(Date.now() - start), 20_000);
|
|
174
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
175
|
+
if (signal?.aborted)
|
|
176
|
+
return onAbort();
|
|
155
177
|
const consider = async (m) => {
|
|
156
178
|
if (finished || seen.has(m.id))
|
|
157
179
|
return;
|
|
158
180
|
seen.add(m.id);
|
|
181
|
+
for (let i = unclassified.length - 1; i >= 0; i--)
|
|
182
|
+
if (unclassified[i]?.id === m.id)
|
|
183
|
+
unclassified.splice(i, 1);
|
|
159
184
|
if (compareMessageIds(m.id, floor) <= 0)
|
|
160
185
|
return;
|
|
161
186
|
const skip = (reason) => {
|
|
@@ -177,8 +202,10 @@ export async function waitForMessage(client, self, options, signal, deps = {}) {
|
|
|
177
202
|
root = await rootOf(m.id);
|
|
178
203
|
}
|
|
179
204
|
catch (error) {
|
|
205
|
+
if (authorizationFailure(error))
|
|
206
|
+
return fail(error);
|
|
180
207
|
if (!finished)
|
|
181
|
-
unclassified.push({ id: m.id, from: m.from, error:
|
|
208
|
+
unclassified.push({ id: m.id, from: m.from, error: safeErrorMessage(error) });
|
|
182
209
|
return;
|
|
183
210
|
}
|
|
184
211
|
finally {
|
|
@@ -204,8 +231,10 @@ export async function waitForMessage(client, self, options, signal, deps = {}) {
|
|
|
204
231
|
pending.push({ id: m.id, from: m.from, root_id: root });
|
|
205
232
|
};
|
|
206
233
|
const catchUp = async () => {
|
|
234
|
+
if (finished)
|
|
235
|
+
return;
|
|
207
236
|
try {
|
|
208
|
-
const page = await client.listInbox(100, 0,
|
|
237
|
+
const page = await client.listInbox(100, 0, retrySignal);
|
|
209
238
|
for (const m of [...page].reverse())
|
|
210
239
|
await consider(m);
|
|
211
240
|
}
|
|
@@ -222,43 +251,108 @@ export async function waitForMessage(client, self, options, signal, deps = {}) {
|
|
|
222
251
|
pollTimer = setInterval(() => void catchUp(), pollIntervalMs);
|
|
223
252
|
void catchUp();
|
|
224
253
|
};
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
.then((ws) => {
|
|
228
|
-
if (finished) {
|
|
229
|
-
ws.close();
|
|
254
|
+
const considerPushed = async (id) => {
|
|
255
|
+
if (finished || seen.has(id) || inflight.has(id))
|
|
230
256
|
return;
|
|
257
|
+
inflight.set(id, "");
|
|
258
|
+
try {
|
|
259
|
+
// A socket was authorized at its handshake. Re-read through a protected
|
|
260
|
+
// route so an old connection cannot bypass upstream grant revocation.
|
|
261
|
+
const message = await retryRead(() => client.getMessage(id, retrySignal));
|
|
262
|
+
inflight.delete(id);
|
|
263
|
+
await consider(message);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
if (finished)
|
|
267
|
+
return;
|
|
268
|
+
if (authorizationFailure(error)) {
|
|
269
|
+
fail(error);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
if (!unclassified.some(item => item.id === id))
|
|
273
|
+
unclassified.push({ id, from: "", error: safeErrorMessage(error) });
|
|
274
|
+
// Coalesce exhausted reads into one delayed inbox check; a second
|
|
275
|
+
// announcement is not required to recover an early push.
|
|
276
|
+
recoveryTimer ??= setTimeout(() => {
|
|
277
|
+
recoveryTimer = undefined;
|
|
278
|
+
void catchUp();
|
|
279
|
+
}, 1000);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
inflight.delete(id);
|
|
231
284
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
285
|
+
};
|
|
286
|
+
const open = deps.openSocket ?? openFmsgWebSocket;
|
|
287
|
+
const reconnect = (immediate = false) => {
|
|
288
|
+
if (finished || reconnectTimer || !client.reconnectOnTokenExpiry)
|
|
289
|
+
return;
|
|
290
|
+
reconnectTimer = setTimeout(() => {
|
|
291
|
+
reconnectTimer = undefined;
|
|
292
|
+
connect();
|
|
293
|
+
}, immediate ? 0 : reconnectDelay);
|
|
294
|
+
if (!immediate)
|
|
295
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 10_000);
|
|
296
|
+
};
|
|
297
|
+
const connect = () => {
|
|
298
|
+
if (finished)
|
|
299
|
+
return;
|
|
300
|
+
open(client, retrySignal).then(ws => {
|
|
301
|
+
if (finished) {
|
|
302
|
+
disposeSocket(ws);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
socket = ws;
|
|
306
|
+
const expiresAt = webSocketTokenExpiresAt(ws);
|
|
307
|
+
const disconnected = (why) => {
|
|
308
|
+
if (socket !== ws)
|
|
309
|
+
return;
|
|
310
|
+
clearTimeout(openTimer);
|
|
311
|
+
clearTimeout(expiryTimer);
|
|
237
312
|
socket = undefined;
|
|
238
|
-
|
|
313
|
+
disposeSocket(ws);
|
|
314
|
+
if (finished)
|
|
315
|
+
return;
|
|
316
|
+
if (client.reconnectOnTokenExpiry && expiresAt !== undefined && expiresAt <= Date.now() + 1000) {
|
|
317
|
+
reconnect(true);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
startPolling(why);
|
|
321
|
+
reconnect();
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
openTimer = setTimeout(() => disconnected("WebSocket did not open; polling instead"), options.wsOpenTimeoutMs ?? 10_000);
|
|
325
|
+
ws.on("open", () => {
|
|
326
|
+
clearTimeout(openTimer);
|
|
327
|
+
reconnectDelay = 1000;
|
|
328
|
+
clearInterval(pollTimer);
|
|
329
|
+
pollTimer = undefined;
|
|
330
|
+
transport = "websocket";
|
|
331
|
+
note = null;
|
|
332
|
+
if (expiresAt !== undefined) {
|
|
333
|
+
expiryTimer = setTimeout(() => disconnected("WebSocket credential expired"), Math.max(0, expiresAt - Date.now()));
|
|
334
|
+
}
|
|
335
|
+
// Keep the original floor, seen set, batch and deadline across renewal.
|
|
336
|
+
void catchUp();
|
|
337
|
+
});
|
|
338
|
+
ws.on("message", raw => {
|
|
339
|
+
const event = parseWsEvent(raw);
|
|
340
|
+
if (event?.type === "new_msg" && event.data)
|
|
341
|
+
void considerPushed(event.data.id);
|
|
342
|
+
});
|
|
343
|
+
ws.on("error", () => disconnected("WebSocket failed; polling instead"));
|
|
344
|
+
ws.on("close", () => disconnected("WebSocket closed; polling instead"));
|
|
345
|
+
}).catch(error => {
|
|
346
|
+
if (finished)
|
|
347
|
+
return;
|
|
348
|
+
if (authorizationFailure(error)) {
|
|
349
|
+
fail(error);
|
|
350
|
+
return;
|
|
239
351
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
clearTimeout(openTimer);
|
|
243
|
-
void catchUp();
|
|
352
|
+
startPolling("WebSocket unavailable; polling instead");
|
|
353
|
+
reconnect();
|
|
244
354
|
});
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (event?.type === "new_msg" && event.data)
|
|
248
|
-
void consider(event.data);
|
|
249
|
-
});
|
|
250
|
-
ws.on("error", () => {
|
|
251
|
-
clearTimeout(openTimer);
|
|
252
|
-
socket = undefined;
|
|
253
|
-
startPolling("WebSocket failed; polling instead");
|
|
254
|
-
});
|
|
255
|
-
ws.on("close", () => {
|
|
256
|
-
clearTimeout(openTimer);
|
|
257
|
-
socket = undefined;
|
|
258
|
-
if (!finished)
|
|
259
|
-
startPolling("WebSocket closed; polling instead");
|
|
260
|
-
});
|
|
261
|
-
})
|
|
262
|
-
.catch(() => startPolling("WebSocket unavailable; polling instead"));
|
|
355
|
+
};
|
|
356
|
+
connect();
|
|
263
357
|
});
|
|
264
358
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# HTTP deployment behind TLS
|
|
2
|
+
|
|
3
|
+
This example runs Caddy and fmsg-mcp on the same machine. Replace `mcp.example.com` with your DNS
|
|
4
|
+
name pointing to that machine; Caddy needs access to ports 80/443 for automatic public TLS.
|
|
5
|
+
Keep port 8765 bound to loopback. The command below uses API keys; for OAuth add the
|
|
6
|
+
[OAuth configuration](oauth.md#operator-configuration).
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
FMSG_API_URL=https://api.example.com \
|
|
10
|
+
FMSG_MCP_ALLOWED_HOSTS=mcp.example.com \
|
|
11
|
+
FMSG_MCP_PUBLIC_URL=https://mcp.example.com/mcp \
|
|
12
|
+
FMSG_MCP_ALLOWED_ORIGINS=https://mcp.example.com,https://app.example.com \
|
|
13
|
+
npx -y @markmnl/fmsg-mcp --http 127.0.0.1:8765
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`FMSG_API_KEY` must be unset in HTTP mode. Include only browser origins you use. Behind TLS,
|
|
17
|
+
list the public HTTPS origin explicitly: the server sees the proxy's HTTP connection and does
|
|
18
|
+
not trust forwarded headers to establish the request origin.
|
|
19
|
+
|
|
20
|
+
Save this as `Caddyfile`:
|
|
21
|
+
|
|
22
|
+
```caddyfile
|
|
23
|
+
mcp.example.com {
|
|
24
|
+
@fmsg path /mcp /mcp/attachments/* /.well-known/oauth-protected-resource /.well-known/oauth-protected-resource/*
|
|
25
|
+
handle @fmsg {
|
|
26
|
+
reverse_proxy 127.0.0.1:8765 {
|
|
27
|
+
transport http {
|
|
28
|
+
response_header_timeout 240s
|
|
29
|
+
read_timeout 240s
|
|
30
|
+
write_timeout 60s
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
respond 404
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Validate with `caddy validate --config Caddyfile --adapter caddyfile`, then run Caddy using your
|
|
39
|
+
service manager. The `/healthz` liveness endpoint remains available locally at
|
|
40
|
+
`http://127.0.0.1:8765/healthz`. The public MCP URL is `https://mcp.example.com/mcp`.
|
|
41
|
+
|
|
42
|
+
Caddy preserves the Host, Authorization, Origin and MCP headers. It flushes SSE responses
|
|
43
|
+
immediately by default. Keep the default flush setting: `flush_interval -1` disables backend
|
|
44
|
+
cancellation on early disconnect. Do not enable automatic retries for sending requests.
|
|
45
|
+
See [Caddy's streaming and proxy documentation](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#streaming).
|
|
46
|
+
|
|
47
|
+
For another proxy, preserve these headers and streaming behavior, propagate disconnects, and
|
|
48
|
+
allow response idle time beyond `FMSG_MCP_WAIT_MAX_SECONDS` with assembly headroom. Validate
|
|
49
|
+
allowed and denied Host/Origin requests, unauthenticated 401 responses, CORS preflight, a read,
|
|
50
|
+
and cancellation through the actual deployed proxy before advertising that deployment.
|
|
51
|
+
|
|
52
|
+
The metadata routes are public in OAuth mode and must reach the server for MCP authorization
|
|
53
|
+
discovery. In API-key mode they return 404. When OAuth is configured, its resource URL supplies
|
|
54
|
+
the public same-origin value, so explicitly listing that origin is optional.
|
|
55
|
+
|
|
56
|
+
## Binary attachment downloads
|
|
57
|
+
|
|
58
|
+
`get_attachment_download_url` returns a resource link such as
|
|
59
|
+
`https://mcp.example.com/mcp/attachments/123/report.pdf`. The host fetches it with an authenticated
|
|
60
|
+
GET, using the same `Authorization: Bearer ...` header as its MCP connection. OAuth requires
|
|
61
|
+
`fmsg:read`; the server exchanges the incoming token for a Web API token as usual. The Web API
|
|
62
|
+
checks visibility on every download, even when a link was obtained earlier.
|
|
63
|
+
|
|
64
|
+
Set `FMSG_MCP_PUBLIC_URL` to the exact external MCP endpoint when using API keys. OAuth defaults
|
|
65
|
+
to `FMSG_MCP_OAUTH_RESOURCE_URL`; if both are set, they must match. These URLs require HTTPS outside
|
|
66
|
+
loopback and cannot contain credentials, queries or fragments. Request Host and forwarded headers
|
|
67
|
+
never select the download URL. If the proxy exposes MCP under a different public path, map that
|
|
68
|
+
path and its `/attachments/*` suffix to `/mcp` and `/mcp/attachments/*` respectively.
|
|
69
|
+
|
|
70
|
+
Downloads use the original MIME type, `Content-Disposition: attachment`, Unicode filename encoding,
|
|
71
|
+
`Cache-Control: no-store` and `X-Content-Type-Options: nosniff`. The body streams with backpressure;
|
|
72
|
+
disconnects cancel the upstream request and incomplete transfers abort rather than complete as a
|
|
73
|
+
truncated file. No temporary server files, signed URLs or tokens in query strings are used.
|
|
74
|
+
Range/resume requests are not implemented: GET returns the full file. Treat a failed transfer as
|
|
75
|
+
incomplete and discard its partial local output before retrying.
|
|
76
|
+
|
|
77
|
+
Host and Origin validation applies to downloads too. Allowed browser clients can preflight GET
|
|
78
|
+
with Authorization and read Content-Disposition. Hosts must attach the connection credential
|
|
79
|
+
themselves; never ask the model to locate or copy tokens. Clients that cannot fetch authenticated
|
|
80
|
+
links can still use `download_attachment` inline. Compatibility of authenticated links with each
|
|
81
|
+
third-party AI host must be tested before advertising support.
|
package/docs/oauth.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# HTTP OAuth
|
|
2
|
+
|
|
3
|
+
OAuth is an optional HTTP authentication mode. API-key HTTP and stdio remain available with
|
|
4
|
+
`FMSG_MCP_AUTH_MODE=api-key` (the default). Each endpoint uses one mode; run separate instances
|
|
5
|
+
if you need both. There is no API-key fallback in OAuth mode.
|
|
6
|
+
|
|
7
|
+
A user adds the public MCP URL to a compatible host, signs in with the configured authorization
|
|
8
|
+
server, and consents to messaging scopes. The host handles authorization-code/PKCE and refresh;
|
|
9
|
+
fmsg-mcp validates its access token and exchanges it for a separate Web API token. Users do not
|
|
10
|
+
need to create an fmsg API key for this connection. The MCP server's exchange secret belongs to
|
|
11
|
+
the operator and is never given to users or AI hosts.
|
|
12
|
+
|
|
13
|
+
## Operator configuration
|
|
14
|
+
|
|
15
|
+
Provision an OAuth resource and a confidential exchange client at your authorization server.
|
|
16
|
+
Use the exact same resource URI in its resource registration, MCP configuration and host setup.
|
|
17
|
+
The Web API must support [delegated OAuth claims](https://github.com/markmnl/fmsg-webapi/blob/main/docs/oauth-claims.md),
|
|
18
|
+
with distinct OAuth and owner audiences. Configure the IdP's exchange target to issue that OAuth
|
|
19
|
+
audience and the consented address, with `fmsg:read` and/or `fmsg:write`.
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
export FMSG_API_URL=https://api.example.com
|
|
23
|
+
export FMSG_MCP_AUTH_MODE=oauth
|
|
24
|
+
export FMSG_MCP_OAUTH_RESOURCE_URL=https://mcp.example.com/mcp
|
|
25
|
+
export FMSG_MCP_OAUTH_ISSUER_URL=https://idp.example.com/oauth
|
|
26
|
+
export FMSG_MCP_OAUTH_CLIENT_ID=fmsg-mcp
|
|
27
|
+
export FMSG_MCP_OAUTH_EXCHANGE_AUDIENCE=fmsg-webapi
|
|
28
|
+
export FMSG_MCP_ALLOWED_HOSTS=mcp.example.com
|
|
29
|
+
# Supply FMSG_MCP_OAUTH_CLIENT_SECRET through your service's secret manager.
|
|
30
|
+
# Leave FMSG_API_KEY unset.
|
|
31
|
+
npx -y @markmnl/fmsg-mcp --http 127.0.0.1:8765
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Setting | Required in OAuth mode | Meaning |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `FMSG_MCP_OAUTH_RESOURCE_URL` | Yes | Exact public MCP URL, including `/mcp`; also the incoming JWT audience |
|
|
37
|
+
| `FMSG_MCP_OAUTH_ISSUER_URL` | Yes | Exact authorization-server issuer, including any path |
|
|
38
|
+
| `FMSG_MCP_OAUTH_CLIENT_ID` | Yes | This MCP server's confidential token-exchange client ID |
|
|
39
|
+
| `FMSG_MCP_OAUTH_CLIENT_SECRET` | Yes | Its exchange secret; sent only in HTTP Basic authentication |
|
|
40
|
+
| `FMSG_MCP_OAUTH_EXCHANGE_AUDIENCE` | Yes | Actual Web API OAuth audience; must differ from the MCP resource URI |
|
|
41
|
+
| `FMSG_MCP_OAUTH_ADDRESS_CLAIM` | No; `sub` | Incoming JWT claim containing the consented full `@user@domain` address |
|
|
42
|
+
| `FMSG_API_URL` | Yes | Fixed deployed Web API base URL |
|
|
43
|
+
|
|
44
|
+
Use the actual audience value for `EXCHANGE_AUDIENCE`, even if the IdP also supports target
|
|
45
|
+
aliases: fmsg-mcp checks the returned JWT audience against this value. In the example the Web API
|
|
46
|
+
and IdP target must both use `fmsg-webapi` as their OAuth audience. The outgoing token's `sub`
|
|
47
|
+
must equal the incoming consented address; its issuer/signature are validated by the Web API.
|
|
48
|
+
If your IdP stores a hash of the exchange secret, provision its SHA-256 there and give the original
|
|
49
|
+
secret to this server. Never put the secret in source control or client configuration.
|
|
50
|
+
|
|
51
|
+
OAuth URLs require HTTPS, with HTTP permitted only on loopback for local testing. Discovery,
|
|
52
|
+
JWKS and exchange requests refuse redirects and have a five-second/64-KiB response budget.
|
|
53
|
+
Configuration is vendor neutral, but authorization servers must implement the token profile below;
|
|
54
|
+
an arbitrary OAuth provider without token exchange is not sufficient.
|
|
55
|
+
|
|
56
|
+
Use the [TLS deployment recipe](http-deployment.md), including its metadata routes. Browser hosts
|
|
57
|
+
on other origins need `FMSG_MCP_ALLOWED_ORIGINS`. The configured resource origin is accepted as
|
|
58
|
+
same-origin behind a TLS proxy; forwarded headers never select an issuer, resource or token endpoint.
|
|
59
|
+
|
|
60
|
+
## Discovery and client onboarding
|
|
61
|
+
|
|
62
|
+
Public [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728.html) metadata is served at
|
|
63
|
+
`/.well-known/oauth-protected-resource` and the resource-specific path
|
|
64
|
+
`/.well-known/oauth-protected-resource/mcp`. Both advertise the exact configured resource,
|
|
65
|
+
authorization server, supported scopes and header bearer authentication. A resource served under
|
|
66
|
+
another public path requires the corresponding metadata path to reach this process too.
|
|
67
|
+
Unauthenticated or invalid-token requests receive `401` with a `WWW-Authenticate: Bearer`
|
|
68
|
+
challenge containing `resource_metadata`.
|
|
69
|
+
|
|
70
|
+
Authorization-server metadata uses [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414.html):
|
|
71
|
+
issuer `https://idp.example.com/oauth` is discovered at
|
|
72
|
+
`https://idp.example.com/.well-known/oauth-authorization-server/oauth`. A 404 permits the
|
|
73
|
+
OpenID-style discovery fallback below the issuer. The discovered issuer must match exactly.
|
|
74
|
+
Only its `jwks_uri` is used; the server does not guess `/.well-known/jwks.json` or trust JWT
|
|
75
|
+
`jku`/`x5u` headers. Discovery and keys cache for five minutes. An unknown signing key can trigger
|
|
76
|
+
a refresh after a five-second fetch cooldown; publish new keys before using them.
|
|
77
|
+
|
|
78
|
+
Client registration belongs to the authorization server. If it offers no dynamic registration,
|
|
79
|
+
use pre-registered clients or Client ID Metadata Documents where supported by both server and
|
|
80
|
+
host. Check the intended host's registration support before advertising compatibility. This
|
|
81
|
+
implementation does not add a registration proxy, login UI or consent UI.
|
|
82
|
+
|
|
83
|
+
The resource URL also supplies the public URL for `get_attachment_download_url`. Its links contain
|
|
84
|
+
no credentials; hosts fetch them using the current MCP bearer token, with `fmsg:read`. The binary
|
|
85
|
+
GET route uses the same token validation and Web API token exchange, and exposes the same metadata
|
|
86
|
+
challenge on authentication failure. See [binary downloads](http-deployment.md#binary-attachment-downloads)
|
|
87
|
+
for reverse-proxy routing and host compatibility requirements.
|
|
88
|
+
|
|
89
|
+
## Token validation and scopes
|
|
90
|
+
|
|
91
|
+
Incoming access tokens must be signed EdDSA JWTs with `typ: at+jwt`, a nonempty `kid` in the
|
|
92
|
+
discovered key set, exact `iss`, and exactly one audience equal to the resource URI. Signature,
|
|
93
|
+
`exp` and any `nbf` are checked on every request. `sub` is required, the configured address claim
|
|
94
|
+
must contain a full fmsg address, and `scope` is a space-delimited string. Unrecognized scopes
|
|
95
|
+
grant no messaging capability. Tokens for another audience, including Web API/owner tokens,
|
|
96
|
+
are refused.
|
|
97
|
+
|
|
98
|
+
| Operations | Required scope |
|
|
99
|
+
|---|---|
|
|
100
|
+
| Identity/address lookup, inbox/sent, message/thread, delivery status, attachment download, wait, resource reads | `fmsg:read` |
|
|
101
|
+
| Send a new message, add recipients, react, mark read | `fmsg:write` |
|
|
102
|
+
| Reply (reads the parent before sending) | `fmsg:read fmsg:write` |
|
|
103
|
+
| Protocol initialization, tool/resource/prompt discovery and prompt templates | Valid incoming token; no messaging scope needed |
|
|
104
|
+
|
|
105
|
+
Missing scope returns `403` with `error="insufficient_scope"` and the required `scope` in the
|
|
106
|
+
bearer challenge. The SDK then validates tool arguments and dispatches the request. Scopes are
|
|
107
|
+
checked against the actual request body, including resource reads; JSON batches are refused.
|
|
108
|
+
The Web API still decides message visibility, recipients, account grants, quotas and acceptance.
|
|
109
|
+
No duplicate messaging ACLs or per-message approvals are introduced.
|
|
110
|
+
|
|
111
|
+
## Exchange, renewal and revocation
|
|
112
|
+
|
|
113
|
+
For protected operations, fmsg-mcp calls the discovered token endpoint with an
|
|
114
|
+
[RFC 8693](https://www.rfc-editor.org/rfc/rfc8693.html) form: token-exchange `grant_type`, incoming
|
|
115
|
+
`subject_token`, access-token `subject_token_type`, configured `audience`, and the granted
|
|
116
|
+
messaging `scope`. HTTP Basic carries the server client ID and secret. The incoming token is
|
|
117
|
+
never sent to the Web API, and `X-FMSG-Act-As` is never set.
|
|
118
|
+
|
|
119
|
+
The response must contain a separate JWT `access_token`, Bearer `token_type`, access-token
|
|
120
|
+
`issued_token_type`, positive `expires_in`, matching `scope`, and no refresh token. Its audience,
|
|
121
|
+
address, expiry and scopes are checked before use; the trusted token endpoint issues it, and the
|
|
122
|
+
Web API verifies its signature and issuer. Delegated tokens must not reach owner-only routes
|
|
123
|
+
such as sub-account, API-key or push administration, even outside the MCP tool surface.
|
|
124
|
+
|
|
125
|
+
Each incoming token has its own client and cache, even when two tokens name the same user.
|
|
126
|
+
The cache lifetime is the earliest of `expires_in`, exchanged JWT expiry, incoming JWT expiry
|
|
127
|
+
and five minutes from the exchange request. Renewal normally starts halfway through that lifetime.
|
|
128
|
+
`FMSG_MCP_KEY_CACHE_MAX` bounds retained client entries; idle OAuth entries are evicted after five
|
|
129
|
+
minutes. `FMSG_MCP_KEY_CACHE_TTL_SECONDS` applies only to API-key mode and cannot extend OAuth
|
|
130
|
+
credentials. Active requests retain their own leases through cache eviction. No token is persisted.
|
|
131
|
+
|
|
132
|
+
Web API `401` retries once after re-exchange; `403` never retries. An expiry timer closes an OAuth
|
|
133
|
+
WebSocket by its cached credential deadline, even if the Web API leaves it open. Waits reconnect,
|
|
134
|
+
catch up, and retain their original cursor, accumulated batch and timeout. Network failures use
|
|
135
|
+
bounded reconnect backoff with polling; cancellation closes sockets and stops exchange work.
|
|
136
|
+
This does not resolve the separate large-backlog/pending-thread cursor work in [ROADMAP.md](../ROADMAP.md).
|
|
137
|
+
|
|
138
|
+
- `invalid_grant` from exchange means `401`: refresh or reconnect the client connection.
|
|
139
|
+
- `invalid_client` / `invalid_target` means `500` plus a sanitized operator-action log. Monitor this
|
|
140
|
+
log as an alert; correct the exchange configuration rather than asking users to sign in again.
|
|
141
|
+
- Unavailable discovery/JWKS/exchange means `503`; there is no fallback credential.
|
|
142
|
+
- Web API `403` with `insufficient_scope` is returned as a scope challenge. It may indicate either
|
|
143
|
+
missing scope or a route permanently closed to delegated tokens; additional consent cannot open
|
|
144
|
+
owner-only routes.
|
|
145
|
+
|
|
146
|
+
Authentication is checked before protected responses start. A later authentication failure also
|
|
147
|
+
becomes an HTTP challenge if headers have not been sent. Once a long wait has streamed progress,
|
|
148
|
+
HTTP cannot change its status: it finishes with an MCP authentication error and the next request
|
|
149
|
+
receives the appropriate challenge. Hosts should reconnect/refresh after that error, not repeat sends.
|
|
150
|
+
|
|
151
|
+
Offline validation cannot instantly detect revocation. With ten-minute incoming and five-minute
|
|
152
|
+
exchanged tokens, discovery operations may accept a revoked incoming token for up to ten minutes;
|
|
153
|
+
new Web API requests stop within five minutes once exchange starts refusing the grant. Longer
|
|
154
|
+
IdP lifetimes extend offline acceptance at the corresponding service. The MCP cache/socket cap
|
|
155
|
+
stays five minutes, but a copied upstream token remains usable directly until its actual expiry.
|
|
156
|
+
Configure upstream lifetimes to meet your intended revocation guarantee.
|
|
157
|
+
|
|
158
|
+
## Validation and rollout
|
|
159
|
+
|
|
160
|
+
`test/oauth.test.ts` uses a local authorization server with real Ed25519 signatures and separate
|
|
161
|
+
OAuth/Web API keys. It exercises discovery, invalid claims, scopes, exchange, caller isolation,
|
|
162
|
+
revocation, token expiry and waiting across reconnects. The fake Web API accepts registered token
|
|
163
|
+
fixtures; it is not a substitute for the Web API's JWT authorization tests.
|
|
164
|
+
|
|
165
|
+
Before enabling a hosted deployment, verify the deployed IdP and Web API audience configuration,
|
|
166
|
+
then exercise sign-in/consent, `whoami`, send/read/reply, refresh, revoked grants, scope denial and
|
|
167
|
+
wait cancellation through the real TLS proxy in each advertised MCP host. No live hosted-client
|
|
168
|
+
or deployed IdP compatibility is claimed by the local tests. API-key integration can keep running
|
|
169
|
+
while these services are prepared.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Web API token providers
|
|
2
|
+
|
|
3
|
+
`FmsgClient` accepts either the existing `fmsgk_…` API-key string or a `TokenProvider`.
|
|
4
|
+
This is a client-library extension point. The MCP executable uses it for API keys and
|
|
5
|
+
optional [HTTP OAuth](oauth.md), including discovery, JWT validation and RFC 8693 exchange.
|
|
6
|
+
|
|
7
|
+
Import `TokenProvider`, `TokenProviderRequest` and `AccessToken` from
|
|
8
|
+
`@markmnl/fmsg-mcp/client` (also exported from the package root):
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
interface TokenProvider {
|
|
12
|
+
getToken(request: {
|
|
13
|
+
apiUrl: string;
|
|
14
|
+
signal: AbortSignal;
|
|
15
|
+
forceRefresh: boolean;
|
|
16
|
+
}): Promise<{
|
|
17
|
+
accessToken: string;
|
|
18
|
+
address: string;
|
|
19
|
+
expiresAtMs: number;
|
|
20
|
+
}>;
|
|
21
|
+
close?(): void;
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Supply an implementation as `new FmsgClient(apiUrl, tokenProvider, options)`.
|
|
26
|
+
One provider and client belong to one caller, authorization grant and Web API URL.
|
|
27
|
+
Sharing a provider between clients risks mixing grants or closing another client's credentials.
|
|
28
|
+
|
|
29
|
+
## Provider responsibilities
|
|
30
|
+
|
|
31
|
+
- Obtain a fresh **Web API** bearer access token whenever called. `apiUrl` is the
|
|
32
|
+
normalized, fixed upstream URL; never derive credential destinations from model input.
|
|
33
|
+
`forceRefresh: true` means the previous token was rejected with 401 or renewal was
|
|
34
|
+
explicitly requested. It must bypass any provider cache of that rejected token.
|
|
35
|
+
- Return the authenticated fmsg address that the token authorizes, preserving user-name
|
|
36
|
+
case, and a finite future expiry in milliseconds since the Unix epoch. Use trusted
|
|
37
|
+
authorization/exchange metadata; do not invent an expiry or infer identity from an
|
|
38
|
+
unverified incoming token. The generic client does not decode provider tokens.
|
|
39
|
+
- Honour `signal` for all network work. It aborts when renewal times out, the client
|
|
40
|
+
closes, or all callers waiting for that renewal cancel. Reject on failed/revoked
|
|
41
|
+
authorization; never fall back to an owner token, API key or another grant.
|
|
42
|
+
- Keep credentials out of errors and logs. `close()` synchronously releases retained
|
|
43
|
+
credentials/resources; it is called once when the owning client closes. Closing a
|
|
44
|
+
client is local cleanup, not remote OAuth-grant revocation.
|
|
45
|
+
|
|
46
|
+
## Client responsibilities
|
|
47
|
+
|
|
48
|
+
The client caches immutable token snapshots and shares concurrent renewal. It renews
|
|
49
|
+
five minutes before expiry by default, capped at half the remaining lifetime when the
|
|
50
|
+
token is acquired, so a one-minute token is usable without constant re-exchange.
|
|
51
|
+
`refreshMarginMs: 0` disables early renewal, but never permits an expired cached token.
|
|
52
|
+
Each acquisition has the client's `timeoutMs` budget (60 seconds by default).
|
|
53
|
+
|
|
54
|
+
The first successful acquisition pins the address. A renewal for another address is
|
|
55
|
+
rejected before any request uses that token; changing identities requires a new client.
|
|
56
|
+
This is caller binding, not an additional messaging permission system. The Web API
|
|
57
|
+
still validates the actual credential, identity, scopes, visibility and host limits.
|
|
58
|
+
|
|
59
|
+
Protected HTTP requests retry once on 401 after renewal. Concurrent or late 401s for
|
|
60
|
+
the same old token reuse an already renewed token; 403 never triggers renewal.
|
|
61
|
+
Failed renewal discards the cache. Provider output must be a bearer token with a valid
|
|
62
|
+
address and expiry; an API key passed back as an access token is rejected.
|
|
63
|
+
|
|
64
|
+
`getToken(force?, signal?)` and `address(signal?)` support cancellation, as do existing
|
|
65
|
+
request methods that accept a signal. Cancelling one waiter leaves renewal available
|
|
66
|
+
to others; cancelling the last aborts acquisition. Late provider completion cannot
|
|
67
|
+
replace a later token. `close()` aborts outstanding token acquisition and HTTP work.
|
|
68
|
+
|
|
69
|
+
`openFmsgWebSocket(client, signal?)` obtains its bearer from the same provider/cache.
|
|
70
|
+
The optional signal cancels token acquisition before opening the socket. The caller
|
|
71
|
+
owns the returned WebSocket and must handle its events and close it; long-lived
|
|
72
|
+
connections do not gain automatic token renewal or revocation handling from this helper.
|
|
73
|
+
`wait_for_message` owns its socket and passes cancellation into acquisition. With
|
|
74
|
+
`reconnectOnTokenExpiry: true`, it closes and reopens sockets by the token metadata expiry,
|
|
75
|
+
retaining the original wait deadline and cursor; OAuth mode enables this option.
|
|
76
|
+
|
|
77
|
+
## OAuth adapter contract
|
|
78
|
+
|
|
79
|
+
Validate the incoming MCP access token for the configured issuer and MCP audience,
|
|
80
|
+
then use authenticated [RFC 8693 token exchange](https://www.rfc-editor.org/rfc/rfc8693.html)
|
|
81
|
+
to obtain a separate upstream token. [MCP forbids passing the incoming token through
|
|
82
|
+
to the Web API](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations).
|
|
83
|
+
Keep issuer, JWKS/discovery URLs, audiences, address-claim mapping and client credentials
|
|
84
|
+
configurable; no particular identity provider is required by this interface.
|
|
85
|
+
|
|
86
|
+
The IdP and Web API must agree on scopes and consented-identity binding before hosted
|
|
87
|
+
OAuth is enabled. Exchanged messaging tokens must not acquire owner key-management
|
|
88
|
+
rights or broaden identity via `X-FMSG-Act-As`. The Web API enforces those restrictions;
|
|
89
|
+
the tool list is not an authorization boundary. Browser consent and refresh/revocation endpoints belong to the authorization service and host. Offline JWT validation alone does not
|
|
90
|
+
provide immediate revocation of already issued upstream tokens.
|
|
91
|
+
|
|
92
|
+
Tests in `test/token-provider.test.ts` use registered opaque token fixtures to exercise
|
|
93
|
+
renewal, cancellation, caller isolation, and HTTP/WebSocket credential delivery. They
|
|
94
|
+
do not establish OAuth conformance, JWT verification, or compatibility with a real IdP.
|