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