@markmnl/fmsg-mcp 1.0.0 → 1.1.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 CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - An upload the fmsg host refused part-way, such as a large file PUT to someone else's draft or a
6
+ sent message, no longer hangs until the proxy times out. The server reads the rest of the body
7
+ and answers with the host's reason; if the host closed the connection before giving one, the
8
+ draft is checked to explain it. An upload whose pooled connection had already closed is
9
+ repeated when none of the file had been read.
10
+ - Upload URL refusals made before the upload starts (wrong method, bad path, unsupported
11
+ encoding and so on) are JSON `{"error": ...}`, like every other upload error. A truncated gzip
12
+ or deflate body now says the data ends early.
13
+ - `data_base64` also accepts the URL-safe alphabet (`-` and `_`), which strict decoding refused
14
+ in 1.1.0; a value that mixes both alphabets is still refused.
15
+ - `download_attachment` returns a text attachment's contents in its structured result (`text`)
16
+ too. Hosts such as Claude Code show models only the structured result, so the file never
17
+ reached the model.
18
+ - Over OAuth, a request with no access token gets a 401 that says so, instead of asking for the
19
+ connection to be refreshed.
20
+ - Large attachments no longer have to pass through the model as base64 ([#17](https://github.com/markmnl/fmsg-mcp/issues/17)).
21
+ New `create_draft`, `send_draft` and `delete_draft` tools create a real fmsg draft that other fmsg
22
+ clients can see. Over HTTP with a public MCP URL, `create_draft` returns an upload URL: the host
23
+ PUTs raw file bytes to `/mcp/drafts/{id}/attachments/{filename}` with the connection's
24
+ Authorization header (OAuth needs `fmsg:write`), and the server streams them into the draft
25
+ without storing them. Uploads accept `Content-Encoding: gzip` or `deflate` and an optional
26
+ `x-content-sha256` check, and have an idle timeout rather than a total one. The example Caddyfile now forwards everything
27
+ under `/mcp`, so new routes need no proxy changes.
28
+ - Attachments take exactly one of `data_base64`, `data_text` (UTF-8 text without base64) or, over
29
+ stdio with the new `FMSG_MCP_UPLOAD_DIR`, `path` (a file in that folder, streamed by the server).
30
+ An optional `sha256` is checked before anything is sent.
31
+ - `data_base64` is decoded strictly: characters outside the base64 alphabet, a bad length or
32
+ padding, or a stray ending are refused with the attachment's name. They used to be skipped
33
+ silently, sending a corrupted file.
34
+ - `send_message` and `reply` descriptions say when inline base64 suits and where large files go.
35
+ The server instructions say how to send files, naming only the routes the server offers. When
36
+ a file of about 48 KB or more goes inline as base64 although a better route exists, the result
37
+ adds a `hint` naming it. A new `send_file` prompt sends files by the best route.
38
+ - `FmsgClient` adds `createDraft()`, `uploadAttachmentStream()`, `deleteAttachment()` and
39
+ `sendDraft()`, and exports `AttachmentChecksumError`; `send()` is their composition.
5
40
  - `get_message` text gives the decoded body size next to the stored size for a compressed body
6
41
  ("1002 bytes; 587 stored, sent compressed"), so the stored size is never mistaken for the body's.
7
42
  - `list_messages` text marks messages you sent to yourself as unread when they are.
package/README.md CHANGED
@@ -63,7 +63,7 @@ claude mcp add fmsg --scope user \
63
63
  ```
64
64
 
65
65
  Then in any session: *"Send @bob@example.com a note about the release"*, *"What's in my fmsg inbox?"*,
66
- *"Wait for Bob's reply and answer it"*. `/fmsg:chat` and `/fmsg:reply` are available as prompts.
66
+ *"Wait for Bob's reply and answer it"*. `/fmsg:chat`, `/fmsg:reply` and `/fmsg:send_file` are available as prompts.
67
67
 
68
68
  ### Claude Desktop, Cursor, VS Code and other stdio hosts
69
69
 
@@ -136,6 +136,9 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
136
136
  | `get_thread` | The lineage from the thread root to a message (`scope: "lineage"`; other replies in the thread are not included), with gaps for messages you cannot see, per-message flags and added recipients, the root's `thread_topic`, and a `next` step that never suggests replying to a terminal or no-reply message |
137
137
  | `send_message` | Start a new thread; sends immediately (fmsg messages are immutable) |
138
138
  | `reply` | Reply into a thread; reply-all by default, refuses terminal and no-reply parents |
139
+ | `create_draft` | Create an unsent message or reply (`reply_to`) that other fmsg clients can see; over HTTP with a public MCP URL, returns an authenticated upload URL for large attachments |
140
+ | `send_draft` | Send a draft, with its attachments |
141
+ | `delete_draft` | Discard an unsent draft |
139
142
  | `add_recipients` | Add `recipients` to a sent message (`add_to` is a deprecated alias) |
140
143
  | `react` | Set or clear your emoji reaction |
141
144
  | `mark_read` | Mark received messages read |
@@ -167,7 +170,8 @@ required. Releases add fields and values; they do not remove or rename them. Sch
167
170
  example "must NOT have additional properties"); remove and re-add the connector once to refresh it.
168
171
 
169
172
  Resources `fmsg://message/{id}` and `fmsg://thread/{id}` expose the same content to hosts that
170
- attach resources; prompts `chat` and `reply` script the wait → reply loop and a guided reply.
173
+ attach resources; prompts `chat` and `reply` script the wait → reply loop and a guided reply, and
174
+ `send_file` sends files by the most efficient route the server offers.
171
175
 
172
176
  ## Configuration
173
177
 
@@ -177,11 +181,12 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
177
181
  | `FMSG_API_KEY` | — | `fmsgk_…` key; stdio mode only |
178
182
  | `FMSG_API_PUBLIC_URL` | `FMSG_API_URL` (HTTP mode: only if HTTPS) | Web API URL reported to users by `whoami`; never used for requests. Set it when the server reaches the Web API at an internal address. Without it, HTTP mode reports no URL for a cleartext `FMSG_API_URL`. Validated like `FMSG_API_URL` |
179
183
  | `FMSG_MCP_AUTH_MODE` | `api-key` | HTTP authentication: `api-key`, `oauth`, or `oauth+api-key` to accept both on one endpoint; see [OAuth settings](docs/oauth.md#operator-configuration) and [combined mode](docs/oauth.md#combined-oauth-and-api-key-mode) |
180
- | `FMSG_MCP_PUBLIC_URL` | OAuth resource URL, otherwise unset | Public MCP endpoint, including `/mcp`; enables the HTTP download-link tool. HTTPS required except loopback; when OAuth is enabled must equal `FMSG_MCP_OAUTH_RESOURCE_URL` |
184
+ | `FMSG_MCP_PUBLIC_URL` | OAuth resource URL, otherwise unset | Public MCP endpoint, including `/mcp`; enables the HTTP download-link tool and draft upload URLs. HTTPS required except loopback; when OAuth is enabled must equal `FMSG_MCP_OAUTH_RESOURCE_URL` |
181
185
  | `FMSG_ALLOW_INSECURE_HTTP` | disabled | Set to `1` only to permit cleartext API access on a trusted development/private network; loopback HTTP is allowed by default |
182
186
  | `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@<domain>`. `caller` resolves them on the domain of the address the server acts as for each request, so `@mark@example.org` gets `@bob@example.org`; directory entries still take precedence |
183
187
  | `FMSG_DIRECTORY` | — | JSON file mapping short names to full addresses |
184
188
  | `FMSG_MCP_DOWNLOAD_DIR` | — | Enable `save_attachment` in stdio; folder for new files named from message ID and filename |
189
+ | `FMSG_MCP_UPLOAD_DIR` | — | Allow attachment `path` arguments in stdio: files in this folder (or below it) are read by the server instead of written out by the model |
185
190
  | `FMSG_MCP_WAIT_MAX_SECONDS` | `230` | Cap on one `wait_for_message` call |
186
191
  | `FMSG_MCP_HOST` / `FMSG_MCP_PORT` | `127.0.0.1` / `8765` | HTTP bind address (or `--http host:port`) |
187
192
  | `FMSG_MCP_ALLOWED_HOSTS` | loopback names | Comma-separated `Host` header allowlist; required for non-loopback binds |
@@ -209,12 +214,38 @@ For remote file downloads, call `get_attachment_download_url` with the message I
209
214
  It returns metadata and an HTTPS `resource_link`; your AI host downloads the original bytes using
210
215
  the existing MCP connection's Authorization header. API-key operators enable the tool with
211
216
  `FMSG_MCP_PUBLIC_URL=https://mcp.example.com/mcp`; OAuth deployments reuse their configured resource
212
- URL automatically. The [reverse proxy must forward the attachment route](docs/http-deployment.md#binary-attachment-downloads).
217
+ URL automatically. See [Binary attachment downloads](docs/http-deployment.md#binary-attachment-downloads).
213
218
  Links contain no credentials and grant no access by themselves. The host must support authenticated
214
219
  HTTP downloads; an ordinary browser click without the header returns 401. Use inline downloads when
215
220
  that host capability is unavailable. Downloads stream without the inline size budget, using the
216
221
  original Content-Type and download filename. No files are stored on the MCP server.
217
222
 
223
+ ### Sending attachments
224
+
225
+ `send_message`, `reply` and `create_draft` take each attachment as exactly one of:
226
+
227
+ - `data_base64`: base64 written by the model; fine for small files. The standard or URL-safe
228
+ alphabet is accepted, but not both in one value. Decoding is strict: any other character, a bad
229
+ length or padding, or a stray ending is refused with the attachment's name, rather than sent as a
230
+ corrupted file.
231
+ - `data_text`: text, sent as its UTF-8 bytes; no base64 overhead.
232
+ - `path` (stdio with `FMSG_MCP_UPLOAD_DIR`): a file in that folder, streamed by the server. Paths
233
+ are relative to the folder, and a path or symlink that leads outside it is refused.
234
+
235
+ Add `sha256` (hex) to any attachment and the server checks the bytes against it; a mismatch is
236
+ refused before anything is sent. For large files over HTTP, call `create_draft`, PUT the files to
237
+ the returned upload URL with the connection's Authorization header (optionally gzip-compressed and
238
+ with `x-content-sha256`), then `send_draft`; see
239
+ [attachment uploads](docs/http-deployment.md#attachment-uploads). Send files as they are: fmsg
240
+ compresses message bodies and attachments itself when it sends them.
241
+
242
+ Models learn the routes from the server instructions and tool descriptions, which name only the
243
+ routes the running server offers. A model that writes a large file as base64 although a better
244
+ route exists gets a `hint` in the result naming it. The `send_file` prompt walks through the best
245
+ route. The upload URL needs a host that can send the MCP connection's credentials with its own
246
+ HTTP requests: scripts, SDK clients, and coding agents given an fmsg API key. Chat hosts usually
247
+ cannot, so for them `data_text` and `data_base64` remain the routes.
248
+
218
249
  The Web API already transfers attachments as raw bytes. Base64 is used only when embedding binary
219
250
  content in MCP's JSON results, as required by [MCP binary resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources#binary-content).
220
251
  `save_attachment` and authenticated HTTP downloads avoid that encoding and keep file bytes out of
@@ -1,6 +1,19 @@
1
1
  import { type TokenProvider } from "./token-provider.js";
2
- import type { AccessToken, Attachment, FmsgMessage, ReactResult, SendInput, SendResult, Thread } from "./types.js";
2
+ import type { AccessToken, Attachment, DraftInput, DraftResult, FmsgMessage, OutboundAttachment, ReactResult, SendInput, SendResult, StreamAttachment, Thread, UploadedAttachment } from "./types.js";
3
3
  export { FmsgHttpError } from "./errors.js";
4
+ /** An uploaded attachment's bytes did not match the sha256 the sender gave; the attachment was removed again. */
5
+ export declare class AttachmentChecksumError extends Error {
6
+ readonly filename: string;
7
+ readonly expected: string;
8
+ readonly actual: string;
9
+ constructor(filename: string, expected: string, actual: string);
10
+ }
11
+ /** The fmsg host closed the connection before the whole attachment was sent, so its reason, if it gave one, was lost. */
12
+ export declare class UploadInterruptedError extends Error {
13
+ readonly filename: string;
14
+ readonly sent: number;
15
+ constructor(filename: string, sent: number, cause: unknown);
16
+ }
4
17
  export type FetchLike = typeof fetch;
5
18
  export type FmsgClientOptions = {
6
19
  fetch?: FetchLike;
@@ -35,6 +48,10 @@ export declare class FmsgClient {
35
48
  private startRenewal;
36
49
  /** Release credentials and cancel outstanding work when the client is no longer used. */
37
50
  close(): void;
51
+ /**
52
+ * `timing`: "total" bounds the whole request; "headers" bounds only the wait for response headers (streamed
53
+ * downloads); "caller" leaves deadlines to the caller's signal (streamed uploads with their own idle budget).
54
+ */
38
55
  private request;
39
56
  private json;
40
57
  listInbox(limit?: number, offset?: number, signal?: AbortSignal): Promise<FmsgMessage[]>;
@@ -81,12 +98,26 @@ export declare class FmsgClient {
81
98
  contentType?: string;
82
99
  }>;
83
100
  deleteMessage(id: string, signal?: AbortSignal): Promise<void>;
84
- private createDraft;
85
- uploadAttachment(draftId: string, attachment: {
86
- filename: string;
87
- data: Uint8Array;
88
- contentType?: string;
89
- }, signal?: AbortSignal): Promise<Attachment>;
101
+ private postDraft;
102
+ /**
103
+ * Create an unsent draft. Selected secret patterns in body/topic are replaced, as for send(); the result
104
+ * reports them and the stored topic. Upload attachments, then sendDraft() or deleteMessage().
105
+ */
106
+ createDraft(input: DraftInput): Promise<DraftResult>;
107
+ uploadAttachment(draftId: string, attachment: OutboundAttachment, signal?: AbortSignal): Promise<Attachment>;
108
+ /**
109
+ * Stream an attachment into a draft without buffering it, hashing the bytes on the way. Each chunk resets an idle
110
+ * budget (the client timeout, default 60 s), so a slow but progressing upload is not cut off. The upload is not
111
+ * retried on 401 because a consumed stream cannot be replayed. When `expectedSha256` is given and differs, the
112
+ * attachment is removed again and AttachmentChecksumError is thrown.
113
+ */
114
+ uploadAttachmentStream(draftId: string, attachment: StreamAttachment, signal?: AbortSignal): Promise<UploadedAttachment>;
115
+ deleteAttachment(draftId: string, filename: string, signal?: AbortSignal): Promise<void>;
116
+ /** Send a draft created with createDraft(). The host validates it; a refusal leaves it a draft. */
117
+ sendDraft(id: string, signal?: AbortSignal): Promise<{
118
+ id: string;
119
+ time: number | null;
120
+ }>;
90
121
  /**
91
122
  * Draft → attach → send. Selected secret patterns in body/topic are replaced;
92
123
  * the result reports their count and the sent topic. Attachments are unchanged.
@@ -5,7 +5,36 @@ import { normalizeApiUrl } from "./url.js";
5
5
  import { readBytes, withIdleTimeout } from "./stream.js";
6
6
  import { FmsgHttpError, readError } from "./errors.js";
7
7
  import { ApiKeyTokenProvider } from "./token-provider.js";
8
+ import { createHash, randomUUID } from "node:crypto";
8
9
  export { FmsgHttpError } from "./errors.js";
10
+ /** An uploaded attachment's bytes did not match the sha256 the sender gave; the attachment was removed again. */
11
+ export class AttachmentChecksumError extends Error {
12
+ filename;
13
+ expected;
14
+ actual;
15
+ constructor(filename, expected, actual) {
16
+ super(`attachment ${filename}: sha256 mismatch (expected ${expected}, got ${actual}); the attachment was not kept`);
17
+ this.filename = filename;
18
+ this.expected = expected;
19
+ this.actual = actual;
20
+ this.name = "AttachmentChecksumError";
21
+ }
22
+ }
23
+ /** The fmsg host closed the connection before the whole attachment was sent, so its reason, if it gave one, was lost. */
24
+ export class UploadInterruptedError extends Error {
25
+ filename;
26
+ sent;
27
+ constructor(filename, sent, cause) {
28
+ const inner = cause instanceof Error ? cause.cause : undefined;
29
+ const detail = inner?.message || inner?.code || (cause instanceof Error ? cause.message : String(cause));
30
+ super(sent === 0 ? `could not send attachment ${filename} to the fmsg host (${detail})`
31
+ : `the fmsg host closed the connection after ${sent} bytes of attachment ${filename}, before the upload finished (${detail}); ` +
32
+ "it may exceed the host's attachment size limit", { cause });
33
+ this.filename = filename;
34
+ this.sent = sent;
35
+ this.name = "UploadInterruptedError";
36
+ }
37
+ }
9
38
  function withId(message, id) {
10
39
  return { ...message, id, terminal: message.terminal === true, reaction: message.reaction ?? null, reactions: message.reactions ?? [] };
11
40
  }
@@ -117,14 +146,18 @@ export class FmsgClient {
117
146
  this.provider = undefined;
118
147
  provider?.close?.();
119
148
  }
120
- async request(path, init = {}, retry401 = true, streaming = false) {
149
+ /**
150
+ * `timing`: "total" bounds the whole request; "headers" bounds only the wait for response headers (streamed
151
+ * downloads); "caller" leaves deadlines to the caller's signal (streamed uploads with their own idle budget).
152
+ */
153
+ async request(path, init = {}, retry401 = true, timing = "total") {
121
154
  init.signal?.throwIfAborted();
122
155
  const token = await this.getToken(false, init.signal ?? undefined);
123
156
  const headers = new Headers(init.headers);
124
157
  headers.set("authorization", `Bearer ${token.accessToken}`);
125
158
  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);
159
+ const headerTimer = timing === "headers" ? setTimeout(() => headerDeadline.abort(new DOMException("attachment response headers timed out", "TimeoutError")), this.options.timeoutMs ?? 60_000).unref() : undefined;
160
+ const timeout = timing === "total" ? AbortSignal.timeout(this.options.timeoutMs ?? 60_000) : headerDeadline.signal;
128
161
  const signal = AbortSignal.any([this.lifetime.signal, timeout, ...(init.signal ? [init.signal] : [])]);
129
162
  try {
130
163
  signal.throwIfAborted();
@@ -135,7 +168,7 @@ export class FmsgClient {
135
168
  if (!this.token || this.token === token || this.token.expiresAtMs <= Date.now()) {
136
169
  await this.getToken(true, init.signal ?? undefined);
137
170
  }
138
- return this.request(path, init, false, streaming);
171
+ return this.request(path, init, false, timing);
139
172
  }
140
173
  if (!response.ok) {
141
174
  const { message, code } = await readError(response);
@@ -276,7 +309,7 @@ export class FmsgClient {
276
309
  if (!filename || filename === "." || filename === ".." || /[/\\\u0000]/u.test(filename)) {
277
310
  throw new Error("use an attachment filename without directory components");
278
311
  }
279
- const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/attach/${encodeURIComponent(filename)}`, { signal }, true, true);
312
+ const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/attach/${encodeURIComponent(filename)}`, { signal }, true, "headers");
280
313
  const contentType = response.headers.get("content-type") ?? undefined;
281
314
  if (!response.body)
282
315
  throw new Error("attachment response has no body");
@@ -287,7 +320,7 @@ export class FmsgClient {
287
320
  await this.request(`/fmsg/${encodeURIComponent(mid)}`, { method: "DELETE", signal });
288
321
  }
289
322
  // ── Sending ───────────────────────────────────────────────────────────
290
- async createDraft(input, from) {
323
+ async postDraft(input, from) {
291
324
  const body = {
292
325
  version: 1,
293
326
  from,
@@ -310,11 +343,31 @@ export class FmsgClient {
310
343
  throw new Error("draft response has no id");
311
344
  return normalizeMessageId(result.id);
312
345
  }
346
+ /**
347
+ * Create an unsent draft. Selected secret patterns in body/topic are replaced, as for send(); the result
348
+ * reports them and the stored topic. Upload attachments, then sendDraft() or deleteMessage().
349
+ */
350
+ async createDraft(input) {
351
+ if (input.to.length === 0)
352
+ throw new Error("at least one recipient is required");
353
+ if (input.pid && input.topic)
354
+ throw new Error("a reply (pid) cannot carry a topic");
355
+ const from = await this.address(input.signal);
356
+ const body = redactSecrets(input.body);
357
+ const topic = redactSecrets(input.pid ? "" : (input.topic ?? ""));
358
+ const id = await this.postDraft({ ...input, body: body.text, topic: topic.text }, from);
359
+ return {
360
+ id,
361
+ redactions: body.count + topic.count,
362
+ redacted: [...new Set([...topic.kinds, ...body.kinds])].sort(),
363
+ topic: topic.text,
364
+ };
365
+ }
313
366
  async uploadAttachment(draftId, attachment, signal) {
314
367
  const form = new FormData();
315
368
  const blob = new Blob([Buffer.from(attachment.data)], { type: attachment.contentType ?? "application/octet-stream" });
316
369
  form.append("file", blob, attachment.filename);
317
- const response = await this.request(`/fmsg/${encodeURIComponent(draftId)}/attach`, {
370
+ const response = await this.request(`/fmsg/${encodeURIComponent(normalizeMessageId(draftId))}/attach`, {
318
371
  method: "POST",
319
372
  body: form,
320
373
  ...(signal ? { signal } : {}),
@@ -322,41 +375,143 @@ export class FmsgClient {
322
375
  const result = parseFmsgJson(await response.text());
323
376
  return { filename: result.filename ?? attachment.filename, size: result.size ?? attachment.data.byteLength };
324
377
  }
378
+ /**
379
+ * Stream an attachment into a draft without buffering it, hashing the bytes on the way. Each chunk resets an idle
380
+ * budget (the client timeout, default 60 s), so a slow but progressing upload is not cut off. The upload is not
381
+ * retried on 401 because a consumed stream cannot be replayed. When `expectedSha256` is given and differs, the
382
+ * attachment is removed again and AttachmentChecksumError is thrown.
383
+ */
384
+ async uploadAttachmentStream(draftId, attachment, signal) {
385
+ const mid = normalizeMessageId(draftId);
386
+ if (!/^[A-Za-z0-9._-]+$/u.test(attachment.filename) || /^\.+$/u.test(attachment.filename)) {
387
+ throw new Error("attachment filenames may contain only letters, digits, dot, underscore and hyphen");
388
+ }
389
+ const contentType = attachment.contentType ?? "application/octet-stream";
390
+ if (!/^[\x20-\x7e]+$/u.test(contentType))
391
+ throw new Error("attachment content type must be printable ASCII");
392
+ const expected = attachment.expectedSha256?.toLowerCase();
393
+ const boundary = `fmsg-mcp-${randomUUID()}`;
394
+ const encoder = new TextEncoder();
395
+ const head = encoder.encode(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${attachment.filename}"\r\n` +
396
+ `Content-Type: ${contentType}\r\n\r\n`);
397
+ const tail = encoder.encode(`\r\n--${boundary}--\r\n`);
398
+ const idleMs = this.options.timeoutMs ?? 60_000;
399
+ const deadline = new AbortController();
400
+ let timer;
401
+ const arm = (reason) => {
402
+ clearTimeout(timer);
403
+ timer = setTimeout(() => deadline.abort(new DOMException(reason, "TimeoutError")), idleMs).unref();
404
+ };
405
+ const hash = createHash("sha256");
406
+ const reader = attachment.stream.getReader();
407
+ let stage = "head";
408
+ let size = 0;
409
+ let pulled = false;
410
+ let sourceError;
411
+ // The source is cancelled in finally, not when fetch drops an attempt, so an attempt that read none of it can be repeated.
412
+ const multipart = () => {
413
+ stage = "head";
414
+ return new ReadableStream({
415
+ async pull(controller) {
416
+ if (stage === "head") {
417
+ stage = "data";
418
+ controller.enqueue(head);
419
+ return;
420
+ }
421
+ arm("attachment upload stalled waiting for data");
422
+ pulled = true;
423
+ let chunk;
424
+ try {
425
+ chunk = await reader.read();
426
+ }
427
+ catch (error) {
428
+ sourceError = error;
429
+ throw error;
430
+ }
431
+ if (!chunk.done) {
432
+ hash.update(chunk.value);
433
+ size += chunk.value.byteLength;
434
+ controller.enqueue(chunk.value);
435
+ return;
436
+ }
437
+ stage = "done";
438
+ controller.enqueue(tail);
439
+ controller.close();
440
+ arm("attachment upload response timed out");
441
+ },
442
+ });
443
+ };
444
+ arm("attachment upload stalled waiting for data");
445
+ try {
446
+ for (let attempt = 1;; attempt++) {
447
+ try {
448
+ const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/attach`, {
449
+ method: "POST",
450
+ headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
451
+ body: multipart(),
452
+ duplex: "half",
453
+ signal: AbortSignal.any([deadline.signal, ...(signal ? [signal] : [])]),
454
+ }, false, "caller");
455
+ const result = parseFmsgJson(await response.text());
456
+ const uploaded = { filename: result.filename ?? attachment.filename, size: result.size ?? size, sha256: hash.digest("hex") };
457
+ if (expected && expected !== uploaded.sha256) {
458
+ await this.deleteAttachment(mid, uploaded.filename).catch(() => undefined);
459
+ throw new AttachmentChecksumError(attachment.filename, expected, uploaded.sha256);
460
+ }
461
+ return uploaded;
462
+ }
463
+ catch (error) {
464
+ // Report the source's failure (such as invalid compressed data) rather than fetch's wrapper.
465
+ if (sourceError)
466
+ throw sourceError;
467
+ const network = error instanceof TypeError && !deadline.signal.aborted && !signal?.aborted;
468
+ // A pooled connection the host had already closed fails before any of the file is read; that is safe to repeat.
469
+ if (network && !pulled && attempt < 3)
470
+ continue;
471
+ // A host that refuses an upload part-way may close the connection; fetch then reports only the broken write.
472
+ if (network && stage !== "done")
473
+ throw new UploadInterruptedError(attachment.filename, size, error);
474
+ throw error;
475
+ }
476
+ }
477
+ }
478
+ finally {
479
+ clearTimeout(timer);
480
+ if (stage !== "done")
481
+ await reader.cancel().catch(() => undefined);
482
+ }
483
+ }
484
+ async deleteAttachment(draftId, filename, signal) {
485
+ const mid = normalizeMessageId(draftId);
486
+ await this.request(`/fmsg/${encodeURIComponent(mid)}/attach/${encodeURIComponent(filename)}`, { method: "DELETE", ...(signal ? { signal } : {}) });
487
+ }
488
+ /** Send a draft created with createDraft(). The host validates it; a refusal leaves it a draft. */
489
+ async sendDraft(id, signal) {
490
+ const mid = normalizeMessageId(id);
491
+ const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/send`, {
492
+ method: "POST",
493
+ ...(signal ? { signal } : {}),
494
+ });
495
+ const result = parseFmsgJson(await response.text());
496
+ return { id: result.id === undefined || result.id === null ? mid : normalizeMessageId(result.id), time: result.time ?? null };
497
+ }
325
498
  /**
326
499
  * Draft → attach → send. Selected secret patterns in body/topic are replaced;
327
500
  * the result reports their count and the sent topic. Attachments are unchanged.
328
501
  * The draft is deleted if any step after creation fails.
329
502
  */
330
503
  async send(input) {
331
- if (input.to.length === 0)
332
- throw new Error("at least one recipient is required");
333
- if (input.pid && input.topic)
334
- throw new Error("a reply (pid) cannot carry a topic");
335
- const from = await this.address(input.signal);
336
- const body = redactSecrets(input.body);
337
- const topic = redactSecrets(input.pid ? "" : (input.topic ?? ""));
338
- const draftId = await this.createDraft({ ...input, body: body.text, topic: topic.text }, from);
504
+ const draft = await this.createDraft(input);
339
505
  try {
340
506
  const attachments = [];
341
507
  for (const attachment of input.attachments ?? []) {
342
- attachments.push(await this.uploadAttachment(draftId, attachment, input.signal));
508
+ attachments.push(await this.uploadAttachment(draft.id, attachment, input.signal));
343
509
  }
344
- const response = await this.request(`/fmsg/${encodeURIComponent(draftId)}/send`, {
345
- method: "POST",
346
- ...(input.signal ? { signal: input.signal } : {}),
347
- });
348
- const result = parseFmsgJson(await response.text());
349
- return {
350
- id: result.id === undefined || result.id === null ? draftId : normalizeMessageId(result.id),
351
- time: result.time ?? null,
352
- attachments,
353
- redactions: body.count + topic.count,
354
- redacted: [...new Set([...topic.kinds, ...body.kinds])].sort(),
355
- topic: topic.text,
356
- };
510
+ const sent = await this.sendDraft(draft.id, input.signal);
511
+ return { ...sent, attachments, redactions: draft.redactions, redacted: draft.redacted, topic: draft.topic };
357
512
  }
358
513
  catch (error) {
359
- await this.deleteMessage(draftId).catch(() => undefined);
514
+ await this.deleteMessage(draft.id).catch(() => undefined);
360
515
  throw error;
361
516
  }
362
517
  }
@@ -1,4 +1,4 @@
1
- export { FmsgClient, FmsgHttpError, type FmsgClientOptions } from "./client.js";
1
+ export { AttachmentChecksumError, FmsgClient, FmsgHttpError, UploadInterruptedError, type FmsgClientOptions } from "./client.js";
2
2
  export type { TokenProvider, TokenProviderRequest } from "./token-provider.js";
3
3
  export { openFmsgWebSocket, parseWsEvent } from "./ws.js";
4
4
  export { redactSecrets, safeErrorMessage, type Redacted } from "./redact.js";
@@ -1,4 +1,4 @@
1
- export { FmsgClient, FmsgHttpError } from "./client.js";
1
+ export { AttachmentChecksumError, FmsgClient, FmsgHttpError, UploadInterruptedError } from "./client.js";
2
2
  export { openFmsgWebSocket, parseWsEvent } from "./ws.js";
3
3
  export { redactSecrets, safeErrorMessage } from "./redact.js";
4
4
  export { normalizeMessageId, compareMessageIds, maxMessageId, parseFmsgJson, stringifyWithIds } from "./message-id.js";
@@ -121,6 +121,27 @@ export type SendInput = {
121
121
  attachments?: OutboundAttachment[];
122
122
  signal?: AbortSignal;
123
123
  };
124
+ /** A new message's fields without attachments; see FmsgClient.createDraft. */
125
+ export type DraftInput = Omit<SendInput, "attachments">;
126
+ export type DraftResult = {
127
+ id: string;
128
+ /** Selected secret patterns replaced in the draft's body and topic. */
129
+ redactions: number;
130
+ redacted: string[];
131
+ /** Topic actually stored, after redaction; empty for replies. */
132
+ topic: string;
133
+ };
134
+ export type StreamAttachment = {
135
+ filename: string;
136
+ stream: ReadableStream<Uint8Array>;
137
+ contentType?: string;
138
+ /** Lowercase hex sha256 the bytes must have; a mismatch removes the attachment again. */
139
+ expectedSha256?: string;
140
+ };
141
+ export type UploadedAttachment = Attachment & {
142
+ /** Lowercase hex sha256 of the bytes uploaded. */
143
+ sha256: string;
144
+ };
124
145
  export type SendResult = {
125
146
  id: string;
126
147
  time: number | null;
package/dist/config.d.ts CHANGED
@@ -30,6 +30,8 @@ export type Config = {
30
30
  directory?: Record<string, string>;
31
31
  /** Trusted local destination; enables save_attachment over stdio only. */
32
32
  downloadDir?: string;
33
+ /** Trusted local source folder; enables attachment `path` arguments over stdio only. */
34
+ uploadDir?: string;
33
35
  /** Hard cap on a single wait_for_message call. */
34
36
  waitMaxSeconds: number;
35
37
  http: HttpConfig;
package/dist/config.js CHANGED
@@ -96,6 +96,7 @@ export function loadConfig(env, transport, overrides = {}, options = {}) {
96
96
  ...(defaultDomain ? { defaultDomain } : {}),
97
97
  ...(directoryPath ? { directory: loadDirectory(directoryPath) } : {}),
98
98
  ...(transport === "stdio" && env.FMSG_MCP_DOWNLOAD_DIR?.trim() ? { downloadDir: env.FMSG_MCP_DOWNLOAD_DIR.trim() } : {}),
99
+ ...(transport === "stdio" && env.FMSG_MCP_UPLOAD_DIR?.trim() ? { uploadDir: env.FMSG_MCP_UPLOAD_DIR.trim() } : {}),
99
100
  waitMaxSeconds: intEnv(env, "FMSG_MCP_WAIT_MAX_SECONDS", DEFAULT_WAIT_MAX_SECONDS),
100
101
  http: {
101
102
  host,
package/dist/http.d.ts CHANGED
@@ -5,6 +5,8 @@ import { OAuthCallerProvider } from "./oauth/provider.js";
5
5
  export declare const MCP_PATH = "/mcp";
6
6
  /** How long close() lets in-flight requests finish (waits end at once) before cutting them off. */
7
7
  export declare const SHUTDOWN_GRACE_MS = 5000;
8
+ /** How long a request other than an attachment upload may take to arrive in full. Uploads use an idle budget instead. */
9
+ export declare const REQUEST_BODY_TIMEOUT_MS = 60000;
8
10
  /** Convert a Node request into a web-standard Request for the MCP handler. */
9
11
  export declare function toWebRequest(req: IncomingMessage, signal?: AbortSignal): Request;
10
12
  /** Pipe a web-standard Response (possibly a long SSE stream) to the Node response. */