@markmnl/fmsg-mcp 0.1.4 → 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 (60) 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/stream.d.ts +12 -0
  13. package/dist/client/stream.js +83 -0
  14. package/dist/client/token-provider.d.ts +27 -0
  15. package/dist/client/token-provider.js +54 -0
  16. package/dist/client/types.d.ts +8 -4
  17. package/dist/client/url.d.ts +4 -0
  18. package/dist/client/url.js +35 -0
  19. package/dist/client/ws.d.ts +2 -1
  20. package/dist/client/ws.js +9 -3
  21. package/dist/config.d.ts +7 -3
  22. package/dist/config.js +15 -6
  23. package/dist/context.d.ts +6 -3
  24. package/dist/context.js +9 -2
  25. package/dist/errors.d.ts +1 -1
  26. package/dist/errors.js +18 -26
  27. package/dist/http.d.ts +4 -3
  28. package/dist/http.js +158 -23
  29. package/dist/index.js +27 -11
  30. package/dist/instructions.js +10 -5
  31. package/dist/oauth/config.d.ts +11 -0
  32. package/dist/oauth/config.js +46 -0
  33. package/dist/oauth/errors.d.ts +18 -0
  34. package/dist/oauth/errors.js +40 -0
  35. package/dist/oauth/issuer.d.ts +31 -0
  36. package/dist/oauth/issuer.js +185 -0
  37. package/dist/oauth/provider.d.ts +23 -0
  38. package/dist/oauth/provider.js +95 -0
  39. package/dist/oauth/scopes.d.ts +6 -0
  40. package/dist/oauth/scopes.js +31 -0
  41. package/dist/render.d.ts +6 -1
  42. package/dist/render.js +27 -11
  43. package/dist/resources.js +18 -9
  44. package/dist/server.js +2 -0
  45. package/dist/thread.js +15 -14
  46. package/dist/tools/common.js +8 -3
  47. package/dist/tools/identity.js +1 -1
  48. package/dist/tools/list.js +3 -3
  49. package/dist/tools/read.js +26 -43
  50. package/dist/tools/save.d.ts +2 -0
  51. package/dist/tools/save.js +84 -0
  52. package/dist/tools/send.js +9 -13
  53. package/dist/tools/wait.js +8 -6
  54. package/dist/wait.d.ts +1 -1
  55. package/dist/wait.js +161 -67
  56. package/docs/http-deployment.md +53 -0
  57. package/docs/oauth.md +163 -0
  58. package/docs/token-providers.md +94 -0
  59. package/package.json +8 -1
  60. package/server.json +7 -2
@@ -1,39 +1,40 @@
1
+ import { type TokenProvider } from "./token-provider.js";
1
2
  import type { AccessToken, Attachment, FmsgMessage, ReactResult, SendInput, SendResult, Thread } from "./types.js";
3
+ export { FmsgHttpError } from "./errors.js";
2
4
  export type FetchLike = typeof fetch;
3
5
  export type FmsgClientOptions = {
4
6
  fetch?: FetchLike;
5
- /** Refresh the access token this long before it expires (default 5 minutes). */
7
+ /** Refresh margin (default 5 minutes), capped at half the token's remaining lifetime on acquisition. */
6
8
  refreshMarginMs?: number;
7
- /** Per-request timeout (default 60 s). */
9
+ /** Per-request timeout; attachment streams use separate header/idle budgets (default 60 s). */
8
10
  timeoutMs?: number;
11
+ /** Allow HTTP outside loopback only on an explicitly trusted network. */
12
+ allowInsecureHttp?: boolean;
13
+ /** Renew event sockets when their bearer expires (delegated OAuth clients). */
14
+ reconnectOnTokenExpiry?: boolean;
9
15
  };
10
- /** An HTTP error from the fmsg Web API, with the status and the host's own error text. */
11
- export declare class FmsgHttpError extends Error {
12
- readonly status: number;
13
- readonly method: string;
14
- readonly path: string;
15
- /** Machine-readable `code` from the body, when the host sends one (thread routes). */
16
- readonly code?: string | undefined;
17
- constructor(message: string, status: number, method: string, path: string,
18
- /** Machine-readable `code` from the body, when the host sends one (thread routes). */
19
- code?: string | undefined);
20
- }
21
16
  /**
22
- * Client for the fmsg Web API (FMSG-003). Exchanges an `fmsgk_` API key for a
23
- * short-lived JWT, refreshes it ahead of expiry, and retries once on 401.
17
+ * Client for the fmsg Web API (FMSG-003). Accepts an API key or a caller-bound
18
+ * token provider, renews tokens ahead of expiry, and retries once on 401.
24
19
  */
25
20
  export declare class FmsgClient {
26
- private readonly apiKey;
27
21
  private readonly options;
28
22
  readonly apiUrl: string;
29
23
  private token?;
30
- private tokenPromise?;
31
- constructor(apiUrl: string, apiKey: string, options?: FmsgClientOptions);
24
+ private renewal?;
25
+ private provider?;
26
+ private boundAddress?;
27
+ private refreshAtMs;
28
+ private readonly lifetime;
29
+ constructor(apiUrl: string, credentials: string | TokenProvider, options?: FmsgClientOptions);
32
30
  private get fetchImpl();
33
- /** The address this client acts as (from the JWT `sub`), exchanging the key if needed. */
34
- address(): Promise<string>;
35
- getToken(force?: boolean): Promise<AccessToken>;
36
- private exchangeToken;
31
+ get reconnectOnTokenExpiry(): boolean;
32
+ /** The provider's authenticated address, pinned for this client's lifetime. */
33
+ address(signal?: AbortSignal): Promise<string>;
34
+ getToken(force?: boolean, signal?: AbortSignal): Promise<AccessToken>;
35
+ private startRenewal;
36
+ /** Release credentials and cancel outstanding work when the client is no longer used. */
37
+ close(): void;
37
38
  private request;
38
39
  private json;
39
40
  listInbox(limit?: number, offset?: number, signal?: AbortSignal): Promise<FmsgMessage[]>;
@@ -67,10 +68,15 @@ export declare class FmsgClient {
67
68
  added: number;
68
69
  }>;
69
70
  react(id: string, emoji: string | null, signal?: AbortSignal): Promise<ReactResult>;
70
- downloadAttachment(id: string, filename: string, signal?: AbortSignal): Promise<{
71
+ downloadAttachment(id: string, filename: string, signal?: AbortSignal, maxBytes?: number): Promise<{
71
72
  data: Uint8Array;
72
73
  contentType?: string;
73
74
  }>;
75
+ /** Caller must consume or cancel the stream. Progress resets the idle budget; cancellation remains active. */
76
+ streamAttachment(id: string, filename: string, signal?: AbortSignal): Promise<{
77
+ stream: ReadableStream<Uint8Array>;
78
+ contentType?: string;
79
+ }>;
74
80
  deleteMessage(id: string, signal?: AbortSignal): Promise<void>;
75
81
  private createDraft;
76
82
  uploadAttachment(draftId: string, attachment: {
@@ -78,6 +84,10 @@ export declare class FmsgClient {
78
84
  data: Uint8Array;
79
85
  contentType?: string;
80
86
  }, signal?: AbortSignal): Promise<Attachment>;
81
- /** Draft → attach → send. The draft is deleted if any step after creation fails. */
87
+ /**
88
+ * Draft → attach → send. Selected secret patterns in body/topic are replaced;
89
+ * the result reports their count and the sent topic. Attachments are unchanged.
90
+ * The draft is deleted if any step after creation fails.
91
+ */
82
92
  send(input: SendInput): Promise<SendResult>;
83
93
  }
@@ -1,130 +1,152 @@
1
1
  import { normalizeFmsgAddress } from "../address.js";
2
2
  import { normalizeMessageId, parseFmsgJson, stringifyWithIds } from "./message-id.js";
3
3
  import { redactSecrets } from "./redact.js";
4
- /** An HTTP error from the fmsg Web API, with the status and the host's own error text. */
5
- export class FmsgHttpError extends Error {
6
- status;
7
- method;
8
- path;
9
- code;
10
- constructor(message, status, method, path,
11
- /** Machine-readable `code` from the body, when the host sends one (thread routes). */
12
- code) {
13
- super(message);
14
- this.status = status;
15
- this.method = method;
16
- this.path = path;
17
- this.code = code;
18
- this.name = "FmsgHttpError";
19
- }
20
- }
21
- function decodeJwtPayload(token) {
22
- const parts = token.split(".");
23
- if (parts.length !== 3 || !parts[1])
24
- throw new Error("token exchange returned an invalid JWT");
25
- try {
26
- return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
27
- }
28
- catch {
29
- throw new Error("token exchange returned an unreadable JWT payload");
30
- }
31
- }
32
- async function readError(response) {
33
- const raw = await response.text().catch(() => "");
34
- if (!raw)
35
- return { message: `HTTP ${response.status}` };
36
- try {
37
- const parsed = JSON.parse(raw);
38
- const message = typeof parsed.error === "string" ? parsed.error : `HTTP ${response.status}`;
39
- return typeof parsed.code === "string" ? { message, code: parsed.code } : { message };
40
- }
41
- catch {
42
- return { message: raw.slice(0, 300) };
43
- }
44
- }
4
+ import { normalizeApiUrl } from "./url.js";
5
+ import { readBytes, withIdleTimeout } from "./stream.js";
6
+ import { FmsgHttpError, readError } from "./errors.js";
7
+ import { ApiKeyTokenProvider } from "./token-provider.js";
8
+ export { FmsgHttpError } from "./errors.js";
45
9
  function withId(message, id) {
46
10
  return { ...message, id, terminal: message.terminal === true, reaction: message.reaction ?? null, reactions: message.reactions ?? [] };
47
11
  }
12
+ /** Detach one waiter without cancelling work another caller still needs. */
13
+ function withAbort(promise, signal) {
14
+ if (!signal)
15
+ return promise;
16
+ return new Promise((resolve, reject) => {
17
+ const onAbort = () => { signal.removeEventListener("abort", onAbort); reject(signal.reason); };
18
+ signal.addEventListener("abort", onAbort, { once: true });
19
+ if (signal.aborted)
20
+ onAbort();
21
+ // Always observe the operation, including a provider that ignores cancellation.
22
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
23
+ });
24
+ }
48
25
  /**
49
- * Client for the fmsg Web API (FMSG-003). Exchanges an `fmsgk_` API key for a
50
- * short-lived JWT, refreshes it ahead of expiry, and retries once on 401.
26
+ * Client for the fmsg Web API (FMSG-003). Accepts an API key or a caller-bound
27
+ * token provider, renews tokens ahead of expiry, and retries once on 401.
51
28
  */
52
29
  export class FmsgClient {
53
- apiKey;
54
30
  options;
55
31
  apiUrl;
56
32
  token;
57
- tokenPromise;
58
- constructor(apiUrl, apiKey, options = {}) {
59
- this.apiKey = apiKey;
33
+ renewal;
34
+ provider;
35
+ boundAddress;
36
+ refreshAtMs = 0;
37
+ lifetime = new AbortController();
38
+ constructor(apiUrl, credentials, options = {}) {
60
39
  this.options = options;
61
- this.apiUrl = apiUrl.replace(/\/+$/u, "");
62
- if (!/^https?:\/\//u.test(this.apiUrl))
63
- throw new Error("FMSG_API_URL must be an http(s) URL");
64
- if (!apiKey.startsWith("fmsgk_"))
65
- throw new Error("fmsg API key must start with fmsgk_");
40
+ this.apiUrl = normalizeApiUrl(apiUrl, options.allowInsecureHttp);
41
+ if (options.refreshMarginMs !== undefined && (!Number.isFinite(options.refreshMarginMs) || options.refreshMarginMs < 0)) {
42
+ throw new Error("refreshMarginMs must be a finite non-negative number");
43
+ }
44
+ this.provider = typeof credentials === "string" ? new ApiKeyTokenProvider(credentials, this.fetchImpl) : credentials;
45
+ if (!this.provider || typeof this.provider.getToken !== "function")
46
+ throw new Error("a token provider must implement getToken");
66
47
  }
67
48
  get fetchImpl() {
68
49
  return this.options.fetch ?? fetch;
69
50
  }
70
- /** The address this client acts as (from the JWT `sub`), exchanging the key if needed. */
71
- async address() {
72
- return (await this.getToken()).address;
51
+ get reconnectOnTokenExpiry() { return this.options.reconnectOnTokenExpiry === true; }
52
+ /** The provider's authenticated address, pinned for this client's lifetime. */
53
+ async address(signal) {
54
+ return (await this.getToken(false, signal)).address;
73
55
  }
74
- async getToken(force = false) {
75
- const margin = this.options.refreshMarginMs ?? 300_000;
76
- if (!force && this.token && this.token.expiresAtMs - margin > Date.now())
56
+ async getToken(force = false, signal) {
57
+ signal?.throwIfAborted();
58
+ this.lifetime.signal.throwIfAborted();
59
+ if (!this.renewal && !force && this.token && this.refreshAtMs > Date.now())
77
60
  return this.token;
78
- if (!force && this.tokenPromise)
79
- return this.tokenPromise;
80
- this.tokenPromise = this.exchangeToken();
61
+ const renewal = this.renewal ?? this.startRenewal(force);
62
+ renewal.waiters++;
81
63
  try {
82
- this.token = await this.tokenPromise;
83
- return this.token;
64
+ return await withAbort(renewal.promise, signal);
84
65
  }
85
66
  finally {
86
- this.tokenPromise = undefined;
67
+ renewal.waiters--;
68
+ if (!renewal.waiters && this.renewal === renewal) {
69
+ this.renewal = undefined;
70
+ renewal.abort.abort();
71
+ }
87
72
  }
88
73
  }
89
- async exchangeToken() {
90
- const response = await this.fetchImpl(`${this.apiUrl}/fmsg/token`, {
91
- method: "POST",
92
- headers: { authorization: `Bearer ${this.apiKey}` },
93
- signal: AbortSignal.timeout(this.options.timeoutMs ?? 60_000),
74
+ startRenewal(forceRefresh) {
75
+ const provider = this.provider;
76
+ const renewal = { promise: undefined, abort: new AbortController(), waiters: 0 };
77
+ this.renewal = renewal;
78
+ this.token = undefined;
79
+ const signal = AbortSignal.any([this.lifetime.signal, renewal.abort.signal]);
80
+ const timer = setTimeout(() => renewal.abort.abort(new DOMException("token renewal timed out", "TimeoutError")), this.options.timeoutMs ?? 60_000).unref();
81
+ const acquiring = Promise.resolve().then(() => {
82
+ signal.throwIfAborted();
83
+ return provider.getToken({ apiUrl: this.apiUrl, signal, forceRefresh });
94
84
  });
95
- if (!response.ok) {
96
- const { message } = await readError(response);
97
- throw new FmsgHttpError(`token exchange failed: ${redactSecrets(message).text}`, response.status, "POST", "/fmsg/token");
98
- }
99
- const body = (await response.json());
100
- if (typeof body.access_token !== "string")
101
- throw new Error("token response has no access_token");
102
- const payload = decodeJwtPayload(body.access_token);
103
- const address = typeof payload.sub === "string" ? normalizeFmsgAddress(payload.sub) : undefined;
104
- if (!address)
105
- throw new Error("token JWT sub is not an fmsg address");
106
- const fromResponse = typeof body.expires_at === "string" ? Date.parse(body.expires_at) : Number.NaN;
107
- const fromJwt = typeof payload.exp === "number" ? payload.exp * 1000 : Number.NaN;
108
- const fromIn = typeof body.expires_in === "number" ? Date.now() + body.expires_in * 1000 : Number.NaN;
109
- const expiresAtMs = [fromResponse, fromJwt, fromIn].find(Number.isFinite) ?? Date.now() + 3_600_000;
110
- return { accessToken: body.access_token, address, expiresAtMs };
85
+ renewal.promise = withAbort(acquiring, signal).then(value => {
86
+ signal.throwIfAborted();
87
+ const address = typeof value?.address === "string" ? normalizeFmsgAddress(value.address) : undefined;
88
+ if (!address)
89
+ throw new Error("token provider returned an invalid fmsg address");
90
+ if (typeof value.accessToken !== "string" || !/^[A-Za-z0-9._~+\/-]+=*$/u.test(value.accessToken) || value.accessToken.startsWith("fmsgk_")) {
91
+ throw new Error("token provider must return a Web API bearer access token");
92
+ }
93
+ const now = Date.now();
94
+ if (!Number.isFinite(value.expiresAtMs) || value.expiresAtMs <= now)
95
+ throw new Error("token provider returned an invalid or expired token lifetime");
96
+ if (this.boundAddress !== undefined && address !== this.boundAddress)
97
+ throw new Error("token provider changed the authenticated address; create a new client for a different identity");
98
+ this.boundAddress = address;
99
+ const token = Object.freeze({ accessToken: value.accessToken, address, expiresAtMs: value.expiresAtMs });
100
+ this.refreshAtMs = token.expiresAtMs - Math.min(this.options.refreshMarginMs ?? 300_000, (token.expiresAtMs - now) / 2);
101
+ this.token = token;
102
+ return token;
103
+ }).finally(() => {
104
+ clearTimeout(timer);
105
+ if (this.renewal === renewal)
106
+ this.renewal = undefined;
107
+ });
108
+ return renewal;
111
109
  }
112
- async request(path, init = {}, retry401 = true) {
113
- const token = await this.getToken();
110
+ /** Release credentials and cancel outstanding work when the client is no longer used. */
111
+ close() {
112
+ if (this.lifetime.signal.aborted)
113
+ return;
114
+ this.lifetime.abort();
115
+ this.token = undefined;
116
+ const provider = this.provider;
117
+ this.provider = undefined;
118
+ provider?.close?.();
119
+ }
120
+ async request(path, init = {}, retry401 = true, streaming = false) {
121
+ init.signal?.throwIfAborted();
122
+ const token = await this.getToken(false, init.signal ?? undefined);
114
123
  const headers = new Headers(init.headers);
115
124
  headers.set("authorization", `Bearer ${token.accessToken}`);
116
- const signal = init.signal ?? AbortSignal.timeout(this.options.timeoutMs ?? 60_000);
117
- const response = await this.fetchImpl(`${this.apiUrl}${path}`, { ...init, headers, signal });
118
- if (response.status === 401 && retry401) {
119
- await this.getToken(true);
120
- return this.request(path, init, false);
125
+ const headerDeadline = new AbortController();
126
+ const headerTimer = streaming ? setTimeout(() => headerDeadline.abort(new DOMException("attachment response headers timed out", "TimeoutError")), this.options.timeoutMs ?? 60_000).unref() : undefined;
127
+ const timeout = streaming ? headerDeadline.signal : AbortSignal.timeout(this.options.timeoutMs ?? 60_000);
128
+ const signal = AbortSignal.any([this.lifetime.signal, timeout, ...(init.signal ? [init.signal] : [])]);
129
+ try {
130
+ signal.throwIfAborted();
131
+ const response = await this.fetchImpl(`${this.apiUrl}${path}`, { ...init, headers, signal, redirect: "error" });
132
+ if (response.status === 401 && retry401) {
133
+ await response.body?.cancel();
134
+ // A slower 401 may refer to a token another request already renewed.
135
+ if (!this.token || this.token === token || this.token.expiresAtMs <= Date.now()) {
136
+ await this.getToken(true, init.signal ?? undefined);
137
+ }
138
+ return this.request(path, init, false, streaming);
139
+ }
140
+ if (!response.ok) {
141
+ const { message, code } = await readError(response);
142
+ const method = init.method ?? "GET";
143
+ throw new FmsgHttpError(message, response.status, method, path, code, response.status === 403 && /\bBearer\b.*\berror="insufficient_scope"/iu.test(response.headers.get("www-authenticate") ?? ""));
144
+ }
145
+ return response;
121
146
  }
122
- if (!response.ok) {
123
- const { message, code } = await readError(response);
124
- const method = init.method ?? "GET";
125
- throw new FmsgHttpError(redactSecrets(message).text, response.status, method, path, code);
147
+ finally {
148
+ clearTimeout(headerTimer);
126
149
  }
127
- return response;
128
150
  }
129
151
  async json(path, init) {
130
152
  const response = await this.request(path, init);
@@ -195,8 +217,14 @@ export class FmsgClient {
195
217
  }
196
218
  /** Download by a `download` path returned from thread/messages (`/fmsg/...`). */
197
219
  async downloadPath(path, signal) {
198
- if (!path.startsWith("/fmsg/") || path.includes("://"))
199
- throw new Error(`refusing to download non-fmsg path ${path}`);
220
+ // Paths come from upstream message data. Reject normalization tricks and
221
+ // routes outside the documented body/attachment download endpoints.
222
+ if (!/^\/fmsg\/[0-9]+\/(?:data|attach\/[^/?#\\]+)$/u.test(path) || /[\u0000-\u0020\\]/u.test(path)) {
223
+ throw new Error("refusing an invalid fmsg download path");
224
+ }
225
+ const normalized = new URL(path, "https://example.com");
226
+ if (normalized.pathname !== path || normalized.search || normalized.hash)
227
+ throw new Error("refusing an invalid fmsg download path");
200
228
  const response = await this.request(path, { signal });
201
229
  const contentType = response.headers.get("content-type") ?? undefined;
202
230
  return { data: new Uint8Array(await response.arrayBuffer()), ...(contentType ? { contentType } : {}) };
@@ -232,11 +260,22 @@ export class FmsgClient {
232
260
  time: result.time ?? null,
233
261
  };
234
262
  }
235
- async downloadAttachment(id, filename, signal) {
263
+ async downloadAttachment(id, filename, signal, maxBytes) {
264
+ const { stream, contentType } = await this.streamAttachment(id, filename, signal);
265
+ const { data } = await readBytes(stream, maxBytes);
266
+ return { data, ...(contentType ? { contentType } : {}) };
267
+ }
268
+ /** Caller must consume or cancel the stream. Progress resets the idle budget; cancellation remains active. */
269
+ async streamAttachment(id, filename, signal) {
236
270
  const mid = normalizeMessageId(id);
237
- const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/attach/${encodeURIComponent(filename)}`, { signal });
271
+ if (!filename || filename === "." || filename === ".." || /[/\\\u0000]/u.test(filename)) {
272
+ throw new Error("use an attachment filename without directory components");
273
+ }
274
+ const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/attach/${encodeURIComponent(filename)}`, { signal }, true, true);
238
275
  const contentType = response.headers.get("content-type") ?? undefined;
239
- return { data: new Uint8Array(await response.arrayBuffer()), ...(contentType ? { contentType } : {}) };
276
+ if (!response.body)
277
+ throw new Error("attachment response has no body");
278
+ return { stream: withIdleTimeout(response.body, this.options.timeoutMs ?? 60_000), ...(contentType ? { contentType } : {}) };
240
279
  }
241
280
  async deleteMessage(id, signal) {
242
281
  const mid = normalizeMessageId(id);
@@ -278,14 +317,20 @@ export class FmsgClient {
278
317
  const result = parseFmsgJson(await response.text());
279
318
  return { filename: result.filename ?? attachment.filename, size: result.size ?? attachment.data.byteLength };
280
319
  }
281
- /** Draft → attach → send. The draft is deleted if any step after creation fails. */
320
+ /**
321
+ * Draft → attach → send. Selected secret patterns in body/topic are replaced;
322
+ * the result reports their count and the sent topic. Attachments are unchanged.
323
+ * The draft is deleted if any step after creation fails.
324
+ */
282
325
  async send(input) {
283
326
  if (input.to.length === 0)
284
327
  throw new Error("at least one recipient is required");
285
328
  if (input.pid && input.topic)
286
329
  throw new Error("a reply (pid) cannot carry a topic");
287
- const from = await this.address();
288
- const draftId = await this.createDraft(input, from);
330
+ const from = await this.address(input.signal);
331
+ const body = redactSecrets(input.body);
332
+ const topic = redactSecrets(input.pid ? "" : (input.topic ?? ""));
333
+ const draftId = await this.createDraft({ ...input, body: body.text, topic: topic.text }, from);
289
334
  try {
290
335
  const attachments = [];
291
336
  for (const attachment of input.attachments ?? []) {
@@ -300,6 +345,8 @@ export class FmsgClient {
300
345
  id: result.id === undefined || result.id === null ? draftId : normalizeMessageId(result.id),
301
346
  time: result.time ?? null,
302
347
  attachments,
348
+ redactions: body.count + topic.count,
349
+ topic: topic.text,
303
350
  };
304
351
  }
305
352
  catch (error) {
@@ -0,0 +1,19 @@
1
+ /** An HTTP error from the fmsg Web API, with the status and the host's own error text. */
2
+ export declare class FmsgHttpError extends Error {
3
+ readonly status: number;
4
+ readonly method: string;
5
+ readonly path: string;
6
+ /** Machine-readable `code` from the body, when the host sends one (thread routes). */
7
+ readonly code?: string | undefined;
8
+ /** The Web API rejected a delegated scope or closed route. */
9
+ readonly insufficientScope: boolean;
10
+ constructor(message: string, status: number, method: string, path: string,
11
+ /** Machine-readable `code` from the body, when the host sends one (thread routes). */
12
+ code?: string | undefined,
13
+ /** The Web API rejected a delegated scope or closed route. */
14
+ insufficientScope?: boolean);
15
+ }
16
+ export declare function readError(response: Response): Promise<{
17
+ message: string;
18
+ code?: string;
19
+ }>;
@@ -0,0 +1,51 @@
1
+ import { redactSecrets } from "./redact.js";
2
+ import { readBytes } from "./stream.js";
3
+ /** An HTTP error from the fmsg Web API, with the status and the host's own error text. */
4
+ export class FmsgHttpError extends Error {
5
+ status;
6
+ method;
7
+ path;
8
+ code;
9
+ insufficientScope;
10
+ constructor(message, status, method, path,
11
+ /** Machine-readable `code` from the body, when the host sends one (thread routes). */
12
+ code,
13
+ /** The Web API rejected a delegated scope or closed route. */
14
+ insufficientScope = false) {
15
+ super(redactSecrets(message).text);
16
+ this.status = status;
17
+ this.method = method;
18
+ this.path = path;
19
+ this.code = code;
20
+ this.insufficientScope = insufficientScope;
21
+ if (this.code)
22
+ this.code = redactSecrets(this.code).text;
23
+ this.method = redactSecrets(method).text;
24
+ this.path = redactSecrets(path).text;
25
+ this.name = "FmsgHttpError";
26
+ }
27
+ }
28
+ export async function readError(response) {
29
+ // Preserve canonical 400/413 JSON policy details. Other errors, including
30
+ // proxy pages, get a bounded preview independent of message acceptance limits.
31
+ const isJson = (response.headers.get("content-type") ?? "").toLowerCase().includes("json");
32
+ let raw;
33
+ if (!isJson || ![400, 413].includes(response.status)) {
34
+ const { data, truncated } = await readBytes(response.body, 2048, true);
35
+ raw = Buffer.from(data).toString("utf8");
36
+ if (!isJson || truncated)
37
+ return { message: (raw || `HTTP ${response.status}`) + (truncated ? "\n[upstream response truncated at 2048 bytes]" : "") };
38
+ }
39
+ else
40
+ raw = await response.text();
41
+ if (!raw)
42
+ return { message: `HTTP ${response.status}` };
43
+ try {
44
+ const parsed = JSON.parse(raw);
45
+ const message = typeof parsed.error === "string" ? parsed.error : `HTTP ${response.status}`;
46
+ return typeof parsed.code === "string" ? { message, code: parsed.code } : { message };
47
+ }
48
+ catch {
49
+ return { message: Buffer.byteLength(raw) > 2048 ? Buffer.from(raw).subarray(0, 2048).toString("utf8") + "\n[upstream response truncated at 2048 bytes]" : raw };
50
+ }
51
+ }
@@ -1,4 +1,5 @@
1
1
  export { FmsgClient, FmsgHttpError, type FmsgClientOptions } from "./client.js";
2
+ export type { TokenProvider, TokenProviderRequest } from "./token-provider.js";
2
3
  export { openFmsgWebSocket, parseWsEvent } from "./ws.js";
3
4
  export { redactSecrets, safeErrorMessage, type Redacted } from "./redact.js";
4
5
  export { normalizeMessageId, compareMessageIds, maxMessageId, parseFmsgJson, stringifyWithIds } from "./message-id.js";
@@ -0,0 +1,12 @@
1
+ /** A client/output budget, independent of the fmsg host's acceptance limits. */
2
+ export declare class ResponseLimitError extends Error {
3
+ readonly limit: number;
4
+ constructor(limit: number);
5
+ }
6
+ /** Budget time awaiting the next chunk, not the duration of a progressing download. */
7
+ export declare function withIdleTimeout(stream: ReadableStream<Uint8Array>, timeoutMs: number): ReadableStream<Uint8Array>;
8
+ /** Stop reading at a byte budget, optionally returning a marked preview. */
9
+ export declare function readBytes(stream: ReadableStream<Uint8Array> | null, limit?: number, preview?: boolean): Promise<{
10
+ data: Uint8Array;
11
+ truncated: boolean;
12
+ }>;
@@ -0,0 +1,83 @@
1
+ /** A client/output budget, independent of the fmsg host's acceptance limits. */
2
+ export class ResponseLimitError extends Error {
3
+ limit;
4
+ constructor(limit) {
5
+ super(`response exceeds the ${limit}-byte client limit`);
6
+ this.limit = limit;
7
+ }
8
+ }
9
+ /** Budget time awaiting the next chunk, not the duration of a progressing download. */
10
+ export function withIdleTimeout(stream, timeoutMs) {
11
+ const reader = stream.getReader();
12
+ let stopped = false;
13
+ let timer;
14
+ const cancel = async (reason) => {
15
+ stopped = true;
16
+ clearTimeout(timer);
17
+ try {
18
+ await reader.cancel(reason);
19
+ }
20
+ finally {
21
+ reader.releaseLock();
22
+ }
23
+ };
24
+ return new ReadableStream({
25
+ async pull(controller) {
26
+ timer = setTimeout(() => {
27
+ const error = new DOMException("attachment download stalled waiting for data", "TimeoutError");
28
+ controller.error(error);
29
+ void cancel(error).catch(() => undefined);
30
+ }, timeoutMs).unref();
31
+ try {
32
+ const { done, value } = await reader.read();
33
+ if (stopped)
34
+ return;
35
+ if (done) {
36
+ stopped = true;
37
+ reader.releaseLock();
38
+ controller.close();
39
+ }
40
+ else
41
+ controller.enqueue(value);
42
+ }
43
+ catch (error) {
44
+ if (!stopped) {
45
+ stopped = true;
46
+ reader.releaseLock();
47
+ controller.error(error);
48
+ }
49
+ }
50
+ finally {
51
+ clearTimeout(timer);
52
+ }
53
+ },
54
+ cancel,
55
+ });
56
+ }
57
+ /** Stop reading at a byte budget, optionally returning a marked preview. */
58
+ export async function readBytes(stream, limit = Infinity, preview = false) {
59
+ if (!stream)
60
+ return { data: new Uint8Array(), truncated: false };
61
+ const reader = stream.getReader();
62
+ const chunks = [];
63
+ let size = 0;
64
+ try {
65
+ for (;;) {
66
+ const { done, value } = await reader.read();
67
+ if (done)
68
+ return { data: Buffer.concat(chunks, size), truncated: false };
69
+ if (size + value.byteLength > limit) {
70
+ if (!preview)
71
+ throw new ResponseLimitError(limit);
72
+ chunks.push(value.subarray(0, limit - size));
73
+ return { data: Buffer.concat(chunks, limit), truncated: true };
74
+ }
75
+ chunks.push(value);
76
+ size += value.byteLength;
77
+ }
78
+ }
79
+ finally {
80
+ await reader.cancel().catch(() => undefined);
81
+ reader.releaseLock();
82
+ }
83
+ }
@@ -0,0 +1,27 @@
1
+ import type { AccessToken } from "./types.js";
2
+ export type TokenProviderRequest = {
3
+ /** Normalized Web API URL this client is bound to. */
4
+ readonly apiUrl: string;
5
+ /** Aborted on timeout, client close, or cancellation by all waiting callers. */
6
+ readonly signal: AbortSignal;
7
+ /** A protected request rejected the previous token, or renewal was explicitly forced. */
8
+ readonly forceRefresh: boolean;
9
+ };
10
+ /**
11
+ * Supplies a fresh Web API access token for one caller and authorization grant.
12
+ * FmsgClient owns caching and concurrent renewal. Never return an incoming MCP
13
+ * token or use a provider across different callers/grants. See docs/token-providers.md.
14
+ */
15
+ export interface TokenProvider {
16
+ getToken(request: TokenProviderRequest): Promise<AccessToken>;
17
+ /** Release credentials/resources. Called once by FmsgClient.close(). */
18
+ close?(): void;
19
+ }
20
+ /** Existing API-key exchange, behind the same interface as future OAuth adapters. */
21
+ export declare class ApiKeyTokenProvider implements TokenProvider {
22
+ private apiKey;
23
+ private readonly fetchImpl;
24
+ constructor(apiKey: string, fetchImpl: typeof fetch);
25
+ getToken({ apiUrl, signal }: TokenProviderRequest): Promise<AccessToken>;
26
+ close(): void;
27
+ }