@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 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. Decoding is strict: a character
228
- outside the base64 alphabet, a bad length or padding, or a stray ending is refused with the
229
- attachment's name, rather than sent as a corrupted file.
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 = { key, caller: { client, address, tokenExpiresAt: async () => (await client.getToken()).expiresAtMs }, lastUsed: Date.now(), active: 0 };
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), extra: { cacheKey: key, callerLease: lease },
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) {
@@ -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. */
@@ -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 token = Object.freeze({ accessToken: value.accessToken, address, expiresAtMs: value.expiresAtMs });
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
- const body = new ReadableStream({
396
- async pull(controller) {
397
- if (stage === "head") {
398
- stage = "data";
399
- controller.enqueue(head);
400
- return;
401
- }
402
- arm("attachment upload stalled waiting for data");
403
- let chunk;
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
- chunk = await reader.read();
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
- sourceError = error;
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);
@@ -1,4 +1,4 @@
1
- export { AttachmentChecksumError, FmsgClient, FmsgHttpError, type FmsgClientOptions } from "./client.js";
1
+ export { AttachmentChecksumError, FmsgClient, FmsgHttpError, UploadInterruptedError, type FmsgClientOptions } from "./client.js";
2
2
  export type { TokenProvider, TokenProviderRequest } from "./token-provider.js";
3
3
  export { openFmsgWebSocket, parseWsEvent } from "./ws.js";
4
4
  export { redactSecrets, safeErrorMessage, type Redacted } from "./redact.js";
@@ -1,4 +1,4 @@
1
- export { AttachmentChecksumError, FmsgClient, FmsgHttpError } from "./client.js";
1
+ export { AttachmentChecksumError, FmsgClient, FmsgHttpError, UploadInterruptedError } from "./client.js";
2
2
  export { openFmsgWebSocket, parseWsEvent } from "./ws.js";
3
3
  export { redactSecrets, safeErrorMessage } from "./redact.js";
4
4
  export { normalizeMessageId, compareMessageIds, maxMessageId, parseFmsgJson, stringifyWithIds } from "./message-id.js";
@@ -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) {
@@ -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
- return { client: this.client, address, tokenExpiresAt: async () => (await this.client.getToken()).expiresAtMs };
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, { ...(authInfo ? { address: address(authInfo) } : {}), shutdown: shutdown.signal }), { onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) });
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 from reading the rest of a refused upload's body.
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 sendWebResponse(res, new Response("use PUT to upload attachments", { status: 405, headers: { allow: "PUT, OPTIONS" } }));
249
+ return uploadRefusal("use PUT to upload attachments", 405, { allow: "PUT, OPTIONS" });
230
250
  if (url.search)
231
- return sendWebResponse(res, new Response("attachment URLs must not contain query parameters", { status: 400 }));
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 sendWebResponse(res, new Response("invalid attachment upload path; use /mcp/drafts/{id}/attachments/{filename}", { status: 400 }));
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 sendWebResponse(res, new Response(error.message, { status: 415 }));
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 sendWebResponse(res, new Response(`${CHECKSUM_HEADER} must be 64 hexadecimal characters`, { status: 400 }));
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 sendWebResponse(res, new Response("Content-Type must be printable ASCII", { status: 400 }));
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 body = decodedUploadBody(request.body ?? new ReadableStream({ start: (c) => c.close() }), upload.encoding);
271
- const uploaded = await caller.client.uploadAttachmentStream(upload.id, {
272
- filename: upload.filename, stream: body, contentType: upload.contentType, ...(upload.sha256 ? { expectedSha256: upload.sha256 } : {}),
273
- }, controller.signal);
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 knownAddress = async () => {
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), timeout]).catch(() => undefined);
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 address = await knownAddress();
137
- return createFmsgMcpServer(provider, cfg, { ...(address ? { address } : {}), shutdown: shutdown.signal });
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.
@@ -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. */
@@ -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 routine, how to
6
- * send files, the irreversible-send rule and untrusted content. Per-tool detail lives in the tool
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
- "Use its tools for everything fmsg; other fmsg tools or local credentials may act as a different address.",
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 " +
@@ -14,5 +14,6 @@ export declare class OAuthRequestError extends Error {
14
14
  constructor(status: 401 | 403 | 500 | 503, code: string, message: string, scopes?: string[]);
15
15
  }
16
16
  export declare const invalidToken: () => OAuthRequestError;
17
+ export declare const missingToken: () => OAuthRequestError;
17
18
  export declare const insufficientScope: (scopes: string[]) => OAuthRequestError;
18
19
  export declare function oauthErrorResponse(error: OAuthRequestError, metadataUrl: string): Response;
@@ -27,6 +27,7 @@ export class OAuthRequestError extends Error {
27
27
  }
28
28
  }
29
29
  export const invalidToken = () => new OAuthRequestError(401, "invalid_token", "The OAuth connection must be refreshed or reconnected.");
30
+ export const missingToken = () => new OAuthRequestError(401, "invalid_token", "No access token was sent; send this MCP connection's Authorization header.");
30
31
  export const insufficientScope = (scopes) => new OAuthRequestError(403, "insufficient_scope", "The OAuth connection does not authorize this operation.", scopes);
31
32
  export function oauthErrorResponse(error, metadataUrl) {
32
33
  const headers = new Headers({ "cache-control": "no-store" });
@@ -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 deps = { provider, config, ...(options.shutdown ? { shutdown: options.shutdown } : {}) };
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: only the standard alphabet and whitespace, `=` padding only at the end, a valid length, and
46
- * no stray trailing bits. Node's decoder skips anything else silently, which would send a corrupted file.
45
+ * Decode base64 strictly: one alphabet (standard, or URL-safe with - and _) plus whitespace, `=` padding only at the
46
+ * end, a valid length, and no stray trailing bits. Node's decoder skips anything else silently, which would send a
47
+ * corrupted file.
47
48
  */
48
49
  export declare function decodeBase64Strict(filename: string, input: string): Buffer;
49
50
  /** Validate every attachment before anything is created upstream, so a bad one never leaves a draft behind. */
@@ -47,14 +47,22 @@ export function attachmentInput(config) {
47
47
  });
48
48
  }
49
49
  /**
50
- * Decode base64 strictly: only the standard alphabet and whitespace, `=` padding only at the end, a valid length, and
51
- * no stray trailing bits. Node's decoder skips anything else silently, which would send a corrupted file.
50
+ * Decode base64 strictly: one alphabet (standard, or URL-safe with - and _) plus whitespace, `=` padding only at the
51
+ * end, a valid length, and no stray trailing bits. Node's decoder skips anything else silently, which would send a
52
+ * corrupted file.
52
53
  */
53
54
  export function decodeBase64Strict(filename, input) {
54
- const bad = /[^A-Za-z0-9+/=\s]/u.exec(input);
55
+ const bad = /[^A-Za-z0-9+/\-_=\s]/u.exec(input);
55
56
  if (bad)
56
57
  throw new Error(`attachment ${filename}: invalid base64 character ${JSON.stringify(bad[0])} at position ${bad.index}`);
57
- const compact = input.replace(/\s+/gu, "");
58
+ const standard = /[+/]/u.exec(input);
59
+ const urlSafe = /[-_]/u.exec(input);
60
+ if (standard && urlSafe) {
61
+ const [first, second] = standard.index < urlSafe.index ? [standard, urlSafe] : [urlSafe, standard];
62
+ throw new Error(`attachment ${filename}: base64 mixes the standard and URL-safe alphabets (${JSON.stringify(first[0])} at position ${first.index}, ` +
63
+ `${JSON.stringify(second[0])} at position ${second.index}); part of the data may be corrupt`);
64
+ }
65
+ const compact = input.replace(/\s+/gu, "").replace(/-/gu, "+").replace(/_/gu, "/");
58
66
  const padding = compact.indexOf("=");
59
67
  const unpadded = padding < 0 ? compact : compact.slice(0, padding);
60
68
  if ((padding >= 0 && (!/^={1,2}$/u.test(compact.slice(padding)) || compact.length % 4 !== 0)) || unpadded.length % 4 === 1) {
@@ -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
  };
@@ -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, {
@@ -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 short names resolve; the text " +
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)
@@ -202,6 +202,7 @@ export const registerReadTools = (server, deps) => {
202
202
  size: z.number(),
203
203
  content_type: z.string().describe("media type; the same value as type"),
204
204
  type: z.string().optional().describe("media type, named as on message attachment lists"),
205
+ text: z.string().optional().describe("the file's contents, for text attachments"),
205
206
  ...untrustedNotice,
206
207
  }),
207
208
  annotations: READ_ONLY,
@@ -223,8 +224,11 @@ export const registerReadTools = (server, deps) => {
223
224
  const type = contentType ?? "application/octet-stream";
224
225
  const base = { id, filename, size: data.byteLength, content_type: type, type, ...UNTRUSTED };
225
226
  const metadata = `${filename} (${data.byteLength} bytes, ${type}) from message ${id}`;
226
- if (type.toLowerCase().startsWith("text/"))
227
- return ok(messageData(`${metadata}\n\n${Buffer.from(data).toString("utf8")}`), base);
227
+ if (type.toLowerCase().startsWith("text/")) {
228
+ // Some hosts show models only structured content, so the text is there too.
229
+ const text = Buffer.from(data).toString("utf8");
230
+ return ok(messageData(`${metadata}\n\n${text}`), { ...base, text });
231
+ }
228
232
  const b64 = Buffer.from(data).toString("base64");
229
233
  const uri = `fmsg://message/${id}/attachment/${encodeURIComponent(filename)}`;
230
234
  const result = {
@@ -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: recipients, resolved } = resolveRecipients(to, resolverFor(deps, caller));
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 { to, resolved } = target;
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
- throw new UploadBodyError(`the ${encoding} request body is invalid or truncated: ${error instanceof Error ? error.message : String(error)}`);
75
+ const code = error?.code;
76
+ const detail = code === "Z_BUF_ERROR" ? "the data ends early" : error instanceof Error ? error.message || code : String(error);
77
+ throw new UploadBodyError(`the ${encoding} request body is invalid or truncated${detail ? ` (${detail})` : ""}`);
40
78
  }
41
79
  if (chunk.done)
42
80
  controller.close();
@@ -110,7 +110,8 @@ The server streams the body straight into the fmsg draft (`POST /fmsg/:id/attach
110
110
  storing it, so the draft and its attachments are visible in the user's other fmsg clients and the
111
111
  fmsg host's own size limits apply; its refusal comes back with its status and text. OAuth requires
112
112
  `fmsg:write`. The response is `201` with `filename`, `size`, `sha256` and `type`. The fmsg host may
113
- rename a duplicate filename; the response gives the stored name.
113
+ rename a duplicate filename; the response gives the stored name. Errors are JSON `{"error": "..."}`,
114
+ and the fmsg host's own reason is passed through when it gives one.
114
115
 
115
116
  - `x-content-sha256` (optional, hex): a mismatch answers `422`, and the attachment is removed again.
116
117
  - `Content-Encoding: gzip` or `deflate` (optional): the server decodes the body while streaming, so
@@ -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.1.0",
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.1.0",
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.1.0",
17
+ "version": "1.2.0",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },