@markmnl/fmsg-mcp 1.1.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,21 @@
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.
5
20
  - Large attachments no longer have to pass through the model as base64 ([#17](https://github.com/markmnl/fmsg-mcp/issues/17)).
6
21
  New `create_draft`, `send_draft` and `delete_draft` tools create a real fmsg draft that other fmsg
7
22
  clients can see. Over HTTP with a public MCP URL, `create_draft` returns an upload URL: the host
package/README.md CHANGED
@@ -224,9 +224,10 @@ original Content-Type and download filename. No files are stored on the MCP serv
224
224
 
225
225
  `send_message`, `reply` and `create_draft` take each attachment as exactly one of:
226
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.
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.
230
231
  - `data_text`: text, sent as its UTF-8 bytes; no base64 overhead.
231
232
  - `path` (stdio with `FMSG_MCP_UPLOAD_DIR`): a file in that folder, streamed by the server. Paths
232
233
  are relative to the folder, and a path or symlink that leads outside it is refused.
@@ -8,6 +8,12 @@ export declare class AttachmentChecksumError extends Error {
8
8
  readonly actual: string;
9
9
  constructor(filename: string, expected: string, actual: string);
10
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
+ }
11
17
  export type FetchLike = typeof fetch;
12
18
  export type FmsgClientOptions = {
13
19
  fetch?: FetchLike;
@@ -20,6 +20,21 @@ export class AttachmentChecksumError extends Error {
20
20
  this.name = "AttachmentChecksumError";
21
21
  }
22
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
+ }
23
38
  function withId(message, id) {
24
39
  return { ...message, id, terminal: message.terminal === true, reaction: message.reaction ?? null, reactions: message.reactions ?? [] };
25
40
  }
@@ -391,56 +406,74 @@ export class FmsgClient {
391
406
  const reader = attachment.stream.getReader();
392
407
  let stage = "head";
393
408
  let size = 0;
409
+ let pulled = false;
394
410
  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;
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++) {
404
447
  try {
405
- chunk = await reader.read();
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;
406
462
  }
407
463
  catch (error) {
408
- sourceError = 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);
409
474
  throw error;
410
475
  }
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
476
  }
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
477
  }
445
478
  finally {
446
479
  clearTimeout(timer);
@@ -1,4 +1,4 @@
1
- export { AttachmentChecksumError, 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 { AttachmentChecksumError, 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";
package/dist/http.js CHANGED
@@ -4,12 +4,13 @@ import { createMcpHandler, hostHeaderValidationResponse, localhostAllowedHostnam
4
4
  import { ApiKeyCallerProvider, FMSG_SCOPE } from "./auth.js";
5
5
  import { createFmsgMcpServer } from "./server.js";
6
6
  import { OAuthCallerProvider } from "./oauth/provider.js";
7
- import { invalidToken, insufficientScope, OAuthRequestError, oauthErrorResponse, oauthRequestState } from "./oauth/errors.js";
7
+ import { invalidToken, insufficientScope, missingToken, OAuthRequestError, oauthErrorResponse, oauthRequestState } from "./oauth/errors.js";
8
8
  import { MESSAGING_SCOPES, READ_SCOPE, WRITE_SCOPE, requestScopes } from "./oauth/scopes.js";
9
9
  import { DOWNLOAD_PATH, downloadBaseUrl, downloadHeaders, parseDownloadPath } from "./download.js";
10
- import { CHECKSUM_HEADER, UPLOAD_PATH, UploadBodyError, decodedUploadBody, parseUploadPath, uploadEncoding } from "./upload.js";
11
- import { AttachmentChecksumError } from "./client/client.js";
10
+ import { CHECKSUM_HEADER, UPLOAD_DRAIN_IDLE_MS, UPLOAD_PATH, UploadBodyError, decodedUploadBody, drainableBody, parseUploadPath, uploadEncoding } from "./upload.js";
11
+ import { AttachmentChecksumError, UploadInterruptedError } from "./client/client.js";
12
12
  import { FmsgHttpError } from "./client/errors.js";
13
+ import { sameAddress } from "./address.js";
13
14
  import { attachmentType } from "./render.js";
14
15
  import { describeError } from "./errors.js";
15
16
  import { VERSION } from "./version.js";
@@ -104,6 +105,18 @@ export async function sendWebResponse(res, response, failure) {
104
105
  res.end();
105
106
  }
106
107
  }
108
+ /**
109
+ * After the host cut off an upload, the reason it gave is lost; say why when the draft shows it. A failed lookup
110
+ * throws the host's own error, such as a draft the caller cannot see.
111
+ */
112
+ async function draftRefusal(caller, id, signal) {
113
+ const draft = await caller.client.getMessage(id, signal);
114
+ if (draft.time !== null && draft.time !== undefined)
115
+ return `message ${id} was already sent; attachments can be added only to drafts`;
116
+ if (!sameAddress(draft.from, caller.address))
117
+ return `message ${id} is not your draft; only its sender can add attachments`;
118
+ return undefined;
119
+ }
107
120
  export function createHttpServer(config, log = (l) => console.error(l)) {
108
121
  const allowedHosts = config.http.allowedHosts.length
109
122
  ? config.http.allowedHosts
@@ -144,7 +157,8 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
144
157
  const url = new URL(req.url ?? "/", "http://localhost");
145
158
  const isUpload = url.pathname.startsWith(`${UPLOAD_PATH}/`);
146
159
  // A progressing upload may take longer than any fixed budget; the upload stream has an idle timeout instead.
147
- // Closing the connection afterwards stops Node from reading the rest of a refused upload's body.
160
+ // Closing the connection afterwards stops Node reading the rest of an upload refused before authentication;
161
+ // an authenticated caller's failed upload is drained first so its answer arrives (see serveUpload).
148
162
  const bodyTimer = isUpload ? undefined : setTimeout(() => { if (!req.complete)
149
163
  req.destroy(); }, REQUEST_BODY_TIMEOUT_MS).unref();
150
164
  if (isUpload)
@@ -225,32 +239,34 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
225
239
  }
226
240
  let upload;
227
241
  if (isUpload) {
242
+ // Refusals are JSON, like the errors from the upload itself.
243
+ const uploadRefusal = (error, status, headers = {}) => sendWebResponse(res, Response.json({ error }, { status, headers }));
228
244
  if (req.method !== "PUT")
229
- return sendWebResponse(res, new Response("use PUT to upload attachments", { status: 405, headers: { allow: "PUT, OPTIONS" } }));
245
+ return uploadRefusal("use PUT to upload attachments", 405, { allow: "PUT, OPTIONS" });
230
246
  if (url.search)
231
- return sendWebResponse(res, new Response("attachment URLs must not contain query parameters", { status: 400 }));
247
+ return uploadRefusal("attachment URLs must not contain query parameters", 400);
232
248
  try {
233
249
  upload = { ...parseUploadPath(url.pathname), contentType: "" };
234
250
  }
235
251
  catch {
236
- return sendWebResponse(res, new Response("invalid attachment upload path; use /mcp/drafts/{id}/attachments/{filename}", { status: 400 }));
252
+ return uploadRefusal("invalid attachment upload path; use /mcp/drafts/{id}/attachments/{filename}", 400);
237
253
  }
238
254
  try {
239
255
  upload.encoding = uploadEncoding(request.headers.get("content-encoding"));
240
256
  }
241
257
  catch (error) {
242
- return sendWebResponse(res, new Response(error.message, { status: 415 }));
258
+ return uploadRefusal(error.message, 415);
243
259
  }
244
260
  const sha256 = request.headers.get(CHECKSUM_HEADER)?.trim();
245
261
  if (sha256 !== undefined) {
246
262
  if (!/^[0-9A-Fa-f]{64}$/u.test(sha256))
247
- return sendWebResponse(res, new Response(`${CHECKSUM_HEADER} must be 64 hexadecimal characters`, { status: 400 }));
263
+ return uploadRefusal(`${CHECKSUM_HEADER} must be 64 hexadecimal characters`, 400);
248
264
  upload.sha256 = sha256.toLowerCase();
249
265
  }
250
266
  // curl and similar tools label raw bodies as form data by default; that says nothing about the file.
251
267
  const declared = request.headers.get("content-type")?.trim();
252
268
  if (declared && !/^[\x20-\x7e]+$/u.test(declared))
253
- return sendWebResponse(res, new Response("Content-Type must be printable ASCII", { status: 400 }));
269
+ return uploadRefusal("Content-Type must be printable ASCII", 400);
254
270
  upload.contentType = declared && !/^application\/x-www-form-urlencoded\b/iu.test(declared)
255
271
  ? attachmentType(upload.filename, declared) : attachmentType(upload.filename);
256
272
  }
@@ -267,10 +283,28 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
267
283
  return sendWebResponse(res, response);
268
284
  });
269
285
  const serveUpload = (provider, auth) => serveTransfer(provider, auth, WRITE_SCOPE, async (caller) => {
270
- const body = decodedUploadBody(request.body ?? new ReadableStream({ start: (c) => c.close() }), upload.encoding);
271
- const uploaded = await caller.client.uploadAttachmentStream(upload.id, {
272
- filename: upload.filename, stream: body, contentType: upload.contentType, ...(upload.sha256 ? { expectedSha256: upload.sha256 } : {}),
273
- }, controller.signal);
286
+ const { stream, drain } = drainableBody(request.body ?? new ReadableStream({ start: (c) => c.close() }), UPLOAD_DRAIN_IDLE_MS);
287
+ const body = decodedUploadBody(stream, upload.encoding);
288
+ let uploaded;
289
+ try {
290
+ uploaded = await caller.client.uploadAttachmentStream(upload.id, {
291
+ filename: upload.filename, stream: body, contentType: upload.contentType, ...(upload.sha256 ? { expectedSha256: upload.sha256 } : {}),
292
+ }, controller.signal);
293
+ }
294
+ catch (error) {
295
+ // Answering while the client is still sending would close the connection with unread data, resetting it
296
+ // and losing the answer; the caller is authenticated and could send these bytes to its own draft anyway.
297
+ if (!(await drain())) {
298
+ res.destroy();
299
+ return;
300
+ }
301
+ if (!(error instanceof UploadInterruptedError))
302
+ throw error;
303
+ const refusal = await draftRefusal(caller, upload.id, controller.signal).catch((lookup) => { throw lookup instanceof FmsgHttpError ? lookup : error; });
304
+ if (refusal === undefined)
305
+ throw error;
306
+ return sendWebResponse(res, Response.json({ error: refusal }, { status: 403 }));
307
+ }
274
308
  return sendWebResponse(res, Response.json({ id: upload.id, ...uploaded, type: upload.contentType }, { status: 201 }));
275
309
  });
276
310
  const serveTransfer = async (provider, auth, scope, transfer) => {
@@ -312,7 +346,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
312
346
  try {
313
347
  const bearer = /^Bearer ([A-Za-z0-9._~+\/-]+=*)$/iu.exec(authorization);
314
348
  if (!bearer)
315
- throw invalidToken();
349
+ throw authorization ? invalidToken() : missingToken();
316
350
  const authenticated = await oauthProvider.verifyAccessToken(bearer[1]);
317
351
  release = () => oauthProvider.release(authenticated);
318
352
  let parsedBody;
@@ -14,5 +14,6 @@ export declare class OAuthRequestError extends Error {
14
14
  constructor(status: 401 | 403 | 500 | 503, code: string, message: string, scopes?: string[]);
15
15
  }
16
16
  export declare const invalidToken: () => OAuthRequestError;
17
+ export declare const missingToken: () => OAuthRequestError;
17
18
  export declare const insufficientScope: (scopes: string[]) => OAuthRequestError;
18
19
  export declare function oauthErrorResponse(error: OAuthRequestError, metadataUrl: string): Response;
@@ -27,6 +27,7 @@ export class OAuthRequestError extends Error {
27
27
  }
28
28
  }
29
29
  export const invalidToken = () => new OAuthRequestError(401, "invalid_token", "The OAuth connection must be refreshed or reconnected.");
30
+ export const missingToken = () => new OAuthRequestError(401, "invalid_token", "No access token was sent; send this MCP connection's Authorization header.");
30
31
  export const insufficientScope = (scopes) => new OAuthRequestError(403, "insufficient_scope", "The OAuth connection does not authorize this operation.", scopes);
31
32
  export function oauthErrorResponse(error, metadataUrl) {
32
33
  const headers = new Headers({ "cache-control": "no-store" });
@@ -42,8 +42,9 @@ export type PreparedAttachment = {
42
42
  file: string;
43
43
  });
44
44
  /**
45
- * Decode base64 strictly: only the standard alphabet and whitespace, `=` padding only at the end, a valid length, and
46
- * no stray trailing bits. Node's decoder skips anything else silently, which would send a corrupted file.
45
+ * Decode base64 strictly: one alphabet (standard, or URL-safe with - and _) plus whitespace, `=` padding only at the
46
+ * end, a valid length, and no stray trailing bits. Node's decoder skips anything else silently, which would send a
47
+ * corrupted file.
47
48
  */
48
49
  export declare function decodeBase64Strict(filename: string, input: string): Buffer;
49
50
  /** Validate every attachment before anything is created upstream, so a bad one never leaves a draft behind. */
@@ -47,14 +47,22 @@ export function attachmentInput(config) {
47
47
  });
48
48
  }
49
49
  /**
50
- * Decode base64 strictly: only the standard alphabet and whitespace, `=` padding only at the end, a valid length, and
51
- * no stray trailing bits. Node's decoder skips anything else silently, which would send a corrupted file.
50
+ * Decode base64 strictly: one alphabet (standard, or URL-safe with - and _) plus whitespace, `=` padding only at the
51
+ * end, a valid length, and no stray trailing bits. Node's decoder skips anything else silently, which would send a
52
+ * corrupted file.
52
53
  */
53
54
  export function decodeBase64Strict(filename, input) {
54
- const bad = /[^A-Za-z0-9+/=\s]/u.exec(input);
55
+ const bad = /[^A-Za-z0-9+/\-_=\s]/u.exec(input);
55
56
  if (bad)
56
57
  throw new Error(`attachment ${filename}: invalid base64 character ${JSON.stringify(bad[0])} at position ${bad.index}`);
57
- const compact = input.replace(/\s+/gu, "");
58
+ const standard = /[+/]/u.exec(input);
59
+ const urlSafe = /[-_]/u.exec(input);
60
+ if (standard && urlSafe) {
61
+ const [first, second] = standard.index < urlSafe.index ? [standard, urlSafe] : [urlSafe, standard];
62
+ throw new Error(`attachment ${filename}: base64 mixes the standard and URL-safe alphabets (${JSON.stringify(first[0])} at position ${first.index}, ` +
63
+ `${JSON.stringify(second[0])} at position ${second.index}); part of the data may be corrupt`);
64
+ }
65
+ const compact = input.replace(/\s+/gu, "").replace(/-/gu, "+").replace(/_/gu, "/");
58
66
  const padding = compact.indexOf("=");
59
67
  const unpadded = padding < 0 ? compact : compact.slice(0, padding);
60
68
  if ((padding >= 0 && (!/^={1,2}$/u.test(compact.slice(padding)) || compact.length % 4 !== 0)) || unpadded.length % 4 === 1) {
@@ -202,6 +202,7 @@ export const registerReadTools = (server, deps) => {
202
202
  size: z.number(),
203
203
  content_type: z.string().describe("media type; the same value as type"),
204
204
  type: z.string().optional().describe("media type, named as on message attachment lists"),
205
+ text: z.string().optional().describe("the file's contents, for text attachments"),
205
206
  ...untrustedNotice,
206
207
  }),
207
208
  annotations: READ_ONLY,
@@ -223,8 +224,11 @@ export const registerReadTools = (server, deps) => {
223
224
  const type = contentType ?? "application/octet-stream";
224
225
  const base = { id, filename, size: data.byteLength, content_type: type, type, ...UNTRUSTED };
225
226
  const metadata = `${filename} (${data.byteLength} bytes, ${type}) from message ${id}`;
226
- if (type.toLowerCase().startsWith("text/"))
227
- return ok(messageData(`${metadata}\n\n${Buffer.from(data).toString("utf8")}`), base);
227
+ if (type.toLowerCase().startsWith("text/")) {
228
+ // Some hosts show models only structured content, so the text is there too.
229
+ const text = Buffer.from(data).toString("utf8");
230
+ return ok(messageData(`${metadata}\n\n${text}`), { ...base, text });
231
+ }
228
232
  const b64 = Buffer.from(data).toString("base64");
229
233
  const uri = `fmsg://message/${id}/attachment/${encodeURIComponent(filename)}`;
230
234
  const result = {
package/dist/upload.d.ts CHANGED
@@ -12,6 +12,16 @@ export declare function parseUploadPath(pathname: string): {
12
12
  export declare class UploadBodyError extends Error {
13
13
  constructor(message: string);
14
14
  }
15
+ /** How long draining a failed upload's remaining body may wait for more data before the connection is closed. */
16
+ export declare const UPLOAD_DRAIN_IDLE_MS = 10000;
17
+ /**
18
+ * The upload body as a stream whose cancellation leaves the request open, plus `drain()`, which discards what the
19
+ * upload did not read so the answer can follow. drain() resolves false if the client goes idle first.
20
+ */
21
+ export declare function drainableBody(body: ReadableStream<Uint8Array>, idleMs: number): {
22
+ stream: ReadableStream<Uint8Array>;
23
+ drain: () => Promise<boolean>;
24
+ };
15
25
  /** Decode a Content-Encoding while streaming, reporting corrupt data as UploadBodyError. */
16
26
  export declare function decodedUploadBody(body: ReadableStream<Uint8Array>, encoding: string | undefined): ReadableStream<Uint8Array>;
17
27
  /** The upload's Content-Encoding, lowercased; "identity" or none means undecoded. Throws for an unsupported one. */
package/dist/upload.js CHANGED
@@ -24,6 +24,42 @@ export class UploadBodyError extends Error {
24
24
  this.name = "UploadBodyError";
25
25
  }
26
26
  }
27
+ /** How long draining a failed upload's remaining body may wait for more data before the connection is closed. */
28
+ export const UPLOAD_DRAIN_IDLE_MS = 10_000;
29
+ /**
30
+ * The upload body as a stream whose cancellation leaves the request open, plus `drain()`, which discards what the
31
+ * upload did not read so the answer can follow. drain() resolves false if the client goes idle first.
32
+ */
33
+ export function drainableBody(body, idleMs) {
34
+ const reader = body.getReader();
35
+ let stopped = false;
36
+ const stream = new ReadableStream({
37
+ async pull(controller) {
38
+ const chunk = await reader.read();
39
+ if (stopped)
40
+ return;
41
+ if (chunk.done)
42
+ controller.close();
43
+ else
44
+ controller.enqueue(chunk.value);
45
+ },
46
+ cancel: () => { stopped = true; },
47
+ });
48
+ const drain = async () => {
49
+ stopped = true;
50
+ for (;;) {
51
+ let timer;
52
+ const idle = new Promise((resolve) => { timer = setTimeout(() => resolve("idle"), idleMs); });
53
+ const next = await Promise.race([reader.read().catch(() => ({ done: true })), idle]);
54
+ clearTimeout(timer);
55
+ if (next === "idle")
56
+ return false;
57
+ if (next.done)
58
+ return true;
59
+ }
60
+ };
61
+ return { stream, drain };
62
+ }
27
63
  /** Decode a Content-Encoding while streaming, reporting corrupt data as UploadBodyError. */
28
64
  export function decodedUploadBody(body, encoding) {
29
65
  if (!encoding)
@@ -36,7 +72,9 @@ export function decodedUploadBody(body, encoding) {
36
72
  chunk = await reader.read();
37
73
  }
38
74
  catch (error) {
39
- throw new UploadBodyError(`the ${encoding} request body is invalid or truncated: ${error instanceof Error ? error.message : String(error)}`);
75
+ const code = error?.code;
76
+ const detail = code === "Z_BUF_ERROR" ? "the data ends early" : error instanceof Error ? error.message || code : String(error);
77
+ throw new UploadBodyError(`the ${encoding} request body is invalid or truncated${detail ? ` (${detail})` : ""}`);
40
78
  }
41
79
  if (chunk.done)
42
80
  controller.close();
@@ -110,7 +110,8 @@ The server streams the body straight into the fmsg draft (`POST /fmsg/:id/attach
110
110
  storing it, so the draft and its attachments are visible in the user's other fmsg clients and the
111
111
  fmsg host's own size limits apply; its refusal comes back with its status and text. OAuth requires
112
112
  `fmsg:write`. The response is `201` with `filename`, `size`, `sha256` and `type`. The fmsg host may
113
- rename a duplicate filename; the response gives the stored name.
113
+ rename a duplicate filename; the response gives the stored name. Errors are JSON `{"error": "..."}`,
114
+ and the fmsg host's own reason is passed through when it gives one.
114
115
 
115
116
  - `x-content-sha256` (optional, hex): a mismatch answers `422`, and the attachment is removed again.
116
117
  - `Content-Encoding: gzip` or `deflate` (optional): the server decodes the body while streaming, so
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@markmnl/fmsg-mcp",
3
3
  "mcpName": "io.github.markmnl/fmsg-mcp",
4
- "version": "1.1.0",
4
+ "version": "1.1.1",
5
5
  "description": "MCP server for fmsg: send and receive federated messages from any AI agent via a deployed fmsg Web API",
6
6
  "type": "module",
7
7
  "license": "MIT",
package/server.json CHANGED
@@ -8,13 +8,13 @@
8
8
  "url": "https://github.com/markmnl/fmsg-mcp",
9
9
  "source": "github"
10
10
  },
11
- "version": "1.1.0",
11
+ "version": "1.1.1",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "registryBaseUrl": "https://registry.npmjs.org",
16
16
  "identifier": "@markmnl/fmsg-mcp",
17
- "version": "1.1.0",
17
+ "version": "1.1.1",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },