@markmnl/fmsg-mcp 1.1.0 → 1.2.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 +21 -0
- package/README.md +10 -4
- package/dist/auth.js +6 -2
- package/dist/client/client.d.ts +8 -0
- package/dist/client/client.js +86 -44
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/token-provider.d.ts +2 -0
- package/dist/client/token-provider.js +8 -1
- package/dist/client/types.d.ts +5 -0
- package/dist/context.d.ts +2 -0
- package/dist/context.js +2 -1
- package/dist/http.js +54 -16
- package/dist/index.js +5 -4
- package/dist/instructions.d.ts +2 -0
- package/dist/instructions.js +8 -5
- package/dist/oauth/errors.d.ts +1 -0
- package/dist/oauth/errors.js +1 -0
- package/dist/owner.d.ts +15 -0
- package/dist/owner.js +27 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +6 -1
- package/dist/tools/attachments.d.ts +3 -2
- package/dist/tools/attachments.js +12 -4
- package/dist/tools/common.d.ts +2 -0
- package/dist/tools/draft.js +5 -1
- package/dist/tools/identity.js +6 -2
- package/dist/tools/read.js +6 -2
- package/dist/tools/send.js +10 -4
- package/dist/upload.d.ts +10 -0
- package/dist/upload.js +39 -1
- package/docs/http-deployment.md +2 -1
- package/docs/token-providers.md +3 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- A sub-address (such as `@user_agent@example.com`, owned by `@user@example.com`) now sends to
|
|
6
|
+
its owner by default: `send_message`, `reply` and `create_draft` add the owner as a recipient so
|
|
7
|
+
they can follow and reply to the thread, with a new `include_owner` argument (default true) to
|
|
8
|
+
leave them off. The owner comes from the API-key token's `owner` claim; the instructions and
|
|
9
|
+
`whoami` (new `owner` field) name it, and `include_owner` is only offered when the caller may be a
|
|
10
|
+
sub-address. `FmsgClient.owner()` and the token provider's optional `owner` field expose it.
|
|
11
|
+
- An upload the fmsg host refused part-way, such as a large file PUT to someone else's draft or a
|
|
12
|
+
sent message, no longer hangs until the proxy times out. The server reads the rest of the body
|
|
13
|
+
and answers with the host's reason; if the host closed the connection before giving one, the
|
|
14
|
+
draft is checked to explain it. An upload whose pooled connection had already closed is
|
|
15
|
+
repeated when none of the file had been read.
|
|
16
|
+
- Upload URL refusals made before the upload starts (wrong method, bad path, unsupported
|
|
17
|
+
encoding and so on) are JSON `{"error": ...}`, like every other upload error. A truncated gzip
|
|
18
|
+
or deflate body now says the data ends early.
|
|
19
|
+
- `data_base64` also accepts the URL-safe alphabet (`-` and `_`), which strict decoding refused
|
|
20
|
+
in 1.1.0; a value that mixes both alphabets is still refused.
|
|
21
|
+
- `download_attachment` returns a text attachment's contents in its structured result (`text`)
|
|
22
|
+
too. Hosts such as Claude Code show models only the structured result, so the file never
|
|
23
|
+
reached the model.
|
|
24
|
+
- Over OAuth, a request with no access token gets a 401 that says so, instead of asking for the
|
|
25
|
+
connection to be refreshed.
|
|
5
26
|
- Large attachments no longer have to pass through the model as base64 ([#17](https://github.com/markmnl/fmsg-mcp/issues/17)).
|
|
6
27
|
New `create_draft`, `send_draft` and `delete_draft` tools create a real fmsg draft that other fmsg
|
|
7
28
|
clients can see. Over HTTP with a public MCP URL, `create_draft` returns an upload URL: the host
|
package/README.md
CHANGED
|
@@ -40,6 +40,11 @@ Use the API URL and key for local installation below, or use your provider's API
|
|
|
40
40
|
with an `Authorization: Bearer fmsgk_...` header. The endpoint must use the fmsg Web API that accepts
|
|
41
41
|
your key.
|
|
42
42
|
|
|
43
|
+
An agent's key is usually for a **sub-address** such as `@user_agent@example.com`, owned by
|
|
44
|
+
`@user@example.com`. The owner can switch to the sub-address in their client, but can only follow
|
|
45
|
+
and reply to threads they are a participant of. So `send_message`, `reply` and `create_draft` add the
|
|
46
|
+
owner as a recipient unless `include_owner` is false; `whoami` reports the owner.
|
|
47
|
+
|
|
43
48
|
### MCP Registry
|
|
44
49
|
|
|
45
50
|
Find `io.github.markmnl/fmsg-mcp` in the [official MCP Registry](https://registry.modelcontextprotocol.io/?q=io.github.markmnl%2Ffmsg-mcp).
|
|
@@ -128,7 +133,7 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
|
|
|
128
133
|
|
|
129
134
|
| Tool | What it does |
|
|
130
135
|
|---|---|
|
|
131
|
-
| `whoami` | The address this server acts as, the public API URL (when known), the transport (`stdio` or `http`, which is Streamable HTTP) and short-name defaults |
|
|
136
|
+
| `whoami` | The address this server acts as, its `owner` if it is a sub-address, the public API URL (when known), the transport (`stdio` or `http`, which is Streamable HTTP) and short-name defaults |
|
|
132
137
|
| `resolve_address` | Turn a short name into `@user@domain` (directory, then default domain) |
|
|
133
138
|
| `list_messages` | Inbox, newest first, with previews, attachment types and `size` (`compressed` marks a body sent deflated); reactions hidden and never counted as unread unless `include_reactions` |
|
|
134
139
|
| `list_sent` | Sent messages with per-recipient delivery state |
|
|
@@ -224,9 +229,10 @@ original Content-Type and download filename. No files are stored on the MCP serv
|
|
|
224
229
|
|
|
225
230
|
`send_message`, `reply` and `create_draft` take each attachment as exactly one of:
|
|
226
231
|
|
|
227
|
-
- `data_base64`: base64 written by the model; fine for small files.
|
|
228
|
-
|
|
229
|
-
attachment's name, rather than sent as a
|
|
232
|
+
- `data_base64`: base64 written by the model; fine for small files. The standard or URL-safe
|
|
233
|
+
alphabet is accepted, but not both in one value. Decoding is strict: any other character, a bad
|
|
234
|
+
length or padding, or a stray ending is refused with the attachment's name, rather than sent as a
|
|
235
|
+
corrupted file.
|
|
230
236
|
- `data_text`: text, sent as its UTF-8 bytes; no base64 overhead.
|
|
231
237
|
- `path` (stdio with `FMSG_MCP_UPLOAD_DIR`): a file in that folder, streamed by the server. Paths
|
|
232
238
|
are relative to the folder, and a path or symlink that leads outside it is refused.
|
package/dist/auth.js
CHANGED
|
@@ -47,9 +47,12 @@ export class ApiKeyCallerProvider {
|
|
|
47
47
|
this.pendingClients.add(client);
|
|
48
48
|
try {
|
|
49
49
|
const address = await client.address();
|
|
50
|
+
const owner = await client.owner();
|
|
50
51
|
if (this.closed)
|
|
51
52
|
throw new Error("server is closing");
|
|
52
|
-
const entry = {
|
|
53
|
+
const entry = {
|
|
54
|
+
key, caller: { client, address, ...(owner ? { owner } : {}), tokenExpiresAt: async () => (await client.getToken()).expiresAtMs }, lastUsed: Date.now(), active: 0,
|
|
55
|
+
};
|
|
53
56
|
this.entries.set(key, entry);
|
|
54
57
|
this.log(safeErrorMessage(`authenticated ${address} (key ${key.slice(0, 8)}…)`));
|
|
55
58
|
return entry;
|
|
@@ -93,7 +96,8 @@ export class ApiKeyCallerProvider {
|
|
|
93
96
|
this.leases.set(lease, entry);
|
|
94
97
|
return {
|
|
95
98
|
token: key, clientId: entry.caller.address, scopes: [FMSG_SCOPE],
|
|
96
|
-
expiresAt: Math.floor(expiresAtMs / 1000),
|
|
99
|
+
expiresAt: Math.floor(expiresAtMs / 1000),
|
|
100
|
+
extra: { cacheKey: key, callerLease: lease, ...(entry.caller.owner ? { owner: entry.caller.owner } : {}) },
|
|
97
101
|
};
|
|
98
102
|
}
|
|
99
103
|
catch (error) {
|
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;
|
|
@@ -38,6 +44,8 @@ export declare class FmsgClient {
|
|
|
38
44
|
get reconnectOnTokenExpiry(): boolean;
|
|
39
45
|
/** The provider's authenticated address, pinned for this client's lifetime. */
|
|
40
46
|
address(signal?: AbortSignal): Promise<string>;
|
|
47
|
+
/** The address that owns the authenticated sub-address, or undefined when acting as an owner's own address. */
|
|
48
|
+
owner(signal?: AbortSignal): Promise<string | undefined>;
|
|
41
49
|
getToken(force?: boolean, signal?: AbortSignal): Promise<AccessToken>;
|
|
42
50
|
private startRenewal;
|
|
43
51
|
/** Release credentials and cancel outstanding work when the client is no longer used. */
|
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
|
}
|
|
@@ -67,6 +82,10 @@ export class FmsgClient {
|
|
|
67
82
|
async address(signal) {
|
|
68
83
|
return (await this.getToken(false, signal)).address;
|
|
69
84
|
}
|
|
85
|
+
/** The address that owns the authenticated sub-address, or undefined when acting as an owner's own address. */
|
|
86
|
+
async owner(signal) {
|
|
87
|
+
return (await this.getToken(false, signal)).owner;
|
|
88
|
+
}
|
|
70
89
|
async getToken(force = false, signal) {
|
|
71
90
|
signal?.throwIfAborted();
|
|
72
91
|
this.lifetime.signal.throwIfAborted();
|
|
@@ -110,7 +129,12 @@ export class FmsgClient {
|
|
|
110
129
|
if (this.boundAddress !== undefined && address !== this.boundAddress)
|
|
111
130
|
throw new Error("token provider changed the authenticated address; create a new client for a different identity");
|
|
112
131
|
this.boundAddress = address;
|
|
113
|
-
const
|
|
132
|
+
const owner = typeof value.owner === "string" ? normalizeFmsgAddress(value.owner) : undefined;
|
|
133
|
+
if (value.owner !== undefined && !owner)
|
|
134
|
+
throw new Error("token provider returned an invalid owner address");
|
|
135
|
+
const token = Object.freeze({
|
|
136
|
+
accessToken: value.accessToken, address, ...(owner && owner.toLowerCase() !== address.toLowerCase() ? { owner } : {}), expiresAtMs: value.expiresAtMs,
|
|
137
|
+
});
|
|
114
138
|
this.refreshAtMs = token.expiresAtMs - Math.min(this.options.refreshMarginMs ?? 300_000, (token.expiresAtMs - now) / 2);
|
|
115
139
|
this.token = token;
|
|
116
140
|
return token;
|
|
@@ -391,56 +415,74 @@ export class FmsgClient {
|
|
|
391
415
|
const reader = attachment.stream.getReader();
|
|
392
416
|
let stage = "head";
|
|
393
417
|
let size = 0;
|
|
418
|
+
let pulled = false;
|
|
394
419
|
let sourceError;
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
420
|
+
// The source is cancelled in finally, not when fetch drops an attempt, so an attempt that read none of it can be repeated.
|
|
421
|
+
const multipart = () => {
|
|
422
|
+
stage = "head";
|
|
423
|
+
return new ReadableStream({
|
|
424
|
+
async pull(controller) {
|
|
425
|
+
if (stage === "head") {
|
|
426
|
+
stage = "data";
|
|
427
|
+
controller.enqueue(head);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
arm("attachment upload stalled waiting for data");
|
|
431
|
+
pulled = true;
|
|
432
|
+
let chunk;
|
|
433
|
+
try {
|
|
434
|
+
chunk = await reader.read();
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
sourceError = error;
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
if (!chunk.done) {
|
|
441
|
+
hash.update(chunk.value);
|
|
442
|
+
size += chunk.value.byteLength;
|
|
443
|
+
controller.enqueue(chunk.value);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
stage = "done";
|
|
447
|
+
controller.enqueue(tail);
|
|
448
|
+
controller.close();
|
|
449
|
+
arm("attachment upload response timed out");
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
};
|
|
453
|
+
arm("attachment upload stalled waiting for data");
|
|
454
|
+
try {
|
|
455
|
+
for (let attempt = 1;; attempt++) {
|
|
404
456
|
try {
|
|
405
|
-
|
|
457
|
+
const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/attach`, {
|
|
458
|
+
method: "POST",
|
|
459
|
+
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
|
460
|
+
body: multipart(),
|
|
461
|
+
duplex: "half",
|
|
462
|
+
signal: AbortSignal.any([deadline.signal, ...(signal ? [signal] : [])]),
|
|
463
|
+
}, false, "caller");
|
|
464
|
+
const result = parseFmsgJson(await response.text());
|
|
465
|
+
const uploaded = { filename: result.filename ?? attachment.filename, size: result.size ?? size, sha256: hash.digest("hex") };
|
|
466
|
+
if (expected && expected !== uploaded.sha256) {
|
|
467
|
+
await this.deleteAttachment(mid, uploaded.filename).catch(() => undefined);
|
|
468
|
+
throw new AttachmentChecksumError(attachment.filename, expected, uploaded.sha256);
|
|
469
|
+
}
|
|
470
|
+
return uploaded;
|
|
406
471
|
}
|
|
407
472
|
catch (error) {
|
|
408
|
-
|
|
473
|
+
// Report the source's failure (such as invalid compressed data) rather than fetch's wrapper.
|
|
474
|
+
if (sourceError)
|
|
475
|
+
throw sourceError;
|
|
476
|
+
const network = error instanceof TypeError && !deadline.signal.aborted && !signal?.aborted;
|
|
477
|
+
// A pooled connection the host had already closed fails before any of the file is read; that is safe to repeat.
|
|
478
|
+
if (network && !pulled && attempt < 3)
|
|
479
|
+
continue;
|
|
480
|
+
// A host that refuses an upload part-way may close the connection; fetch then reports only the broken write.
|
|
481
|
+
if (network && stage !== "done")
|
|
482
|
+
throw new UploadInterruptedError(attachment.filename, size, error);
|
|
409
483
|
throw error;
|
|
410
484
|
}
|
|
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
485
|
}
|
|
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
486
|
}
|
|
445
487
|
finally {
|
|
446
488
|
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";
|
|
@@ -25,3 +25,5 @@ export declare class ApiKeyTokenProvider implements TokenProvider {
|
|
|
25
25
|
getToken({ apiUrl, signal }: TokenProviderRequest): Promise<AccessToken>;
|
|
26
26
|
close(): void;
|
|
27
27
|
}
|
|
28
|
+
/** The `owner` claim as an address, when it is one and differs from the subject. */
|
|
29
|
+
export declare function ownerOf(payload: Record<string, unknown>, address: string): string | undefined;
|
|
@@ -25,6 +25,8 @@ export class ApiKeyTokenProvider {
|
|
|
25
25
|
const address = typeof payload.sub === "string" ? normalizeFmsgAddress(payload.sub) : undefined;
|
|
26
26
|
if (!address)
|
|
27
27
|
throw new Error("token JWT sub is not an fmsg address");
|
|
28
|
+
// The Web API's `owner` claim names the account a sub-address belongs to; an owner's own token has none.
|
|
29
|
+
const owner = ownerOf(payload, address);
|
|
28
30
|
const expiries = [
|
|
29
31
|
typeof body.expires_at === "string" ? Date.parse(body.expires_at) : Number.NaN,
|
|
30
32
|
typeof payload.exp === "number" ? payload.exp * 1000 : Number.NaN,
|
|
@@ -32,10 +34,15 @@ export class ApiKeyTokenProvider {
|
|
|
32
34
|
].filter(Number.isFinite);
|
|
33
35
|
// Never extend the JWT lifetime using a later response expiry or a guessed TTL.
|
|
34
36
|
const expiresAtMs = expiries.length ? Math.min(...expiries) : Number.NaN;
|
|
35
|
-
return { accessToken: body.access_token, address, expiresAtMs };
|
|
37
|
+
return { accessToken: body.access_token, address, ...(owner ? { owner } : {}), expiresAtMs };
|
|
36
38
|
}
|
|
37
39
|
close() { this.apiKey = ""; }
|
|
38
40
|
}
|
|
41
|
+
/** The `owner` claim as an address, when it is one and differs from the subject. */
|
|
42
|
+
export function ownerOf(payload, address) {
|
|
43
|
+
const owner = typeof payload.owner === "string" ? normalizeFmsgAddress(payload.owner) : undefined;
|
|
44
|
+
return owner && owner.toLowerCase() !== address.toLowerCase() ? owner : undefined;
|
|
45
|
+
}
|
|
39
46
|
// Only the first-party API-key contract requires sub/exp in a JWT. Custom
|
|
40
47
|
// providers supply address/expiry metadata; the client does not decode their tokens.
|
|
41
48
|
function decodeJwtPayload(token) {
|
package/dist/client/types.d.ts
CHANGED
|
@@ -103,6 +103,11 @@ export type AccessToken = {
|
|
|
103
103
|
readonly accessToken: string;
|
|
104
104
|
/** Authenticated fmsg address supplied by the provider; not necessarily a JWT sub. */
|
|
105
105
|
readonly address: string;
|
|
106
|
+
/**
|
|
107
|
+
* The address that owns `address` when it is a sub-address (a derived sub-account such as
|
|
108
|
+
* `@user_agent@example.com`, or a delegated identity); absent when the token is the owner's own.
|
|
109
|
+
*/
|
|
110
|
+
readonly owner?: string;
|
|
106
111
|
readonly expiresAtMs: number;
|
|
107
112
|
};
|
|
108
113
|
export type OutboundAttachment = {
|
package/dist/context.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { FmsgClient } from "./client/client.js";
|
|
|
4
4
|
export type Caller = {
|
|
5
5
|
client: FmsgClient;
|
|
6
6
|
address: string;
|
|
7
|
+
/** The address that owns `address` when it is a sub-address; the owner sees none of this caller's messages. */
|
|
8
|
+
owner?: string;
|
|
7
9
|
/** When the current Web API access token expires (ms since epoch), for whoami. */
|
|
8
10
|
tokenExpiresAt: () => Promise<number>;
|
|
9
11
|
};
|
package/dist/context.js
CHANGED
|
@@ -7,7 +7,8 @@ export class StaticCallerProvider {
|
|
|
7
7
|
forRequest() {
|
|
8
8
|
this.caller ??= (async () => {
|
|
9
9
|
const address = await this.client.address();
|
|
10
|
-
|
|
10
|
+
const owner = await this.client.owner();
|
|
11
|
+
return { client: this.client, address, ...(owner ? { owner } : {}), tokenExpiresAt: async () => (await this.client.getToken()).expiresAtMs };
|
|
11
12
|
})().catch((error) => {
|
|
12
13
|
this.caller = undefined;
|
|
13
14
|
throw error;
|
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
|
|
@@ -125,7 +138,11 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
125
138
|
const metadataPath = resource ? `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}` : undefined;
|
|
126
139
|
const metadataUrl = resource ? `${resource.origin}${metadataPath}` : "";
|
|
127
140
|
const shutdown = new AbortController();
|
|
128
|
-
const mcpHandler = (provider, address) => createMcpHandler(({ authInfo }) => createFmsgMcpServer(provider, config, {
|
|
141
|
+
const mcpHandler = (provider, address) => createMcpHandler(({ authInfo }) => createFmsgMcpServer(provider, config, {
|
|
142
|
+
...(authInfo ? { address: address(authInfo) } : {}),
|
|
143
|
+
...(typeof authInfo?.extra?.owner === "string" ? { owner: authInfo.extra.owner } : {}),
|
|
144
|
+
shutdown: shutdown.signal,
|
|
145
|
+
}), { onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) });
|
|
129
146
|
const oauthHandler = oauthProvider && mcpHandler(oauthProvider, (authInfo) => String(authInfo.extra?.address ?? ""));
|
|
130
147
|
const apiKeyHandler = apiKeyProvider && mcpHandler(apiKeyProvider, (authInfo) => authInfo.clientId);
|
|
131
148
|
const active = new Map();
|
|
@@ -144,7 +161,8 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
144
161
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
145
162
|
const isUpload = url.pathname.startsWith(`${UPLOAD_PATH}/`);
|
|
146
163
|
// 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
|
|
164
|
+
// Closing the connection afterwards stops Node reading the rest of an upload refused before authentication;
|
|
165
|
+
// an authenticated caller's failed upload is drained first so its answer arrives (see serveUpload).
|
|
148
166
|
const bodyTimer = isUpload ? undefined : setTimeout(() => { if (!req.complete)
|
|
149
167
|
req.destroy(); }, REQUEST_BODY_TIMEOUT_MS).unref();
|
|
150
168
|
if (isUpload)
|
|
@@ -225,32 +243,34 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
225
243
|
}
|
|
226
244
|
let upload;
|
|
227
245
|
if (isUpload) {
|
|
246
|
+
// Refusals are JSON, like the errors from the upload itself.
|
|
247
|
+
const uploadRefusal = (error, status, headers = {}) => sendWebResponse(res, Response.json({ error }, { status, headers }));
|
|
228
248
|
if (req.method !== "PUT")
|
|
229
|
-
return
|
|
249
|
+
return uploadRefusal("use PUT to upload attachments", 405, { allow: "PUT, OPTIONS" });
|
|
230
250
|
if (url.search)
|
|
231
|
-
return
|
|
251
|
+
return uploadRefusal("attachment URLs must not contain query parameters", 400);
|
|
232
252
|
try {
|
|
233
253
|
upload = { ...parseUploadPath(url.pathname), contentType: "" };
|
|
234
254
|
}
|
|
235
255
|
catch {
|
|
236
|
-
return
|
|
256
|
+
return uploadRefusal("invalid attachment upload path; use /mcp/drafts/{id}/attachments/{filename}", 400);
|
|
237
257
|
}
|
|
238
258
|
try {
|
|
239
259
|
upload.encoding = uploadEncoding(request.headers.get("content-encoding"));
|
|
240
260
|
}
|
|
241
261
|
catch (error) {
|
|
242
|
-
return
|
|
262
|
+
return uploadRefusal(error.message, 415);
|
|
243
263
|
}
|
|
244
264
|
const sha256 = request.headers.get(CHECKSUM_HEADER)?.trim();
|
|
245
265
|
if (sha256 !== undefined) {
|
|
246
266
|
if (!/^[0-9A-Fa-f]{64}$/u.test(sha256))
|
|
247
|
-
return
|
|
267
|
+
return uploadRefusal(`${CHECKSUM_HEADER} must be 64 hexadecimal characters`, 400);
|
|
248
268
|
upload.sha256 = sha256.toLowerCase();
|
|
249
269
|
}
|
|
250
270
|
// curl and similar tools label raw bodies as form data by default; that says nothing about the file.
|
|
251
271
|
const declared = request.headers.get("content-type")?.trim();
|
|
252
272
|
if (declared && !/^[\x20-\x7e]+$/u.test(declared))
|
|
253
|
-
return
|
|
273
|
+
return uploadRefusal("Content-Type must be printable ASCII", 400);
|
|
254
274
|
upload.contentType = declared && !/^application\/x-www-form-urlencoded\b/iu.test(declared)
|
|
255
275
|
? attachmentType(upload.filename, declared) : attachmentType(upload.filename);
|
|
256
276
|
}
|
|
@@ -267,10 +287,28 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
267
287
|
return sendWebResponse(res, response);
|
|
268
288
|
});
|
|
269
289
|
const serveUpload = (provider, auth) => serveTransfer(provider, auth, WRITE_SCOPE, async (caller) => {
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
290
|
+
const { stream, drain } = drainableBody(request.body ?? new ReadableStream({ start: (c) => c.close() }), UPLOAD_DRAIN_IDLE_MS);
|
|
291
|
+
const body = decodedUploadBody(stream, upload.encoding);
|
|
292
|
+
let uploaded;
|
|
293
|
+
try {
|
|
294
|
+
uploaded = await caller.client.uploadAttachmentStream(upload.id, {
|
|
295
|
+
filename: upload.filename, stream: body, contentType: upload.contentType, ...(upload.sha256 ? { expectedSha256: upload.sha256 } : {}),
|
|
296
|
+
}, controller.signal);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
// Answering while the client is still sending would close the connection with unread data, resetting it
|
|
300
|
+
// and losing the answer; the caller is authenticated and could send these bytes to its own draft anyway.
|
|
301
|
+
if (!(await drain())) {
|
|
302
|
+
res.destroy();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (!(error instanceof UploadInterruptedError))
|
|
306
|
+
throw error;
|
|
307
|
+
const refusal = await draftRefusal(caller, upload.id, controller.signal).catch((lookup) => { throw lookup instanceof FmsgHttpError ? lookup : error; });
|
|
308
|
+
if (refusal === undefined)
|
|
309
|
+
throw error;
|
|
310
|
+
return sendWebResponse(res, Response.json({ error: refusal }, { status: 403 }));
|
|
311
|
+
}
|
|
274
312
|
return sendWebResponse(res, Response.json({ id: upload.id, ...uploaded, type: upload.contentType }, { status: 201 }));
|
|
275
313
|
});
|
|
276
314
|
const serveTransfer = async (provider, auth, scope, transfer) => {
|
|
@@ -312,7 +350,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
312
350
|
try {
|
|
313
351
|
const bearer = /^Bearer ([A-Za-z0-9._~+\/-]+=*)$/iu.exec(authorization);
|
|
314
352
|
if (!bearer)
|
|
315
|
-
throw invalidToken();
|
|
353
|
+
throw authorization ? invalidToken() : missingToken();
|
|
316
354
|
const authenticated = await oauthProvider.verifyAccessToken(bearer[1]);
|
|
317
355
|
release = () => oauthProvider.release(authenticated);
|
|
318
356
|
let parsedBody;
|
package/dist/index.js
CHANGED
|
@@ -125,16 +125,17 @@ async function main() {
|
|
|
125
125
|
}
|
|
126
126
|
// Resolve the address once so the instructions can name it; never let a slow
|
|
127
127
|
// or unreachable host hold up initialize.
|
|
128
|
-
const
|
|
128
|
+
const knownCaller = async () => {
|
|
129
129
|
if (!cfg.apiUrl || !cfg.apiKey)
|
|
130
130
|
return undefined;
|
|
131
131
|
const timeout = new Promise((resolve) => setTimeout(() => resolve(undefined), 5000).unref());
|
|
132
|
-
return Promise.race([provider.forRequest(undefined).then((c) => c.address
|
|
132
|
+
return Promise.race([provider.forRequest(undefined).then((c) => ({ address: c.address, ...(c.owner ? { owner: c.owner } : {}) })), timeout])
|
|
133
|
+
.catch(() => undefined);
|
|
133
134
|
};
|
|
134
135
|
const shutdown = new AbortController();
|
|
135
136
|
const handle = serveStdio(async () => {
|
|
136
|
-
const
|
|
137
|
-
return createFmsgMcpServer(provider, cfg, { ...
|
|
137
|
+
const known = await knownCaller();
|
|
138
|
+
return createFmsgMcpServer(provider, cfg, { ...known, shutdown: shutdown.signal });
|
|
138
139
|
});
|
|
139
140
|
onShutdown(async () => {
|
|
140
141
|
// Let an in-flight wait write its "interrupted" result before the transport closes.
|
package/dist/instructions.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { FileRoutes } from "./tools/attachments.js";
|
|
|
2
2
|
export type InstructionsContext = {
|
|
3
3
|
/** The address this server acts as, when already known (HTTP callers; stdio after a token exchange). */
|
|
4
4
|
address?: string;
|
|
5
|
+
/** The address that owns `address` when it is a sub-address, when known. */
|
|
6
|
+
owner?: string;
|
|
5
7
|
/** FMSG_DEFAULT_DOMAIN: a domain, or CALLER_DOMAIN for the caller's own domain. */
|
|
6
8
|
defaultDomain?: string;
|
|
7
9
|
/** Large-file routes this server offers; none when omitted. */
|
package/dist/instructions.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* Server instructions: returned in the MCP `initialize` result and folded into
|
|
3
3
|
* the model's system prompt by hosts that support them (some chat hosts do not, so
|
|
4
4
|
* nothing a tool needs may live only here). Rules that span tools, each stated once:
|
|
5
|
-
* which address this server acts as, the fmsg terms, the inbox
|
|
6
|
-
* send files, the irreversible-send rule and untrusted content. Per-tool detail lives in
|
|
7
|
-
* descriptions; situational guidance in results.
|
|
5
|
+
* which address this server acts as (and its owner, for a sub-address), the fmsg terms, the inbox
|
|
6
|
+
* routine, how to send files, the irreversible-send rule and untrusted content. Per-tool detail lives in
|
|
7
|
+
* the tool descriptions; situational guidance in results. Keep it short: hosts fold it into every prompt.
|
|
8
8
|
*/
|
|
9
9
|
import { CALLER_DOMAIN, effectiveDefaultDomain } from "./address.js";
|
|
10
|
+
import { ownerSentence } from "./owner.js";
|
|
10
11
|
/** How to send files, naming only the routes this server offers. */
|
|
11
12
|
function filesParagraph(files) {
|
|
12
13
|
const large = [
|
|
@@ -17,8 +18,10 @@ function filesParagraph(files) {
|
|
|
17
18
|
}
|
|
18
19
|
export function buildInstructions(ctx = {}) {
|
|
19
20
|
const identity = ctx.address
|
|
20
|
-
? `you are acting as ${ctx.address}`
|
|
21
|
+
? `you are acting as ${ctx.address}${ctx.owner ? `, a sub-address of ${ctx.owner}` : ""}`
|
|
21
22
|
: "call whoami to see which";
|
|
23
|
+
// An owner's own address needs no owner sentence; an unknown caller gets the conditional one.
|
|
24
|
+
const owner = ctx.address && !ctx.owner ? "" : ` ${ownerSentence(ctx.owner)}`;
|
|
22
25
|
const domain = effectiveDefaultDomain(ctx.defaultDomain, ctx.address);
|
|
23
26
|
const shortNames = domain
|
|
24
27
|
? `; short names resolve to @name@${domain}`
|
|
@@ -27,7 +30,7 @@ export function buildInstructions(ctx = {}) {
|
|
|
27
30
|
: "";
|
|
28
31
|
return [
|
|
29
32
|
`This server sends and receives fmsg messages as one fmsg address, and only that one: ${identity}. ` +
|
|
30
|
-
|
|
33
|
+
`Use its tools for everything fmsg; other fmsg tools or local credentials may act as a different address.${owner}`,
|
|
31
34
|
"fmsg is threaded messaging between @user@domain addresses. Only a thread's first message has a topic; " +
|
|
32
35
|
"replies have none. A reaction is a small message carrying one emoji. Terminal messages (such as reactions) " +
|
|
33
36
|
"cannot be replied or reacted to; no-reply asks recipients not to answer. Added recipients are people " +
|
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" });
|
package/dist/owner.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-addresses, stated once. `@user_agent@example.com` is a sub-address of `@user@example.com`: the
|
|
3
|
+
* owner can switch to it in their client, but can only follow and reply to a thread they are a
|
|
4
|
+
* participant of. So messages a sub-address sends include the owner as a recipient unless the
|
|
5
|
+
* model passes include_owner: false.
|
|
6
|
+
*/
|
|
7
|
+
import * as z from "zod/v4";
|
|
8
|
+
/** The instructions sentence after the identity: concrete when the owner is known, conditional otherwise. */
|
|
9
|
+
export declare function ownerSentence(owner: string | undefined): string;
|
|
10
|
+
/** The include_owner argument for send-type tools; undefined when the caller is known to be an owner's own address. */
|
|
11
|
+
export declare function includeOwnerSchema(address: string | undefined, owner: string | undefined): z.ZodDefault<z.ZodBoolean> | undefined;
|
|
12
|
+
/** The recipients with the caller's owner appended, unless include_owner is false (unknown: the argument is only registered for possible sub-addresses) or they are already present. */
|
|
13
|
+
export declare function withOwner(caller: {
|
|
14
|
+
owner?: string;
|
|
15
|
+
}, recipients: string[], includeOwner: unknown): string[];
|
package/dist/owner.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-addresses, stated once. `@user_agent@example.com` is a sub-address of `@user@example.com`: the
|
|
3
|
+
* owner can switch to it in their client, but can only follow and reply to a thread they are a
|
|
4
|
+
* participant of. So messages a sub-address sends include the owner as a recipient unless the
|
|
5
|
+
* model passes include_owner: false.
|
|
6
|
+
*/
|
|
7
|
+
import * as z from "zod/v4";
|
|
8
|
+
import { sameAddress } from "./address.js";
|
|
9
|
+
/** The instructions sentence after the identity: concrete when the owner is known, conditional otherwise. */
|
|
10
|
+
export function ownerSentence(owner) {
|
|
11
|
+
return owner
|
|
12
|
+
? `Messages you send include ${owner} as a recipient unless include_owner is false, so they can follow and reply.`
|
|
13
|
+
: "If whoami shows an owner, messages you send include them as a recipient unless include_owner is false.";
|
|
14
|
+
}
|
|
15
|
+
/** The include_owner argument for send-type tools; undefined when the caller is known to be an owner's own address. */
|
|
16
|
+
export function includeOwnerSchema(address, owner) {
|
|
17
|
+
if (address && !owner)
|
|
18
|
+
return undefined;
|
|
19
|
+
return z.boolean().default(true).describe(owner ? `also send to your owner ${owner}` : "also send to your owner, if whoami shows one");
|
|
20
|
+
}
|
|
21
|
+
/** The recipients with the caller's owner appended, unless include_owner is false (unknown: the argument is only registered for possible sub-addresses) or they are already present. */
|
|
22
|
+
export function withOwner(caller, recipients, includeOwner) {
|
|
23
|
+
const owner = caller.owner;
|
|
24
|
+
if (!owner || includeOwner === false || recipients.some((r) => sameAddress(r, owner)))
|
|
25
|
+
return recipients;
|
|
26
|
+
return [...recipients, owner];
|
|
27
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export declare const SERVER_NAME = "fmsg";
|
|
|
5
5
|
export type CreateServerOptions = {
|
|
6
6
|
/** The caller's address when already known; makes the instructions name it. */
|
|
7
7
|
address?: string;
|
|
8
|
+
/** The address that owns the caller's sub-address, when known; the instructions and descriptions then name it. */
|
|
9
|
+
owner?: string;
|
|
8
10
|
/** Aborted when the process begins shutting down, so wait_for_message returns "interrupted". */
|
|
9
11
|
shutdown?: AbortSignal;
|
|
10
12
|
};
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import { buildInstructions } from "./instructions.js";
|
|
3
|
+
import { includeOwnerSchema } from "./owner.js";
|
|
3
4
|
import { registerPrompts } from "./prompts.js";
|
|
4
5
|
import { registerResources } from "./resources.js";
|
|
5
6
|
import { fileRoutes } from "./tools/attachments.js";
|
|
@@ -20,11 +21,15 @@ export const SERVER_NAME = "fmsg";
|
|
|
20
21
|
export function createFmsgMcpServer(provider, config, options = {}) {
|
|
21
22
|
const instructions = buildInstructions({
|
|
22
23
|
...(options.address ? { address: options.address } : {}),
|
|
24
|
+
...(options.owner ? { owner: options.owner } : {}),
|
|
23
25
|
...(config.defaultDomain ? { defaultDomain: config.defaultDomain } : {}),
|
|
24
26
|
files: fileRoutes(config),
|
|
25
27
|
});
|
|
26
28
|
const server = new McpServer({ name: SERVER_NAME, title: "fmsg", version: VERSION }, { instructions });
|
|
27
|
-
const
|
|
29
|
+
const includeOwner = includeOwnerSchema(options.address, options.owner);
|
|
30
|
+
const deps = {
|
|
31
|
+
provider, config, ...(includeOwner ? { includeOwner } : {}), ...(options.shutdown ? { shutdown: options.shutdown } : {}),
|
|
32
|
+
};
|
|
28
33
|
registerIdentityTools(server, deps);
|
|
29
34
|
registerListTools(server, deps);
|
|
30
35
|
registerReadTools(server, deps);
|
|
@@ -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/common.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { type Caller, type CallerProvider } from "../context.js";
|
|
|
7
7
|
export type ToolDeps = {
|
|
8
8
|
provider: CallerProvider;
|
|
9
9
|
config: Config;
|
|
10
|
+
/** The include_owner argument of send-type tools; absent when the caller is known to be an owner's own address. */
|
|
11
|
+
includeOwner?: z.ZodDefault<z.ZodBoolean>;
|
|
10
12
|
/** Aborted when the server begins shutting down; long waits then return early. */
|
|
11
13
|
shutdown?: AbortSignal;
|
|
12
14
|
};
|
package/dist/tools/draft.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as z from "zod/v4";
|
|
2
2
|
import { downloadBaseUrl } from "../download.js";
|
|
3
3
|
import { toolError } from "../errors.js";
|
|
4
|
+
import { withOwner } from "../owner.js";
|
|
4
5
|
import { addressList, headerValue, isoTime } from "../render.js";
|
|
5
6
|
import { CHECKSUM_HEADER, UPLOAD_ENCODINGS, draftUploadUrlTemplate } from "../upload.js";
|
|
6
7
|
import { attachmentInput, base64Hint, draftWithAttachments, fileRoutes, prepareAttachments } from "./attachments.js";
|
|
@@ -10,6 +11,7 @@ import { IMMUTABLE, IMPORTANT, NO_REPLY, UPLOAD_STEPS, hintOutput, redactionNote
|
|
|
10
11
|
export const registerDraftTools = (server, deps) => {
|
|
11
12
|
const baseUrl = downloadBaseUrl(deps.config);
|
|
12
13
|
const routes = fileRoutes(deps.config);
|
|
14
|
+
const includeOwner = deps.includeOwner ? { include_owner: deps.includeOwner } : {};
|
|
13
15
|
server.registerTool("create_draft", {
|
|
14
16
|
title: "Create fmsg draft",
|
|
15
17
|
description: "Create an unsent draft: a new message, or a reply when reply_to is given (reply rules and reply-all apply). " +
|
|
@@ -25,6 +27,7 @@ export const registerDraftTools = (server, deps) => {
|
|
|
25
27
|
important: z.boolean().default(false).describe(IMPORTANT),
|
|
26
28
|
no_reply: z.boolean().default(false).describe(NO_REPLY),
|
|
27
29
|
allow_no_reply: z.boolean().default(false).describe("draft a reply even though the parent asked for no replies"),
|
|
30
|
+
...includeOwner,
|
|
28
31
|
attachments: z.array(attachmentInput(deps.config)).optional().describe("files to attach now"),
|
|
29
32
|
}),
|
|
30
33
|
outputSchema: outputObject({
|
|
@@ -48,7 +51,7 @@ export const registerDraftTools = (server, deps) => {
|
|
|
48
51
|
hint: hintOutput,
|
|
49
52
|
}),
|
|
50
53
|
annotations: { ...SENDS, destructiveHint: false },
|
|
51
|
-
}, async ({ to, topic, reply_to, body, type, important, no_reply, allow_no_reply, attachments }, ctx) => {
|
|
54
|
+
}, async ({ to, topic, reply_to, body, type, important, no_reply, allow_no_reply, include_owner, attachments }, ctx) => {
|
|
52
55
|
if (reply_to && topic)
|
|
53
56
|
return toolError("a reply has no topic: pass topic only for a new message");
|
|
54
57
|
if (!reply_to && !to?.length)
|
|
@@ -68,6 +71,7 @@ export const registerDraftTools = (server, deps) => {
|
|
|
68
71
|
else {
|
|
69
72
|
({ to: recipients, resolved } = resolveRecipients(to, resolverFor(deps, caller)));
|
|
70
73
|
}
|
|
74
|
+
recipients = withOwner(caller, recipients, include_owner);
|
|
71
75
|
const outbound = await prepareAttachments(attachments, deps.config);
|
|
72
76
|
const hint = base64Hint(attachments, routes);
|
|
73
77
|
const draft = await draftWithAttachments(caller.client, {
|
package/dist/tools/identity.js
CHANGED
|
@@ -9,10 +9,11 @@ const TRANSPORT_LABELS = { stdio: "stdio", http: "Streamable HTTP" };
|
|
|
9
9
|
export const registerIdentityTools = (server, deps) => {
|
|
10
10
|
server.registerTool("whoami", {
|
|
11
11
|
title: "Show fmsg identity",
|
|
12
|
-
description: "Report the fmsg address this server acts as, the fmsg Web API URL and how
|
|
13
|
-
"also gives the server version. Call this first if unsure who you are sending as.",
|
|
12
|
+
description: "Report the fmsg address this server acts as, its owner if it is a sub-address, the fmsg Web API URL and how " +
|
|
13
|
+
"short names resolve; the text also gives the server version. Call this first if unsure who you are sending as.",
|
|
14
14
|
outputSchema: outputObject({
|
|
15
15
|
address: z.string(),
|
|
16
|
+
owner: z.string().optional().describe("owner of this sub-address, included as a recipient of messages you send unless include_owner is false; absent for an owner's own address"),
|
|
16
17
|
api_url: z.string().nullable(),
|
|
17
18
|
token_expires_at: z.string().nullable().describe("internal: when the server's current upstream access token expires; it is renewed automatically, so no action is needed"),
|
|
18
19
|
transport: openEnum(["stdio", "http"], "http is Streamable HTTP"),
|
|
@@ -27,6 +28,7 @@ export const registerIdentityTools = (server, deps) => {
|
|
|
27
28
|
const transport = TRANSPORT_LABELS[deps.config.transport];
|
|
28
29
|
const structured = {
|
|
29
30
|
address: caller.address,
|
|
31
|
+
...(caller.owner ? { owner: caller.owner } : {}),
|
|
30
32
|
api_url: deps.config.apiPublicUrl ?? null,
|
|
31
33
|
token_expires_at: expires,
|
|
32
34
|
transport: deps.config.transport,
|
|
@@ -39,6 +41,8 @@ export const registerIdentityTools = (server, deps) => {
|
|
|
39
41
|
: `You are ${addressText(caller.address)}, connected over ${transport}.`,
|
|
40
42
|
`Access is renewed automatically. Server version ${VERSION}.`,
|
|
41
43
|
];
|
|
44
|
+
if (caller.owner)
|
|
45
|
+
lines.push(`Sub-address of ${addressText(caller.owner)}, who is a recipient of messages you send unless include_owner is false.`);
|
|
42
46
|
if (defaultDomain)
|
|
43
47
|
lines.push(`Short names resolve to @name@${defaultDomain}.`);
|
|
44
48
|
if (directoryNames.length)
|
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/tools/send.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as z from "zod/v4";
|
|
2
2
|
import { resolveAddress, sameAddress } from "../address.js";
|
|
3
3
|
import { toolError } from "../errors.js";
|
|
4
|
+
import { withOwner } from "../owner.js";
|
|
4
5
|
import { addressList, addressText, attachmentType, headerValue, isoTime, participantsOf, sortAttachments } from "../render.js";
|
|
5
6
|
import { attachmentInput, base64Hint, fileRoutes, prepareAttachments, sendWithAttachments } from "./attachments.js";
|
|
6
7
|
import { SENDS, attachmentItem, idSchema, ok, outputObject, resolverFor, withCaller } from "./common.js";
|
|
@@ -99,6 +100,7 @@ export function resolvedNote(resolved) {
|
|
|
99
100
|
export const registerSendTools = (server, deps) => {
|
|
100
101
|
const attachments = z.array(attachmentInput(deps.config)).optional().describe("files");
|
|
101
102
|
const guidance = attachmentGuidance(deps.config);
|
|
103
|
+
const includeOwner = deps.includeOwner ? { include_owner: deps.includeOwner } : {};
|
|
102
104
|
const routes = fileRoutes(deps.config);
|
|
103
105
|
server.registerTool("send_message", {
|
|
104
106
|
title: "Send new fmsg message",
|
|
@@ -113,12 +115,14 @@ export const registerSendTools = (server, deps) => {
|
|
|
113
115
|
type: z.string().default("text/markdown; charset=utf-8").describe("body media type"),
|
|
114
116
|
important: z.boolean().default(false).describe(IMPORTANT),
|
|
115
117
|
no_reply: z.boolean().default(false).describe(NO_REPLY),
|
|
118
|
+
...includeOwner,
|
|
116
119
|
attachments,
|
|
117
120
|
}),
|
|
118
121
|
outputSchema: sentOutput,
|
|
119
122
|
annotations: SENDS,
|
|
120
|
-
}, async ({ to, topic, body, type, important, no_reply, attachments }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
121
|
-
const { to:
|
|
123
|
+
}, async ({ to, topic, body, type, important, no_reply, include_owner, attachments }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
124
|
+
const { to: resolvedTo, resolved } = resolveRecipients(to, resolverFor(deps, caller));
|
|
125
|
+
const recipients = withOwner(caller, resolvedTo, include_owner);
|
|
122
126
|
const outbound = await prepareAttachments(attachments, deps.config);
|
|
123
127
|
const hint = base64Hint(attachments, routes);
|
|
124
128
|
const sent = await sendWithAttachments(caller.client, { to: recipients, topic, body, type, important, noReply: no_reply }, outbound, signal);
|
|
@@ -156,16 +160,18 @@ export const registerSendTools = (server, deps) => {
|
|
|
156
160
|
important: z.boolean().default(false).describe(IMPORTANT),
|
|
157
161
|
no_reply: z.boolean().default(false).describe(NO_REPLY),
|
|
158
162
|
allow_no_reply: z.boolean().default(false).describe("reply even though the parent asked for no replies"),
|
|
163
|
+
...includeOwner,
|
|
159
164
|
attachments,
|
|
160
165
|
}),
|
|
161
166
|
outputSchema: sentOutput,
|
|
162
167
|
annotations: SENDS,
|
|
163
|
-
}, async ({ id, body, recipients, type, important, no_reply, allow_no_reply, attachments }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
168
|
+
}, async ({ id, body, recipients, type, important, no_reply, allow_no_reply, include_owner, attachments }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
164
169
|
const parent = await caller.client.getMessage(id, signal);
|
|
165
170
|
const target = replyRecipients(deps, caller, parent, recipients, allow_no_reply);
|
|
166
171
|
if ("content" in target)
|
|
167
172
|
return target;
|
|
168
|
-
const {
|
|
173
|
+
const { resolved } = target;
|
|
174
|
+
const to = withOwner(caller, target.to, include_owner);
|
|
169
175
|
const outbound = await prepareAttachments(attachments, deps.config);
|
|
170
176
|
const hint = base64Hint(attachments, routes);
|
|
171
177
|
const sent = await sendWithAttachments(caller.client, { to, pid: parent.id, body, type, important, noReply: no_reply }, outbound, signal);
|
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/docs/token-providers.md
CHANGED
|
@@ -16,6 +16,7 @@ interface TokenProvider {
|
|
|
16
16
|
}): Promise<{
|
|
17
17
|
accessToken: string;
|
|
18
18
|
address: string;
|
|
19
|
+
owner?: string;
|
|
19
20
|
expiresAtMs: number;
|
|
20
21
|
}>;
|
|
21
22
|
close?(): void;
|
|
@@ -36,6 +37,8 @@ Sharing a provider between clients risks mixing grants or closing another client
|
|
|
36
37
|
case, and a finite future expiry in milliseconds since the Unix epoch. Use trusted
|
|
37
38
|
authorization/exchange metadata; do not invent an expiry or infer identity from an
|
|
38
39
|
unverified incoming token. The generic client does not decode provider tokens.
|
|
40
|
+
- Optionally return `owner`, the address that owns `address` when it is a sub-address (the Web
|
|
41
|
+
API's `owner` claim); omit it for an owner's own address. `FmsgClient.owner()` exposes it.
|
|
39
42
|
- Honour `signal` for all network work. It aborts when renewal times out, the client
|
|
40
43
|
closes, or all callers waiting for that renewal cancel. Reject on failed/revoked
|
|
41
44
|
authorization; never fall back to an owner token, API key or another grant.
|
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.
|
|
4
|
+
"version": "1.2.0",
|
|
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.
|
|
11
|
+
"version": "1.2.0",
|
|
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.
|
|
17
|
+
"version": "1.2.0",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
},
|