@markmnl/fmsg-mcp 0.2.9 → 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 +25 -0
- package/README.md +34 -4
- package/dist/client/client.d.ts +32 -7
- package/dist/client/client.js +152 -30
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/types.d.ts +21 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +81 -21
- package/dist/instructions.d.ts +3 -0
- package/dist/instructions.js +11 -2
- package/dist/oauth/scopes.js +1 -0
- package/dist/prompts.d.ts +2 -1
- package/dist/prompts.js +47 -1
- package/dist/render.d.ts +7 -4
- package/dist/render.js +17 -8
- package/dist/server.js +5 -1
- package/dist/tools/attachments.d.ts +65 -0
- package/dist/tools/attachments.js +157 -0
- package/dist/tools/draft.d.ts +2 -0
- package/dist/tools/draft.js +151 -0
- package/dist/tools/identity.js +1 -1
- package/dist/tools/read.js +3 -3
- package/dist/tools/send.d.ts +85 -1
- package/dist/tools/send.js +69 -60
- package/dist/upload.d.ts +18 -0
- package/dist/upload.js +57 -0
- package/docs/http-deployment.md +35 -3
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
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.
|
|
25
|
+
- `get_message` text gives the decoded body size next to the stored size for a compressed body
|
|
26
|
+
("1002 bytes; 587 stored, sent compressed"), so the stored size is never mistaken for the body's.
|
|
27
|
+
- `list_messages` text marks messages you sent to yourself as unread when they are.
|
|
28
|
+
- `mark_read` describes `ids`; `resolve_address` and `download_attachment` descriptions are shorter.
|
|
29
|
+
|
|
5
30
|
- Tools reject unknown arguments instead of ignoring them, so a misspelled parameter (`unread` for
|
|
6
31
|
`unread_only`) fails with the name rather than returning a plausible wrong answer.
|
|
7
32
|
- Server instructions define fmsg's terms (topics on the first message only, reactions, terminal,
|
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:
|
|
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.
|
|
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
|
package/dist/client/client.d.ts
CHANGED
|
@@ -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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
package/dist/client/client.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
127
|
-
const timeout =
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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(
|
|
475
|
+
attachments.push(await this.uploadAttachment(draft.id, attachment, input.signal));
|
|
343
476
|
}
|
|
344
|
-
const
|
|
345
|
-
|
|
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(
|
|
481
|
+
await this.deleteMessage(draft.id).catch(() => undefined);
|
|
360
482
|
throw error;
|
|
361
483
|
}
|
|
362
484
|
}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/client/index.js
CHANGED
|
@@ -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";
|
package/dist/client/types.d.ts
CHANGED
|
@@ -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. */
|