@markmnl/fmsg-mcp 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Large attachments no longer have to pass through the model as base64 ([#17](https://github.com/markmnl/fmsg-mcp/issues/17)).
6
+ New `create_draft`, `send_draft` and `delete_draft` tools create a real fmsg draft that other fmsg
7
+ clients can see. Over HTTP with a public MCP URL, `create_draft` returns an upload URL: the host
8
+ PUTs raw file bytes to `/mcp/drafts/{id}/attachments/{filename}` with the connection's
9
+ Authorization header (OAuth needs `fmsg:write`), and the server streams them into the draft
10
+ without storing them. Uploads accept `Content-Encoding: gzip` or `deflate` and an optional
11
+ `x-content-sha256` check, and have an idle timeout rather than a total one. The example Caddyfile now forwards everything
12
+ under `/mcp`, so new routes need no proxy changes.
13
+ - Attachments take exactly one of `data_base64`, `data_text` (UTF-8 text without base64) or, over
14
+ stdio with the new `FMSG_MCP_UPLOAD_DIR`, `path` (a file in that folder, streamed by the server).
15
+ An optional `sha256` is checked before anything is sent.
16
+ - `data_base64` is decoded strictly: characters outside the base64 alphabet, a bad length or
17
+ padding, or a stray ending are refused with the attachment's name. They used to be skipped
18
+ silently, sending a corrupted file.
19
+ - `send_message` and `reply` descriptions say when inline base64 suits and where large files go.
20
+ The server instructions say how to send files, naming only the routes the server offers. When
21
+ a file of about 48 KB or more goes inline as base64 although a better route exists, the result
22
+ adds a `hint` naming it. A new `send_file` prompt sends files by the best route.
23
+ - `FmsgClient` adds `createDraft()`, `uploadAttachmentStream()`, `deleteAttachment()` and
24
+ `sendDraft()`, and exports `AttachmentChecksumError`; `send()` is their composition.
5
25
  - `get_message` text gives the decoded body size next to the stored size for a compressed body
6
26
  ("1002 bytes; 587 stored, sent compressed"), so the stored size is never mistaken for the body's.
7
27
  - `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,37 @@ 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. Decoding is strict: a character
228
+ outside the base64 alphabet, a bad length or padding, or a stray ending is refused with the
229
+ attachment's name, rather than sent as a corrupted file.
230
+ - `data_text`: text, sent as its UTF-8 bytes; no base64 overhead.
231
+ - `path` (stdio with `FMSG_MCP_UPLOAD_DIR`): a file in that folder, streamed by the server. Paths
232
+ are relative to the folder, and a path or symlink that leads outside it is refused.
233
+
234
+ Add `sha256` (hex) to any attachment and the server checks the bytes against it; a mismatch is
235
+ refused before anything is sent. For large files over HTTP, call `create_draft`, PUT the files to
236
+ the returned upload URL with the connection's Authorization header (optionally gzip-compressed and
237
+ with `x-content-sha256`), then `send_draft`; see
238
+ [attachment uploads](docs/http-deployment.md#attachment-uploads). Send files as they are: fmsg
239
+ compresses message bodies and attachments itself when it sends them.
240
+
241
+ Models learn the routes from the server instructions and tool descriptions, which name only the
242
+ routes the running server offers. A model that writes a large file as base64 although a better
243
+ route exists gets a `hint` in the result naming it. The `send_file` prompt walks through the best
244
+ route. The upload URL needs a host that can send the MCP connection's credentials with its own
245
+ HTTP requests: scripts, SDK clients, and coding agents given an fmsg API key. Chat hosts usually
246
+ cannot, so for them `data_text` and `data_base64` remain the routes.
247
+
218
248
  The Web API already transfers attachments as raw bytes. Base64 is used only when embedding binary
219
249
  content in MCP's JSON results, as required by [MCP binary resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources#binary-content).
220
250
  `save_attachment` and authenticated HTTP downloads avoid that encoding and keep file bytes out of
@@ -1,6 +1,13 @@
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
+ }
4
11
  export type FetchLike = typeof fetch;
5
12
  export type FmsgClientOptions = {
6
13
  fetch?: FetchLike;
@@ -35,6 +42,10 @@ export declare class FmsgClient {
35
42
  private startRenewal;
36
43
  /** Release credentials and cancel outstanding work when the client is no longer used. */
37
44
  close(): void;
45
+ /**
46
+ * `timing`: "total" bounds the whole request; "headers" bounds only the wait for response headers (streamed
47
+ * downloads); "caller" leaves deadlines to the caller's signal (streamed uploads with their own idle budget).
48
+ */
38
49
  private request;
39
50
  private json;
40
51
  listInbox(limit?: number, offset?: number, signal?: AbortSignal): Promise<FmsgMessage[]>;
@@ -81,12 +92,26 @@ export declare class FmsgClient {
81
92
  contentType?: string;
82
93
  }>;
83
94
  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>;
95
+ private postDraft;
96
+ /**
97
+ * Create an unsent draft. Selected secret patterns in body/topic are replaced, as for send(); the result
98
+ * reports them and the stored topic. Upload attachments, then sendDraft() or deleteMessage().
99
+ */
100
+ createDraft(input: DraftInput): Promise<DraftResult>;
101
+ uploadAttachment(draftId: string, attachment: OutboundAttachment, signal?: AbortSignal): Promise<Attachment>;
102
+ /**
103
+ * Stream an attachment into a draft without buffering it, hashing the bytes on the way. Each chunk resets an idle
104
+ * budget (the client timeout, default 60 s), so a slow but progressing upload is not cut off. The upload is not
105
+ * retried on 401 because a consumed stream cannot be replayed. When `expectedSha256` is given and differs, the
106
+ * attachment is removed again and AttachmentChecksumError is thrown.
107
+ */
108
+ uploadAttachmentStream(draftId: string, attachment: StreamAttachment, signal?: AbortSignal): Promise<UploadedAttachment>;
109
+ deleteAttachment(draftId: string, filename: string, signal?: AbortSignal): Promise<void>;
110
+ /** Send a draft created with createDraft(). The host validates it; a refusal leaves it a draft. */
111
+ sendDraft(id: string, signal?: AbortSignal): Promise<{
112
+ id: string;
113
+ time: number | null;
114
+ }>;
90
115
  /**
91
116
  * Draft → attach → send. Selected secret patterns in body/topic are replaced;
92
117
  * the result reports their count and the sent topic. Attachments are unchanged.
@@ -5,7 +5,21 @@ 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
+ }
9
23
  function withId(message, id) {
10
24
  return { ...message, id, terminal: message.terminal === true, reaction: message.reaction ?? null, reactions: message.reactions ?? [] };
11
25
  }
@@ -117,14 +131,18 @@ export class FmsgClient {
117
131
  this.provider = undefined;
118
132
  provider?.close?.();
119
133
  }
120
- async request(path, init = {}, retry401 = true, streaming = false) {
134
+ /**
135
+ * `timing`: "total" bounds the whole request; "headers" bounds only the wait for response headers (streamed
136
+ * downloads); "caller" leaves deadlines to the caller's signal (streamed uploads with their own idle budget).
137
+ */
138
+ async request(path, init = {}, retry401 = true, timing = "total") {
121
139
  init.signal?.throwIfAborted();
122
140
  const token = await this.getToken(false, init.signal ?? undefined);
123
141
  const headers = new Headers(init.headers);
124
142
  headers.set("authorization", `Bearer ${token.accessToken}`);
125
143
  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);
144
+ const headerTimer = timing === "headers" ? setTimeout(() => headerDeadline.abort(new DOMException("attachment response headers timed out", "TimeoutError")), this.options.timeoutMs ?? 60_000).unref() : undefined;
145
+ const timeout = timing === "total" ? AbortSignal.timeout(this.options.timeoutMs ?? 60_000) : headerDeadline.signal;
128
146
  const signal = AbortSignal.any([this.lifetime.signal, timeout, ...(init.signal ? [init.signal] : [])]);
129
147
  try {
130
148
  signal.throwIfAborted();
@@ -135,7 +153,7 @@ export class FmsgClient {
135
153
  if (!this.token || this.token === token || this.token.expiresAtMs <= Date.now()) {
136
154
  await this.getToken(true, init.signal ?? undefined);
137
155
  }
138
- return this.request(path, init, false, streaming);
156
+ return this.request(path, init, false, timing);
139
157
  }
140
158
  if (!response.ok) {
141
159
  const { message, code } = await readError(response);
@@ -276,7 +294,7 @@ export class FmsgClient {
276
294
  if (!filename || filename === "." || filename === ".." || /[/\\\u0000]/u.test(filename)) {
277
295
  throw new Error("use an attachment filename without directory components");
278
296
  }
279
- const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/attach/${encodeURIComponent(filename)}`, { signal }, true, true);
297
+ const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/attach/${encodeURIComponent(filename)}`, { signal }, true, "headers");
280
298
  const contentType = response.headers.get("content-type") ?? undefined;
281
299
  if (!response.body)
282
300
  throw new Error("attachment response has no body");
@@ -287,7 +305,7 @@ export class FmsgClient {
287
305
  await this.request(`/fmsg/${encodeURIComponent(mid)}`, { method: "DELETE", signal });
288
306
  }
289
307
  // ── Sending ───────────────────────────────────────────────────────────
290
- async createDraft(input, from) {
308
+ async postDraft(input, from) {
291
309
  const body = {
292
310
  version: 1,
293
311
  from,
@@ -310,11 +328,31 @@ export class FmsgClient {
310
328
  throw new Error("draft response has no id");
311
329
  return normalizeMessageId(result.id);
312
330
  }
331
+ /**
332
+ * Create an unsent draft. Selected secret patterns in body/topic are replaced, as for send(); the result
333
+ * reports them and the stored topic. Upload attachments, then sendDraft() or deleteMessage().
334
+ */
335
+ async createDraft(input) {
336
+ if (input.to.length === 0)
337
+ throw new Error("at least one recipient is required");
338
+ if (input.pid && input.topic)
339
+ throw new Error("a reply (pid) cannot carry a topic");
340
+ const from = await this.address(input.signal);
341
+ const body = redactSecrets(input.body);
342
+ const topic = redactSecrets(input.pid ? "" : (input.topic ?? ""));
343
+ const id = await this.postDraft({ ...input, body: body.text, topic: topic.text }, from);
344
+ return {
345
+ id,
346
+ redactions: body.count + topic.count,
347
+ redacted: [...new Set([...topic.kinds, ...body.kinds])].sort(),
348
+ topic: topic.text,
349
+ };
350
+ }
313
351
  async uploadAttachment(draftId, attachment, signal) {
314
352
  const form = new FormData();
315
353
  const blob = new Blob([Buffer.from(attachment.data)], { type: attachment.contentType ?? "application/octet-stream" });
316
354
  form.append("file", blob, attachment.filename);
317
- const response = await this.request(`/fmsg/${encodeURIComponent(draftId)}/attach`, {
355
+ const response = await this.request(`/fmsg/${encodeURIComponent(normalizeMessageId(draftId))}/attach`, {
318
356
  method: "POST",
319
357
  body: form,
320
358
  ...(signal ? { signal } : {}),
@@ -322,41 +360,125 @@ export class FmsgClient {
322
360
  const result = parseFmsgJson(await response.text());
323
361
  return { filename: result.filename ?? attachment.filename, size: result.size ?? attachment.data.byteLength };
324
362
  }
363
+ /**
364
+ * Stream an attachment into a draft without buffering it, hashing the bytes on the way. Each chunk resets an idle
365
+ * budget (the client timeout, default 60 s), so a slow but progressing upload is not cut off. The upload is not
366
+ * retried on 401 because a consumed stream cannot be replayed. When `expectedSha256` is given and differs, the
367
+ * attachment is removed again and AttachmentChecksumError is thrown.
368
+ */
369
+ async uploadAttachmentStream(draftId, attachment, signal) {
370
+ const mid = normalizeMessageId(draftId);
371
+ if (!/^[A-Za-z0-9._-]+$/u.test(attachment.filename) || /^\.+$/u.test(attachment.filename)) {
372
+ throw new Error("attachment filenames may contain only letters, digits, dot, underscore and hyphen");
373
+ }
374
+ const contentType = attachment.contentType ?? "application/octet-stream";
375
+ if (!/^[\x20-\x7e]+$/u.test(contentType))
376
+ throw new Error("attachment content type must be printable ASCII");
377
+ const expected = attachment.expectedSha256?.toLowerCase();
378
+ const boundary = `fmsg-mcp-${randomUUID()}`;
379
+ const encoder = new TextEncoder();
380
+ const head = encoder.encode(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${attachment.filename}"\r\n` +
381
+ `Content-Type: ${contentType}\r\n\r\n`);
382
+ const tail = encoder.encode(`\r\n--${boundary}--\r\n`);
383
+ const idleMs = this.options.timeoutMs ?? 60_000;
384
+ const deadline = new AbortController();
385
+ let timer;
386
+ const arm = (reason) => {
387
+ clearTimeout(timer);
388
+ timer = setTimeout(() => deadline.abort(new DOMException(reason, "TimeoutError")), idleMs).unref();
389
+ };
390
+ const hash = createHash("sha256");
391
+ const reader = attachment.stream.getReader();
392
+ let stage = "head";
393
+ let size = 0;
394
+ let sourceError;
395
+ const body = new ReadableStream({
396
+ async pull(controller) {
397
+ if (stage === "head") {
398
+ stage = "data";
399
+ controller.enqueue(head);
400
+ return;
401
+ }
402
+ arm("attachment upload stalled waiting for data");
403
+ let chunk;
404
+ try {
405
+ chunk = await reader.read();
406
+ }
407
+ catch (error) {
408
+ sourceError = error;
409
+ throw error;
410
+ }
411
+ if (!chunk.done) {
412
+ hash.update(chunk.value);
413
+ size += chunk.value.byteLength;
414
+ controller.enqueue(chunk.value);
415
+ return;
416
+ }
417
+ stage = "done";
418
+ controller.enqueue(tail);
419
+ controller.close();
420
+ arm("attachment upload response timed out");
421
+ },
422
+ cancel: (reason) => reader.cancel(reason),
423
+ });
424
+ arm("attachment upload stalled waiting for data");
425
+ try {
426
+ const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/attach`, {
427
+ method: "POST",
428
+ headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
429
+ body,
430
+ duplex: "half",
431
+ signal: AbortSignal.any([deadline.signal, ...(signal ? [signal] : [])]),
432
+ }, false, "caller");
433
+ const result = parseFmsgJson(await response.text());
434
+ const uploaded = { filename: result.filename ?? attachment.filename, size: result.size ?? size, sha256: hash.digest("hex") };
435
+ if (expected && expected !== uploaded.sha256) {
436
+ await this.deleteAttachment(mid, uploaded.filename).catch(() => undefined);
437
+ throw new AttachmentChecksumError(attachment.filename, expected, uploaded.sha256);
438
+ }
439
+ return uploaded;
440
+ }
441
+ catch (error) {
442
+ // Report the source's failure (such as invalid compressed data) rather than fetch's wrapper.
443
+ throw sourceError ?? error;
444
+ }
445
+ finally {
446
+ clearTimeout(timer);
447
+ if (stage !== "done")
448
+ await reader.cancel().catch(() => undefined);
449
+ }
450
+ }
451
+ async deleteAttachment(draftId, filename, signal) {
452
+ const mid = normalizeMessageId(draftId);
453
+ await this.request(`/fmsg/${encodeURIComponent(mid)}/attach/${encodeURIComponent(filename)}`, { method: "DELETE", ...(signal ? { signal } : {}) });
454
+ }
455
+ /** Send a draft created with createDraft(). The host validates it; a refusal leaves it a draft. */
456
+ async sendDraft(id, signal) {
457
+ const mid = normalizeMessageId(id);
458
+ const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/send`, {
459
+ method: "POST",
460
+ ...(signal ? { signal } : {}),
461
+ });
462
+ const result = parseFmsgJson(await response.text());
463
+ return { id: result.id === undefined || result.id === null ? mid : normalizeMessageId(result.id), time: result.time ?? null };
464
+ }
325
465
  /**
326
466
  * Draft → attach → send. Selected secret patterns in body/topic are replaced;
327
467
  * the result reports their count and the sent topic. Attachments are unchanged.
328
468
  * The draft is deleted if any step after creation fails.
329
469
  */
330
470
  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);
471
+ const draft = await this.createDraft(input);
339
472
  try {
340
473
  const attachments = [];
341
474
  for (const attachment of input.attachments ?? []) {
342
- attachments.push(await this.uploadAttachment(draftId, attachment, input.signal));
475
+ attachments.push(await this.uploadAttachment(draft.id, attachment, input.signal));
343
476
  }
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
- };
477
+ const sent = await this.sendDraft(draft.id, input.signal);
478
+ return { ...sent, attachments, redactions: draft.redactions, redacted: draft.redacted, topic: draft.topic };
357
479
  }
358
480
  catch (error) {
359
- await this.deleteMessage(draftId).catch(() => undefined);
481
+ await this.deleteMessage(draft.id).catch(() => undefined);
360
482
  throw error;
361
483
  }
362
484
  }
@@ -1,4 +1,4 @@
1
- export { FmsgClient, FmsgHttpError, type FmsgClientOptions } from "./client.js";
1
+ export { AttachmentChecksumError, FmsgClient, FmsgHttpError, 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 } 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. */