@markmnl/fmsg-mcp 0.1.3 → 0.2.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +78 -22
  3. package/ROADMAP.md +52 -0
  4. package/SECURITY.md +72 -0
  5. package/dist/auth.d.ts +14 -6
  6. package/dist/auth.js +115 -49
  7. package/dist/client/client.d.ts +34 -24
  8. package/dist/client/client.js +152 -105
  9. package/dist/client/errors.d.ts +19 -0
  10. package/dist/client/errors.js +51 -0
  11. package/dist/client/index.d.ts +1 -0
  12. package/dist/client/message-id.d.ts +1 -0
  13. package/dist/client/message-id.js +7 -0
  14. package/dist/client/stream.d.ts +12 -0
  15. package/dist/client/stream.js +83 -0
  16. package/dist/client/token-provider.d.ts +27 -0
  17. package/dist/client/token-provider.js +54 -0
  18. package/dist/client/types.d.ts +8 -4
  19. package/dist/client/url.d.ts +4 -0
  20. package/dist/client/url.js +35 -0
  21. package/dist/client/ws.d.ts +2 -1
  22. package/dist/client/ws.js +9 -3
  23. package/dist/config.d.ts +7 -3
  24. package/dist/config.js +15 -6
  25. package/dist/context.d.ts +6 -3
  26. package/dist/context.js +9 -2
  27. package/dist/errors.d.ts +1 -1
  28. package/dist/errors.js +18 -26
  29. package/dist/http.d.ts +4 -3
  30. package/dist/http.js +158 -23
  31. package/dist/index.js +27 -11
  32. package/dist/instructions.js +10 -5
  33. package/dist/oauth/config.d.ts +11 -0
  34. package/dist/oauth/config.js +46 -0
  35. package/dist/oauth/errors.d.ts +18 -0
  36. package/dist/oauth/errors.js +40 -0
  37. package/dist/oauth/issuer.d.ts +31 -0
  38. package/dist/oauth/issuer.js +185 -0
  39. package/dist/oauth/provider.d.ts +23 -0
  40. package/dist/oauth/provider.js +95 -0
  41. package/dist/oauth/scopes.d.ts +6 -0
  42. package/dist/oauth/scopes.js +31 -0
  43. package/dist/render.d.ts +6 -1
  44. package/dist/render.js +27 -11
  45. package/dist/resources.js +18 -9
  46. package/dist/server.js +2 -0
  47. package/dist/thread.js +15 -14
  48. package/dist/tools/common.js +8 -3
  49. package/dist/tools/identity.js +1 -1
  50. package/dist/tools/list.js +3 -3
  51. package/dist/tools/read.js +26 -43
  52. package/dist/tools/save.d.ts +2 -0
  53. package/dist/tools/save.js +84 -0
  54. package/dist/tools/send.js +9 -13
  55. package/dist/tools/wait.js +14 -5
  56. package/dist/wait.d.ts +15 -1
  57. package/dist/wait.js +232 -84
  58. package/docs/http-deployment.md +53 -0
  59. package/docs/oauth.md +163 -0
  60. package/docs/token-providers.md +94 -0
  61. package/package.json +8 -1
  62. package/server.json +7 -2
package/dist/wait.js CHANGED
@@ -1,10 +1,15 @@
1
- import { compareMessageIds, maxMessageId } from "./client/message-id.js";
2
- import { openFmsgWebSocket, parseWsEvent } from "./client/ws.js";
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { FmsgHttpError } from "./client/client.js";
3
+ import { compareMessageIds, maxMessageId, minMessageId } from "./client/message-id.js";
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;
@@ -18,76 +23,134 @@ export async function waitForMessage(client, self, options, signal, deps = {}) {
18
23
  floor = newest?.id ?? "0";
19
24
  }
20
25
  let skippedMax = floor;
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
+ };
21
44
  const rootCache = new Map();
22
- const rootOf = async (id) => {
23
- if (rootCache.has(id))
24
- return rootCache.get(id);
25
- let root = null;
45
+ const lookupRoot = async (id) => {
26
46
  try {
27
- root = (await client.getThreadMessages(id, signal)).root_id;
47
+ return (await client.getThreadMessages(id, retrySignal)).root_id;
28
48
  }
29
- catch {
30
- // Fall back to a bounded pid walk.
31
- try {
32
- let cur = id;
33
- for (let i = 0; i < 100; i++) {
34
- const m = await client.getMessage(cur, signal);
35
- if (!m.pid) {
36
- root = m.id;
37
- break;
38
- }
39
- cur = m.pid;
40
- }
41
- }
42
- catch {
43
- root = null;
49
+ catch (error) {
50
+ if (authorizationFailure(error))
51
+ throw error;
52
+ // Fall back to a bounded pid walk; any failure here propagates as "unknown".
53
+ let cur = id;
54
+ for (let i = 0; i < 100; i++) {
55
+ const m = await client.getMessage(cur, retrySignal);
56
+ if (!m.pid)
57
+ return m.id;
58
+ cur = m.pid;
44
59
  }
60
+ throw error;
45
61
  }
62
+ };
63
+ /**
64
+ * Resolve a message's thread root. A lookup can fail transiently (the host's
65
+ * WebSocket announces a message slightly before it is readable), so retry a
66
+ * few times; if it still fails, throw rather than guess: a message whose
67
+ * thread is unknown must never be mistaken for one on another thread.
68
+ */
69
+ const rootOf = async (id, attempts = 3) => {
70
+ const cached = rootCache.get(id);
71
+ if (cached !== undefined)
72
+ return cached;
73
+ const root = await retryRead(() => lookupRoot(id), attempts);
46
74
  rootCache.set(id, root);
47
75
  return root;
48
76
  };
49
- const targetRoot = options.threadOf ? await rootOf(options.threadOf) : undefined;
50
- if (options.threadOf && targetRoot === null)
51
- throw new Error(`could not determine the thread of message ${options.threadOf}`);
77
+ let targetRoot;
78
+ if (options.threadOf) {
79
+ try {
80
+ targetRoot = await rootOf(options.threadOf, 1);
81
+ }
82
+ catch (error) {
83
+ if (authorizationFailure(error))
84
+ throw error;
85
+ throw new Error(`could not determine the thread of message ${options.threadOf}`);
86
+ }
87
+ }
52
88
  const seen = new Set();
53
89
  const batch = [];
54
90
  const pending = [];
91
+ const skipped = [];
92
+ const unclassified = [];
93
+ /** Messages whose thread lookup is still running; if the call ends first they count as unclassified. */
94
+ const inflight = new Map();
55
95
  let batchRoot = null;
56
96
  let transport = "websocket";
57
97
  let note = null;
58
98
  let socket;
59
99
  let pollTimer;
60
100
  let settleTimer;
61
- let finished = false;
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
+ };
62
112
  return new Promise((resolve, reject) => {
63
113
  const cleanup = () => {
64
114
  finished = true;
115
+ retryStop.abort();
65
116
  clearTimeout(deadlineTimer);
66
117
  clearTimeout(settleTimer);
118
+ clearTimeout(recoveryTimer);
119
+ clearTimeout(openTimer);
120
+ clearTimeout(expiryTimer);
121
+ clearTimeout(reconnectTimer);
67
122
  clearInterval(pollTimer);
68
123
  clearInterval(tickTimer);
69
124
  signal?.removeEventListener("abort", onAbort);
70
- if (socket) {
71
- socket.removeAllListeners();
72
- try {
73
- socket.close();
74
- }
75
- catch {
76
- /* ignore */
77
- }
78
- }
125
+ if (socket)
126
+ disposeSocket(socket);
79
127
  };
80
128
  const finish = () => {
81
129
  if (finished)
82
130
  return;
83
131
  cleanup();
132
+ for (const [id, from] of inflight)
133
+ unclassified.push({ id, from, error: "thread lookup did not complete before the call returned" });
134
+ inflight.clear();
84
135
  const ids = batch.map((m) => m.id);
136
+ // The cursor advances only over messages returned or deliberately skipped,
137
+ // and never past a message whose thread could not be determined.
138
+ let afterId = maxMessageId([...ids, skippedMax, floor]);
139
+ const firstUnknown = unclassified.length ? minMessageId(unclassified.map((u) => u.id)) : undefined;
140
+ if (firstUnknown !== undefined && compareMessageIds(afterId, firstUnknown) >= 0) {
141
+ const before = (BigInt(firstUnknown) - 1n).toString();
142
+ afterId = compareMessageIds(before, floor) > 0 ? before : floor;
143
+ const held = `cursor held at ${afterId}: could not determine the thread of ${unclassified.map((u) => u.id).join(", ")}; call again to retry`;
144
+ note = note ? `${note}; ${held}` : held;
145
+ }
85
146
  resolve({
86
147
  status: batch.length ? "message" : "timeout",
87
- after_id: batch.length ? maxMessageId(ids) : skippedMax,
148
+ after_id: afterId,
88
149
  thread_root_id: batchRoot,
89
150
  messages: [...batch].sort((a, b) => compareMessageIds(a.id, b.id)),
90
151
  pending_other_threads: pending,
152
+ skipped: [...skipped].sort((a, b) => compareMessageIds(a.id, b.id)),
153
+ unclassified: [...unclassified].sort((a, b) => compareMessageIds(a.id, b.id)),
91
154
  transport,
92
155
  note,
93
156
  });
@@ -102,38 +165,56 @@ export async function waitForMessage(client, self, options, signal, deps = {}) {
102
165
  note = "cancelled";
103
166
  finish();
104
167
  };
105
- signal?.addEventListener("abort", onAbort, { once: true });
106
- if (signal?.aborted)
107
- return onAbort();
108
168
  const deadlineTimer = setTimeout(() => {
109
169
  if (batch.length && settleTimer)
110
170
  note = "the time limit cut the settle window short";
111
171
  finish();
112
172
  }, Math.max(0, deadline - Date.now()));
113
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();
114
177
  const consider = async (m) => {
115
178
  if (finished || seen.has(m.id))
116
179
  return;
117
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);
118
184
  if (compareMessageIds(m.id, floor) <= 0)
119
185
  return;
120
- const disqualified = m.from.toLowerCase() === me ||
121
- (m.reaction !== null && m.reaction !== undefined) ||
122
- m.no_reply === true ||
123
- (wantFrom !== undefined && m.from.toLowerCase() !== wantFrom);
124
- if (disqualified) {
186
+ const skip = (reason) => {
187
+ skipped.push({ id: m.id, reason });
125
188
  if (compareMessageIds(m.id, skippedMax) > 0)
126
189
  skippedMax = m.id;
190
+ };
191
+ if (m.from.toLowerCase() === me)
192
+ return skip("own");
193
+ if (m.reaction !== null && m.reaction !== undefined)
194
+ return skip("reaction");
195
+ if (m.no_reply === true)
196
+ return skip("no_reply");
197
+ if (wantFrom !== undefined && m.from.toLowerCase() !== wantFrom)
198
+ return skip("from_mismatch");
199
+ let root;
200
+ inflight.set(m.id, m.from);
201
+ try {
202
+ root = await rootOf(m.id);
203
+ }
204
+ catch (error) {
205
+ if (authorizationFailure(error))
206
+ return fail(error);
207
+ if (!finished)
208
+ unclassified.push({ id: m.id, from: m.from, error: safeErrorMessage(error) });
127
209
  return;
128
210
  }
129
- const root = await rootOf(m.id);
211
+ finally {
212
+ inflight.delete(m.id);
213
+ }
130
214
  if (finished)
131
215
  return;
132
- if (targetRoot !== undefined && root !== targetRoot) {
133
- if (compareMessageIds(m.id, skippedMax) > 0)
134
- skippedMax = m.id;
135
- return;
136
- }
216
+ if (targetRoot !== undefined && root !== targetRoot)
217
+ return skip("other_thread");
137
218
  if (batch.length === 0) {
138
219
  batchRoot = root;
139
220
  batch.push(m);
@@ -150,8 +231,10 @@ export async function waitForMessage(client, self, options, signal, deps = {}) {
150
231
  pending.push({ id: m.id, from: m.from, root_id: root });
151
232
  };
152
233
  const catchUp = async () => {
234
+ if (finished)
235
+ return;
153
236
  try {
154
- const page = await client.listInbox(100, 0, signal);
237
+ const page = await client.listInbox(100, 0, retrySignal);
155
238
  for (const m of [...page].reverse())
156
239
  await consider(m);
157
240
  }
@@ -168,43 +251,108 @@ export async function waitForMessage(client, self, options, signal, deps = {}) {
168
251
  pollTimer = setInterval(() => void catchUp(), pollIntervalMs);
169
252
  void catchUp();
170
253
  };
171
- const open = deps.openSocket ?? openFmsgWebSocket;
172
- open(client)
173
- .then((ws) => {
174
- if (finished) {
175
- ws.close();
254
+ const considerPushed = async (id) => {
255
+ if (finished || seen.has(id) || inflight.has(id))
176
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
+ }
177
281
  }
178
- socket = ws;
179
- const openTimer = setTimeout(() => {
180
- if (ws.readyState !== ws.OPEN) {
181
- ws.removeAllListeners();
182
- ws.terminate();
282
+ finally {
283
+ inflight.delete(id);
284
+ }
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);
183
312
  socket = undefined;
184
- startPolling("WebSocket did not open; polling instead");
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;
185
351
  }
186
- }, options.wsOpenTimeoutMs ?? 10_000);
187
- ws.on("open", () => {
188
- clearTimeout(openTimer);
189
- void catchUp();
190
- });
191
- ws.on("message", (raw) => {
192
- const event = parseWsEvent(raw);
193
- if (event?.type === "new_msg" && event.data)
194
- void consider(event.data);
195
- });
196
- ws.on("error", () => {
197
- clearTimeout(openTimer);
198
- socket = undefined;
199
- startPolling("WebSocket failed; polling instead");
352
+ startPolling("WebSocket unavailable; polling instead");
353
+ reconnect();
200
354
  });
201
- ws.on("close", () => {
202
- clearTimeout(openTimer);
203
- socket = undefined;
204
- if (!finished)
205
- startPolling("WebSocket closed; polling instead");
206
- });
207
- })
208
- .catch(() => startPolling("WebSocket unavailable; polling instead"));
355
+ };
356
+ connect();
209
357
  });
210
358
  }
@@ -0,0 +1,53 @@
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_ALLOWED_ORIGINS=https://mcp.example.com,https://app.example.com \
12
+ npx -y @markmnl/fmsg-mcp --http 127.0.0.1:8765
13
+ ```
14
+
15
+ `FMSG_API_KEY` must be unset in HTTP mode. Include only browser origins you use. Behind TLS,
16
+ list the public HTTPS origin explicitly: the server sees the proxy's HTTP connection and does
17
+ not trust forwarded headers to establish the request origin.
18
+
19
+ Save this as `Caddyfile`:
20
+
21
+ ```caddyfile
22
+ mcp.example.com {
23
+ @fmsg path /mcp /.well-known/oauth-protected-resource /.well-known/oauth-protected-resource/*
24
+ handle @fmsg {
25
+ reverse_proxy 127.0.0.1:8765 {
26
+ transport http {
27
+ response_header_timeout 240s
28
+ read_timeout 240s
29
+ write_timeout 60s
30
+ }
31
+ }
32
+ }
33
+ respond 404
34
+ }
35
+ ```
36
+
37
+ Validate with `caddy validate --config Caddyfile --adapter caddyfile`, then run Caddy using your
38
+ service manager. The `/healthz` liveness endpoint remains available locally at
39
+ `http://127.0.0.1:8765/healthz`. The public MCP URL is `https://mcp.example.com/mcp`.
40
+
41
+ Caddy preserves the Host, Authorization, Origin and MCP headers. It flushes SSE responses
42
+ immediately by default. Keep the default flush setting: `flush_interval -1` disables backend
43
+ cancellation on early disconnect. Do not enable automatic retries for sending requests.
44
+ See [Caddy's streaming and proxy documentation](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#streaming).
45
+
46
+ For another proxy, preserve these headers and streaming behavior, propagate disconnects, and
47
+ allow response idle time beyond `FMSG_MCP_WAIT_MAX_SECONDS` with assembly headroom. Validate
48
+ allowed and denied Host/Origin requests, unauthenticated 401 responses, CORS preflight, a read,
49
+ and cancellation through the actual deployed proxy before advertising that deployment.
50
+
51
+ The metadata routes are public in OAuth mode and must reach the server for MCP authorization
52
+ discovery. In API-key mode they return 404. When OAuth is configured, its resource URL supplies
53
+ the public same-origin value, so explicitly listing that origin is optional.
package/docs/oauth.md ADDED
@@ -0,0 +1,163 @@
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
+ ## Token validation and scopes
84
+
85
+ Incoming access tokens must be signed EdDSA JWTs with `typ: at+jwt`, a nonempty `kid` in the
86
+ discovered key set, exact `iss`, and exactly one audience equal to the resource URI. Signature,
87
+ `exp` and any `nbf` are checked on every request. `sub` is required, the configured address claim
88
+ must contain a full fmsg address, and `scope` is a space-delimited string. Unrecognized scopes
89
+ grant no messaging capability. Tokens for another audience, including Web API/owner tokens,
90
+ are refused.
91
+
92
+ | Operations | Required scope |
93
+ |---|---|
94
+ | Identity/address lookup, inbox/sent, message/thread, delivery status, attachment download, wait, resource reads | `fmsg:read` |
95
+ | Send a new message, add recipients, react, mark read | `fmsg:write` |
96
+ | Reply (reads the parent before sending) | `fmsg:read fmsg:write` |
97
+ | Protocol initialization, tool/resource/prompt discovery and prompt templates | Valid incoming token; no messaging scope needed |
98
+
99
+ Missing scope returns `403` with `error="insufficient_scope"` and the required `scope` in the
100
+ bearer challenge. The SDK then validates tool arguments and dispatches the request. Scopes are
101
+ checked against the actual request body, including resource reads; JSON batches are refused.
102
+ The Web API still decides message visibility, recipients, account grants, quotas and acceptance.
103
+ No duplicate messaging ACLs or per-message approvals are introduced.
104
+
105
+ ## Exchange, renewal and revocation
106
+
107
+ For protected operations, fmsg-mcp calls the discovered token endpoint with an
108
+ [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693.html) form: token-exchange `grant_type`, incoming
109
+ `subject_token`, access-token `subject_token_type`, configured `audience`, and the granted
110
+ messaging `scope`. HTTP Basic carries the server client ID and secret. The incoming token is
111
+ never sent to the Web API, and `X-FMSG-Act-As` is never set.
112
+
113
+ The response must contain a separate JWT `access_token`, Bearer `token_type`, access-token
114
+ `issued_token_type`, positive `expires_in`, matching `scope`, and no refresh token. Its audience,
115
+ address, expiry and scopes are checked before use; the trusted token endpoint issues it, and the
116
+ Web API verifies its signature and issuer. Delegated tokens must not reach owner-only routes
117
+ such as sub-account, API-key or push administration, even outside the MCP tool surface.
118
+
119
+ Each incoming token has its own client and cache, even when two tokens name the same user.
120
+ The cache lifetime is the earliest of `expires_in`, exchanged JWT expiry, incoming JWT expiry
121
+ and five minutes from the exchange request. Renewal normally starts halfway through that lifetime.
122
+ `FMSG_MCP_KEY_CACHE_MAX` bounds retained client entries; idle OAuth entries are evicted after five
123
+ minutes. `FMSG_MCP_KEY_CACHE_TTL_SECONDS` applies only to API-key mode and cannot extend OAuth
124
+ credentials. Active requests retain their own leases through cache eviction. No token is persisted.
125
+
126
+ Web API `401` retries once after re-exchange; `403` never retries. An expiry timer closes an OAuth
127
+ WebSocket by its cached credential deadline, even if the Web API leaves it open. Waits reconnect,
128
+ catch up, and retain their original cursor, accumulated batch and timeout. Network failures use
129
+ bounded reconnect backoff with polling; cancellation closes sockets and stops exchange work.
130
+ This does not resolve the separate large-backlog/pending-thread cursor work in [ROADMAP.md](../ROADMAP.md).
131
+
132
+ - `invalid_grant` from exchange means `401`: refresh or reconnect the client connection.
133
+ - `invalid_client` / `invalid_target` means `500` plus a sanitized operator-action log. Monitor this
134
+ log as an alert; correct the exchange configuration rather than asking users to sign in again.
135
+ - Unavailable discovery/JWKS/exchange means `503`; there is no fallback credential.
136
+ - Web API `403` with `insufficient_scope` is returned as a scope challenge. It may indicate either
137
+ missing scope or a route permanently closed to delegated tokens; additional consent cannot open
138
+ owner-only routes.
139
+
140
+ Authentication is checked before protected responses start. A later authentication failure also
141
+ becomes an HTTP challenge if headers have not been sent. Once a long wait has streamed progress,
142
+ HTTP cannot change its status: it finishes with an MCP authentication error and the next request
143
+ receives the appropriate challenge. Hosts should reconnect/refresh after that error, not repeat sends.
144
+
145
+ Offline validation cannot instantly detect revocation. With ten-minute incoming and five-minute
146
+ exchanged tokens, discovery operations may accept a revoked incoming token for up to ten minutes;
147
+ new Web API requests stop within five minutes once exchange starts refusing the grant. Longer
148
+ IdP lifetimes extend offline acceptance at the corresponding service. The MCP cache/socket cap
149
+ stays five minutes, but a copied upstream token remains usable directly until its actual expiry.
150
+ Configure upstream lifetimes to meet your intended revocation guarantee.
151
+
152
+ ## Validation and rollout
153
+
154
+ `test/oauth.test.ts` uses a local authorization server with real Ed25519 signatures and separate
155
+ OAuth/Web API keys. It exercises discovery, invalid claims, scopes, exchange, caller isolation,
156
+ revocation, token expiry and waiting across reconnects. The fake Web API accepts registered token
157
+ fixtures; it is not a substitute for the Web API's JWT authorization tests.
158
+
159
+ Before enabling a hosted deployment, verify the deployed IdP and Web API audience configuration,
160
+ then exercise sign-in/consent, `whoami`, send/read/reply, refresh, revoked grants, scope denial and
161
+ wait cancellation through the real TLS proxy in each advertised MCP host. No live hosted-client
162
+ or deployed IdP compatibility is claimed by the local tests. API-key integration can keep running
163
+ while these services are prepared.