@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 +15 -0
- package/README.md +4 -3
- package/dist/client/client.d.ts +6 -0
- package/dist/client/client.js +76 -43
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +1 -1
- package/dist/http.js +49 -15
- package/dist/oauth/errors.d.ts +1 -0
- package/dist/oauth/errors.js +1 -0
- package/dist/tools/attachments.d.ts +3 -2
- package/dist/tools/attachments.js +12 -4
- package/dist/tools/read.js +6 -2
- package/dist/upload.d.ts +10 -0
- package/dist/upload.js +39 -1
- package/docs/http-deployment.md +2 -1
- package/package.json +1 -1
- package/server.json +2 -2
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.
|
|
228
|
-
|
|
229
|
-
attachment's name, rather than sent as a
|
|
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.
|
package/dist/client/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/client/client.js
CHANGED
|
@@ -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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/client/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/client/index.js
CHANGED
|
@@ -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
|
|
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
|
|
245
|
+
return uploadRefusal("use PUT to upload attachments", 405, { allow: "PUT, OPTIONS" });
|
|
230
246
|
if (url.search)
|
|
231
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
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;
|
package/dist/oauth/errors.d.ts
CHANGED
|
@@ -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;
|
package/dist/oauth/errors.js
CHANGED
|
@@ -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:
|
|
46
|
-
* no stray trailing bits. Node's decoder skips anything else silently, which would send a
|
|
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:
|
|
51
|
-
* no stray trailing bits. Node's decoder skips anything else silently, which would send a
|
|
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
|
|
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
|
|
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) {
|
package/dist/tools/read.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|
package/docs/http-deployment.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
17
|
+
"version": "1.1.1",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
},
|