@markmnl/fmsg-mcp 1.2.3 → 1.4.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,31 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - FMSG-006 indicators. The new `indicate` tool shows the other participants of a message a short-lived
6
+ status such as ⏳ or ✍️, refreshed at half its ttl (every 1–30 s) and cleared when the agent replies in
7
+ that thread, reacts, marks it read, after 5 minutes, or when the session ends; an empty emoji clears it
8
+ now. Nothing is shown automatically: reading a message never starts one. Refreshes and automatic clears
9
+ are best effort and never fail a tool. `FmsgClient.indicate()` posts to `/fmsg/{id}/indicate`;
10
+ `createFmsgMcpServer` takes an optional shared `indicators` registry for per-request HTTP factories.
11
+ - Presigned links for hosts that cannot send the connection's Authorization header with their own HTTP
12
+ requests: `get_attachment_download_url` with `presigned: true`, and the new `get_attachment_upload_url` for
13
+ draft files. Each link is single-use, valid for 5 minutes and bound to one file's size and sha256 (`/mcp/links/{token}`),
14
+ and comes with its `expires_at`, `size`, `sha256` and a ready-to-run `curl` command. A download whose
15
+ attachment changed since the link was issued is refused or cut off before its last bytes. `FMSG_MCP_STATE_SECRET` (at least 32 bytes)
16
+ sets the signing key.
17
+ - `data_base64` is described as best effort, because the base64 passes through the model: fine for small
18
+ files (verified exact up to 16 KB), always with `sha256`, and `path` or a presigned link for anything larger.
19
+ - A recipient added to a message after it was sent can reply to it. The Web API takes such a reply only
20
+ through the hash of the add-to batch that added them; `reply`, `create_draft` (and so `send_draft`) now
21
+ send it as the parent, where before the host refused with "not a participant of this parent". A reply
22
+ from an added recipient to an earlier message in the thread, which they cannot reply to, is refused with
23
+ an error naming the message they were added to.
24
+ - `whoami` reports the server version in its structured result (`server_version`, plus `server_commit` and
25
+ `server_build` when recorded), not only in its text, which hosts that show models only the structured
26
+ result never passed on. `/healthz` adds the commit. `npm run build` records the version and commit from
27
+ git; the Docker image takes them as build arguments. Builds from source no longer report the stale
28
+ `package.json` version 0.1.4: it now matches the last release, and a release tag takes precedence.
29
+
5
30
  - Over OAuth, the owner comes from the `owner` claim the IdP puts on the app's access token
6
31
  (and on the exchanged Web API token), so agents that sign in (ChatGPT connectors) include
7
32
  their owner like API-key callers do. 1.2.0 only read the API-key token's claim, so a
package/README.md CHANGED
@@ -98,12 +98,16 @@ server exchanges it at the fmsg host and acts as that address. `FMSG_API_KEY` mu
98
98
  FMSG_API_URL=https://api.example.com FMSG_MCP_ALLOWED_HOSTS=mcp.example.com \
99
99
  npx -y @markmnl/fmsg-mcp --http 0.0.0.0:8765
100
100
  # or
101
- docker build -t fmsg-mcp .
101
+ docker build -t fmsg-mcp --build-arg FMSG_MCP_COMMIT=$(git rev-parse HEAD) \
102
+ --build-arg FMSG_MCP_VERSION=$(git describe --tags --abbrev=0) .
102
103
  docker run -e FMSG_API_URL=https://api.example.com \
103
104
  -e FMSG_MCP_ALLOWED_HOSTS=mcp.example.com -p 8765:8765 fmsg-mcp
104
105
  ```
105
106
 
106
- The MCP endpoint is `/mcp`; `/healthz` reports liveness. Use a client that supports an explicitly
107
+ The MCP endpoint is `/mcp`; `/healthz` reports liveness, the version and the commit. `whoami` reports the
108
+ same, so a client can confirm which deploy it is talking to: `npm run build` records them from git, and the
109
+ Docker build arguments above do so where the build has no git history (`FMSG_MCP_COMMIT` can also be set at
110
+ run time). Use a client that supports an explicitly
107
111
  configured `Authorization: Bearer fmsgk_...` header. Each caller supplies its own key; a shared
108
112
  header means a shared fmsg identity. For browser sign-in without user API keys, configure
109
113
  [HTTP OAuth](docs/oauth.md): the operator supplies an issuer, resource URI and exchange client.
@@ -133,7 +137,7 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
133
137
 
134
138
  | Tool | What it does |
135
139
  |---|---|
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 |
140
+ | `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), short-name defaults, and this server's version and commit |
137
141
  | `resolve_address` | Turn a short name into `@user@domain` (directory, then default domain) |
138
142
  | `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` |
139
143
  | `list_sent` | Sent messages with per-recipient delivery state |
@@ -146,9 +150,11 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
146
150
  | `delete_draft` | Discard an unsent draft |
147
151
  | `add_recipients` | Add `recipients` to a sent message (`add_to` is a deprecated alias) |
148
152
  | `react` | Set or clear your emoji reaction |
153
+ | `indicate` | Show the other participants a short-lived status on a message, such as ⏳ while working on a reply or ✍️ while composing (FMSG-006 indicator; `ttl` 1–120 s, default 60), kept up until you reply, react or mark it read (up to 5 minutes); an empty `emoji` clears it. Reading a message never shows one |
149
154
  | `mark_read` | Mark received messages read |
150
155
  | `download_attachment` | Fetch a small attachment inline: text as text, images as image blocks, other files as base64 resources; a wrong filename lists the names the message has |
151
- | `get_attachment_download_url` | Get a credential-free URL and resource link for an authenticated binary download; HTTP only, requires a public MCP URL; hosts that cannot send the connection's Authorization header use `download_attachment` |
156
+ | `get_attachment_download_url` | Get a credential-free URL and resource link for an authenticated binary download; with `presigned: true`, also a single-use link valid for 5 minutes that needs no Authorization header, with its size, sha256 and a curl command. HTTP only, requires a public MCP URL |
157
+ | `get_attachment_upload_url` | Get a single-use link valid for 5 minutes that PUTs one file of a given size and sha256 into a draft without the Authorization header, with a curl command; then `send_draft`. HTTP only, requires a public MCP URL |
152
158
  | `save_attachment` | Stream an attachment to the configured local folder; stdio only, enabled by `FMSG_MCP_DOWNLOAD_DIR` |
153
159
  | `delivery_status` | Per-recipient delivery times and fmsg response codes (`200` accepted; `null` when not recorded); for a received message, the host's own receipt records; `via` is `to` or `add_to` (added later) |
154
160
  | `wait_for_message` | Block until the next inbound message (WebSocket push), batched per thread, with thread context, skipped reactions and a `next` step that names messages from other threads (`pending_ids`) the cursor moved past |
@@ -174,6 +180,21 @@ required. Releases add fields and values; they do not remove or rename them. Sch
174
180
  0.2.6 and earlier were closed, so a host that cached one rejects results with newer fields (for
175
181
  example "must NOT have additional properties"); remove and re-add the connector once to refresh it.
176
182
 
183
+ ### Indicators
184
+
185
+ `indicate` shows a message's other participants a short-lived status, such as ⏳ while the agent
186
+ works on a reply or ✍️ while composing: an
187
+ [FMSG-006](https://github.com/markmnl/fmsg/blob/main/standards/fmsg-006-indicators.md) indicator.
188
+ Nothing is shown unless the agent calls it; reading a message never does. It is sent at once with
189
+ its `ttl` (default 60 s) and refreshed at half the ttl (every 1–30 s) until the agent replies to
190
+ that message or anywhere in its thread (`reply`, or `send_draft` of a reply), reacts to it, marks it
191
+ read, clears it with an empty `emoji`, or after 5 minutes, and when the session ends or the server
192
+ shuts down. At most 10 are kept up per address, the oldest cleared first. `indicate` returns the
193
+ host's refusal (such as a terminal message, or a host without indicators); refreshes and automatic
194
+ clears are best effort and never fail another tool. Over HTTP each MCP request gets a fresh server
195
+ instance, so indicators are kept per address by the HTTP server; embedders that build one server per
196
+ request pass a shared `IndicatorRegistry` as `indicators`.
197
+
177
198
  Resources `fmsg://message/{id}` and `fmsg://thread/{id}` expose the same content to hosts that
178
199
  attach resources; prompts `chat` and `reply` script the wait → reply loop and a guided reply, and
179
200
  `send_file` sends files by the most efficient route the server offers.
@@ -193,6 +214,7 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
193
214
  | `FMSG_MCP_DOWNLOAD_DIR` | — | Enable `save_attachment` in stdio; folder for new files named from message ID and filename |
194
215
  | `FMSG_MCP_UPLOAD_DIR` | — | Allow attachment `path` arguments in stdio: files in this folder (or below it) are read by the server instead of written out by the model |
195
216
  | `FMSG_MCP_WAIT_MAX_SECONDS` | `230` | Cap on one `wait_for_message` call |
217
+ | `FMSG_MCP_STATE_SECRET` | per-process random | At least 32 bytes; HMAC key for presigned links. Links still work only on the instance that issued them |
196
218
  | `FMSG_MCP_HOST` / `FMSG_MCP_PORT` | `127.0.0.1` / `8765` | HTTP bind address (or `--http host:port`) |
197
219
  | `FMSG_MCP_ALLOWED_HOSTS` | loopback names | Comma-separated `Host` header allowlist; required for non-loopback binds |
198
220
  | `FMSG_MCP_ALLOWED_ORIGINS` | same origin; loopback origins on loopback binds | Comma-separated browser origins including scheme and port; an explicit list replaces the loopback default; hostname-only values are rejected |
@@ -229,7 +251,9 @@ original Content-Type and download filename. No files are stored on the MCP serv
229
251
 
230
252
  `send_message`, `reply` and `create_draft` take each attachment as exactly one of:
231
253
 
232
- - `data_base64`: base64 written by the model; fine for small files. The standard or URL-safe
254
+ - `data_base64`: base64 written by the model. It passes through the model, so it is best effort: fine
255
+ for small files (verified exact up to 16 KB). Always pass `sha256` with it, and prefer `path` or a
256
+ presigned upload link for anything larger. The standard or URL-safe
233
257
  alphabet is accepted, but not both in one value. Decoding is strict: any other character, a bad
234
258
  length or padding, or a stray ending is refused with the attachment's name, rather than sent as a
235
259
  corrupted file.
@@ -244,6 +268,13 @@ with `x-content-sha256`), then `send_draft`; see
244
268
  [attachment uploads](docs/http-deployment.md#attachment-uploads). Send files as they are: fmsg
245
269
  compresses message bodies and attachments itself when it sends them.
246
270
 
271
+ Hosts that cannot attach the connection's credentials to their own HTTP requests can still keep file
272
+ bytes out of the model. `get_attachment_upload_url` returns a single-use, 5-minute link per draft
273
+ file, bound to its size and sha256, with a ready-to-run `curl` command. `get_attachment_download_url`
274
+ with `presigned: true` does the same for downloads. The link itself authorizes that one transfer. It
275
+ holds no API key or access token, but treat it as a secret until it is used or expires. It works only
276
+ on the server instance that issued it.
277
+
247
278
  Models learn the routes from the server instructions and tool descriptions, which name only the
248
279
  routes the running server offers. A model that writes a large file as base64 although a better
249
280
  route exists gets a `hint` in the result naming it. The `send_file` prompt walks through the best
@@ -0,0 +1,5 @@
1
+ {
2
+ "version": "1.4.0",
3
+ "commit": "36e8dafb2afbee867f5518c63b662cd500f24ddb",
4
+ "describe": "v1.4.0"
5
+ }
@@ -90,6 +90,12 @@ export declare class FmsgClient {
90
90
  added: number;
91
91
  }>;
92
92
  react(id: string, emoji: string | null, signal?: AbortSignal): Promise<ReactResult>;
93
+ /**
94
+ * Show an FMSG-006 indicator, a short-lived emoji such as ⏳, on a message to its other participants for `ttl`
95
+ * seconds (1 to 120; the host clamps it). An empty emoji or a ttl of 0 clears it. The host answers 202 with no body
96
+ * and may drop indicators set more than once a second on one message. Hosts without indicators answer 404 or 405.
97
+ */
98
+ indicate(id: string, emoji: string, ttl: number, signal?: AbortSignal): Promise<void>;
93
99
  downloadAttachment(id: string, filename: string, signal?: AbortSignal, maxBytes?: number): Promise<{
94
100
  data: Uint8Array;
95
101
  contentType?: string;
@@ -307,6 +307,21 @@ export class FmsgClient {
307
307
  time: result.time ?? null,
308
308
  };
309
309
  }
310
+ /**
311
+ * Show an FMSG-006 indicator, a short-lived emoji such as ⏳, on a message to its other participants for `ttl`
312
+ * seconds (1 to 120; the host clamps it). An empty emoji or a ttl of 0 clears it. The host answers 202 with no body
313
+ * and may drop indicators set more than once a second on one message. Hosts without indicators answer 404 or 405.
314
+ */
315
+ async indicate(id, emoji, ttl, signal) {
316
+ const mid = normalizeMessageId(id);
317
+ const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/indicate`, {
318
+ method: "POST",
319
+ headers: { "content-type": "application/json" },
320
+ body: JSON.stringify({ emoji, ttl: emoji ? ttl : 0 }),
321
+ ...(signal ? { signal } : {}),
322
+ });
323
+ await response.body?.cancel();
324
+ }
310
325
  async downloadAttachment(id, filename, signal, maxBytes) {
311
326
  const { stream, contentType } = await this.streamAttachment(id, filename, signal);
312
327
  const { data } = await readBytes(stream, maxBytes);
@@ -340,7 +355,9 @@ export class FmsgClient {
340
355
  ...(input.important ? { important: true } : {}),
341
356
  ...(input.noReply ? { no_reply: true } : {}),
342
357
  };
343
- const serialized = input.pid ? stringifyWithIds(body, { pid: input.pid }) : JSON.stringify(body);
358
+ // The Web API takes the parent as a numeric id or a hash string; an added recipient must use their batch's hash.
359
+ const serialized = input.parentSha256 ? JSON.stringify({ ...body, pid: input.parentSha256 })
360
+ : input.pid ? stringifyWithIds(body, { pid: input.pid }) : JSON.stringify(body);
344
361
  const response = await this.request("/fmsg", {
345
362
  method: "POST",
346
363
  headers: { "content-type": "application/json" },
@@ -361,6 +378,9 @@ export class FmsgClient {
361
378
  throw new Error("at least one recipient is required");
362
379
  if (input.pid && input.topic)
363
380
  throw new Error("a reply (pid) cannot carry a topic");
381
+ if (input.parentSha256 !== undefined && (!input.pid || !/^[0-9a-f]{64}$/u.test(input.parentSha256))) {
382
+ throw new Error("parentSha256 must be 64 lowercase hexadecimal characters, given with pid");
383
+ }
364
384
  const from = await this.address(input.signal);
365
385
  const body = redactSecrets(input.body);
366
386
  const topic = redactSecrets(input.pid ? "" : (input.topic ?? ""));
@@ -10,6 +10,8 @@ export type RecipientDelivery = {
10
10
  response_code: number | null;
11
11
  };
12
12
  export type AddToBatch = {
13
+ /** The batch's protocol hash (lowercase hex). A recipient added only by this batch replies with it as the parent. */
14
+ sha256?: string | null;
13
15
  batch_id?: string;
14
16
  add_to_from?: string;
15
17
  to?: string[];
@@ -27,6 +29,10 @@ export type ReactionGroup = {
27
29
  export type FmsgMessage = {
28
30
  /** Present on list items and WebSocket events; absent on GET /fmsg/:id (filled in by the client). */
29
31
  id: string;
32
+ /** Protocol hash (lowercase hex); null for drafts and on hosts that predate message hashes. */
33
+ sha256?: string | null;
34
+ /** The exact parent hash: the parent message's, or the add-to batch's a reply was made through. */
35
+ psha256?: string | null;
30
36
  version?: number;
31
37
  has_pid?: boolean;
32
38
  has_add_to?: boolean;
@@ -121,6 +127,11 @@ export type SendInput = {
121
127
  type?: string;
122
128
  topic?: string;
123
129
  pid?: string;
130
+ /**
131
+ * Reply through this hash instead of `pid`: the add-to batch that added the sender to the parent. A recipient
132
+ * added after a message was sent may reply to it only this way. `pid` must still name the parent.
133
+ */
134
+ parentSha256?: string;
124
135
  important?: boolean;
125
136
  noReply?: boolean;
126
137
  attachments?: OutboundAttachment[];
package/dist/config.d.ts CHANGED
@@ -11,6 +11,11 @@ export type HttpConfig = {
11
11
  allowedOrigins: string[];
12
12
  keyCacheMax: number;
13
13
  keyCacheTtlMs: number;
14
+ /**
15
+ * FMSG_MCP_STATE_SECRET: the HMAC key for presigned links, file-upload targets and requestState, so any instance
16
+ * can verify what another issued. Without it each process makes its own.
17
+ */
18
+ stateSecret?: string;
14
19
  };
15
20
  export type Config = {
16
21
  transport: Transport;
package/dist/config.js CHANGED
@@ -55,6 +55,15 @@ function resolveApiPublicUrl(env, transport, apiUrl, allowInsecureHttp) {
55
55
  return undefined;
56
56
  return apiUrl;
57
57
  }
58
+ /** FMSG_MCP_STATE_SECRET, checked; HTTP only. */
59
+ function stateSecret(env, transport) {
60
+ const secret = env.FMSG_MCP_STATE_SECRET?.trim();
61
+ if (transport !== "http" || !secret)
62
+ return undefined;
63
+ if (Buffer.byteLength(secret) < 32)
64
+ throw new Error("FMSG_MCP_STATE_SECRET must be at least 32 bytes");
65
+ return secret;
66
+ }
58
67
  export function loadConfig(env, transport, overrides = {}, options = {}) {
59
68
  const requireCredentials = options.requireCredentials ?? true;
60
69
  const oauth = loadOAuthConfig(env, transport);
@@ -106,6 +115,7 @@ export function loadConfig(env, transport, overrides = {}, options = {}) {
106
115
  allowedOrigins: listEnv(env, "FMSG_MCP_ALLOWED_ORIGINS").map(normalizeOrigin),
107
116
  keyCacheMax: intEnv(env, "FMSG_MCP_KEY_CACHE_MAX", 500),
108
117
  keyCacheTtlMs: intEnv(env, "FMSG_MCP_KEY_CACHE_TTL_SECONDS", 1800) * 1000,
118
+ ...(stateSecret(env, transport) ? { stateSecret: stateSecret(env, transport) } : {}),
109
119
  },
110
120
  };
111
121
  }
package/dist/http.js CHANGED
@@ -7,15 +7,18 @@ import { OAuthCallerProvider } from "./oauth/provider.js";
7
7
  import { invalidToken, insufficientScope, missingToken, notBearer, 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_DRAIN_IDLE_MS, UPLOAD_PATH, UploadBodyError, decodedUploadBody, drainableBody, parseUploadPath, uploadEncoding } from "./upload.js";
10
+ import { CHECKSUM_HEADER, UPLOAD_DRAIN_IDLE_MS, UPLOAD_PATH, UploadBodyError, decodedUploadBody, drainableBody, parseUploadPath, streamIntoDraft, uploadEncoding } from "./upload.js";
11
11
  import { AttachmentChecksumError, UploadInterruptedError } from "./client/client.js";
12
12
  import { FmsgHttpError } from "./client/errors.js";
13
13
  import { sameAddress } from "./address.js";
14
14
  import { attachmentType } from "./render.js";
15
15
  import { describeError } from "./errors.js";
16
- import { VERSION } from "./version.js";
16
+ import { BUILD, VERSION } from "./version.js";
17
17
  import { safeErrorMessage } from "./client/redact.js";
18
18
  import { isLoopbackHost, normalizeOrigin } from "./client/url.js";
19
+ import { LINK_PATH, PresignedLinks } from "./links.js";
20
+ import { IndicatorRegistry } from "./indicators.js";
21
+ import { createHash } from "node:crypto";
19
22
  export const MCP_PATH = "/mcp";
20
23
  /** How long close() lets in-flight requests finish (waits end at once) before cutting them off. */
21
24
  export const SHUTDOWN_GRACE_MS = 5000;
@@ -117,6 +120,99 @@ async function draftRefusal(caller, id, signal) {
117
120
  return `message ${id} is not your draft; only its sender can add attachments`;
118
121
  return undefined;
119
122
  }
123
+ /** A presigned download's attachment is no longer the bytes its link was issued for. */
124
+ class AttachmentChangedError extends Error {
125
+ constructor() { super("the attachment changed since this link was issued; ask for a new link"); this.name = "AttachmentChangedError"; }
126
+ }
127
+ /**
128
+ * The body as a stream that fails unless it is exactly `size` bytes with SHA-256 `sha256`. The last chunk is held
129
+ * back until the whole body is verified, so a client never receives all `size` bytes of a file that does not match.
130
+ */
131
+ function verified(body, size, sha256) {
132
+ const hash = createHash("sha256");
133
+ let read = 0;
134
+ let held;
135
+ const changed = () => new AttachmentChangedError();
136
+ return body.pipeThrough(new TransformStream({
137
+ transform(chunk, controller) {
138
+ read += chunk.byteLength;
139
+ if (read > size) {
140
+ controller.error(changed());
141
+ return;
142
+ }
143
+ hash.update(chunk);
144
+ if (held)
145
+ controller.enqueue(held);
146
+ held = chunk;
147
+ },
148
+ flush(controller) {
149
+ if (read !== size || hash.digest("hex") !== sha256) {
150
+ controller.error(changed());
151
+ return;
152
+ }
153
+ if (held)
154
+ controller.enqueue(held);
155
+ },
156
+ }));
157
+ }
158
+ /**
159
+ * Serve a presigned link: no Authorization header, the link itself authorizes one transfer. GET streams the
160
+ * attachment, cutting the transfer off if its bytes are not the size and SHA-256 the link was issued for; PUT
161
+ * takes one file of the declared size and SHA-256 into the draft.
162
+ */
163
+ async function serveLink(links, url, request, res, signal) {
164
+ const refuse = (status, error, headers = {}) => sendWebResponse(res, Response.json({ error }, { status, headers }));
165
+ const method = request.method;
166
+ if (method !== "GET" && method !== "PUT")
167
+ return refuse(405, "use GET or PUT, as the link says", { allow: "GET, PUT, OPTIONS" });
168
+ if (url.search)
169
+ return refuse(400, "presigned links must not have query parameters");
170
+ const token = url.pathname.slice(LINK_PATH.length + 1);
171
+ if (!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u.test(token))
172
+ return refuse(404, "unknown link");
173
+ const resolved = links.resolve(token, method);
174
+ if ("status" in resolved)
175
+ return refuse(resolved.status, resolved.error);
176
+ const { claims, caller } = resolved;
177
+ try {
178
+ if (claims.k === "get") {
179
+ const { stream, contentType } = await caller.client.streamAttachment(claims.m, claims.n, signal);
180
+ // Spend the link only once the attachment is there, so a concurrent request cannot use it twice.
181
+ if (!links.consume(claims.j)) {
182
+ await stream.cancel();
183
+ return refuse(409, "this link was already used; each link downloads once");
184
+ }
185
+ const headers = downloadHeaders(claims.n, contentType);
186
+ headers.set("content-length", String(claims.s));
187
+ headers.set(CHECKSUM_HEADER, claims.h);
188
+ return await sendWebResponse(res, new Response(verified(stream, claims.s, claims.h), { headers }));
189
+ }
190
+ let encoding;
191
+ try {
192
+ encoding = uploadEncoding(request.headers.get("content-encoding"));
193
+ }
194
+ catch (error) {
195
+ return refuse(415, error.message);
196
+ }
197
+ const response = await streamIntoDraft(links, caller, {
198
+ draft: claims.m, filename: claims.n, size: claims.s, sha256: claims.h, mimeType: claims.t ?? attachmentType(claims.n),
199
+ }, request, encoding, signal, "link");
200
+ return await sendWebResponse(res, response);
201
+ }
202
+ catch (error) {
203
+ if (error.undrained || res.headersSent) {
204
+ res.destroy();
205
+ return;
206
+ }
207
+ if (res.destroyed || signal.aborted)
208
+ return;
209
+ if (error instanceof AttachmentChangedError)
210
+ return refuse(409, error.message);
211
+ const status = error instanceof AttachmentChecksumError ? 422 : error instanceof UploadBodyError ? 400
212
+ : error instanceof FmsgHttpError && error.status >= 400 && error.status <= 599 && error.status !== 401 ? error.status : 502;
213
+ return refuse(status, describeError(error));
214
+ }
215
+ }
120
216
  export function createHttpServer(config, log = (l) => console.error(l)) {
121
217
  const allowedHosts = config.http.allowedHosts.length
122
218
  ? config.http.allowedHosts
@@ -138,11 +234,22 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
138
234
  const metadataPath = resource ? `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}` : undefined;
139
235
  const metadataUrl = resource ? `${resource.origin}${metadataPath}` : "";
140
236
  const shutdown = new AbortController();
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)}`) });
237
+ // Presigned links need the public URL to build them; they carry their own authorization.
238
+ const links = downloadBaseUrl(config) ? new PresignedLinks(downloadBaseUrl(config), config.http.stateSecret) : undefined;
239
+ // Each MCP request gets its own server instance, so indicators kept up between requests live here, one registry
240
+ // per provider so that combined mode never shares a caller's indicators across authentication paths.
241
+ const registries = [];
242
+ const mcpHandler = (provider, address) => {
243
+ const indicators = new IndicatorRegistry();
244
+ registries.push(indicators);
245
+ return createMcpHandler(({ authInfo }) => createFmsgMcpServer(provider, config, {
246
+ ...(authInfo ? { address: address(authInfo) } : {}),
247
+ ...(typeof authInfo?.extra?.owner === "string" ? { owner: authInfo.extra.owner } : {}),
248
+ shutdown: shutdown.signal,
249
+ ...(links ? { links } : {}),
250
+ indicators,
251
+ }), { onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) });
252
+ };
146
253
  const oauthHandler = oauthProvider && mcpHandler(oauthProvider, (authInfo) => String(authInfo.extra?.address ?? ""));
147
254
  const apiKeyHandler = apiKeyProvider && mcpHandler(apiKeyProvider, (authInfo) => authInfo.clientId);
148
255
  const active = new Map();
@@ -160,18 +267,20 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
160
267
  res.once("close", abort);
161
268
  const url = new URL(req.url ?? "/", "http://localhost");
162
269
  const isUpload = url.pathname.startsWith(`${UPLOAD_PATH}/`);
270
+ const isLink = Boolean(links) && url.pathname.startsWith(`${LINK_PATH}/`);
271
+ const streamsBody = isUpload || (isLink && req.method === "PUT");
163
272
  // A progressing upload may take longer than any fixed budget; the upload stream has an idle timeout instead.
164
273
  // Closing the connection afterwards stops Node reading the rest of an upload refused before authentication;
165
274
  // an authenticated caller's failed upload is drained first so its answer arrives (see serveUpload).
166
- const bodyTimer = isUpload ? undefined : setTimeout(() => { if (!req.complete)
275
+ const bodyTimer = streamsBody ? undefined : setTimeout(() => { if (!req.complete)
167
276
  req.destroy(); }, REQUEST_BODY_TIMEOUT_MS).unref();
168
- if (isUpload)
277
+ if (streamsBody)
169
278
  res.setHeader("connection", "close");
170
279
  const done = (async () => {
171
280
  const isDownload = url.pathname.startsWith(`${DOWNLOAD_PATH}/`);
172
281
  if (url.pathname === "/healthz") {
173
282
  res.writeHead(200, { "content-type": "application/json" });
174
- res.end(JSON.stringify({ ok: true, name: "fmsg-mcp", version: VERSION }));
283
+ res.end(JSON.stringify({ ok: true, name: "fmsg-mcp", version: VERSION, ...(BUILD.commit ? { commit: BUILD.commit } : {}) }));
175
284
  return;
176
285
  }
177
286
  if (resource && (url.pathname === metadataPath || url.pathname === "/.well-known/oauth-protected-resource")) {
@@ -187,7 +296,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
187
296
  authorization_servers: [config.oauth.issuerUrl], scopes_supported: MESSAGING_SCOPES,
188
297
  bearer_methods_supported: ["header"], resource_name: "fmsg messaging", }, { headers }));
189
298
  }
190
- if (url.pathname !== MCP_PATH && !isDownload && !isUpload) {
299
+ if (url.pathname !== MCP_PATH && !isDownload && !isUpload && !isLink) {
191
300
  res.writeHead(404, { "content-type": "text/plain" });
192
301
  res.end("not found; the MCP endpoint is /mcp");
193
302
  return;
@@ -219,7 +328,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
219
328
  if (req.method === "OPTIONS") {
220
329
  const method = request.headers.get("access-control-request-method") ?? "";
221
330
  const headers = (request.headers.get("access-control-request-headers") ?? "").split(",").map((v) => v.trim().toLowerCase()).filter(Boolean);
222
- const methods = isDownload ? ["GET"] : isUpload ? ["PUT"] : CORS_METHODS;
331
+ const methods = isDownload ? ["GET"] : isUpload ? ["PUT"] : isLink ? ["GET", "PUT"] : CORS_METHODS;
223
332
  if (!origin || !methods.includes(method) || headers.some((h) => !CORS_HEADERS.includes(h))) {
224
333
  return sendWebResponse(res, new Response("preflight not allowed", { status: 403 }));
225
334
  }
@@ -241,6 +350,8 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
241
350
  return sendWebResponse(res, new Response("invalid attachment download path", { status: 400 }));
242
351
  }
243
352
  }
353
+ if (isLink)
354
+ return serveLink(links, url, request, res, controller.signal);
244
355
  let upload;
245
356
  if (isUpload) {
246
357
  // Refusals are JSON, like the errors from the upload itself.
@@ -439,6 +550,8 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
439
550
  const close = ({ graceMs = SHUTDOWN_GRACE_MS } = {}) => closing ??= (async () => {
440
551
  // Waits answer "interrupted" with a resumable cursor; new requests get 503.
441
552
  shutdown.abort();
553
+ for (const indicators of registries)
554
+ indicators.close();
442
555
  const closed = new Promise((resolve) => server.close(() => resolve()));
443
556
  server.closeIdleConnections();
444
557
  let timer;
@@ -454,6 +567,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
454
567
  }
455
568
  oauthProvider?.close();
456
569
  apiKeyProvider?.close();
570
+ links?.close();
457
571
  await Promise.all([oauthHandler?.close(), apiKeyHandler?.close()]);
458
572
  // Aborted fetches may open replacement sockets without a request, so
459
573
  // they are absent from `active` and must also be closed on shutdown.
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { loadConfig, DEFAULT_HTTP_PORT } from "./config.js";
8
8
  import { StaticCallerProvider, UnconfiguredCallerProvider } from "./context.js";
9
9
  import { createHttpServer, MCP_PATH, SHUTDOWN_GRACE_MS } from "./http.js";
10
10
  import { createFmsgMcpServer } from "./server.js";
11
- import { PACKAGE_NAME, VERSION } from "./version.js";
11
+ import { PACKAGE_NAME, VERSION, VERSION_LABEL } from "./version.js";
12
12
  const USAGE = `${PACKAGE_NAME} ${VERSION} — MCP server for fmsg
13
13
 
14
14
  Usage:
@@ -115,7 +115,7 @@ async function main() {
115
115
  }
116
116
  else if (cfg.apiUrl && cfg.apiKey) {
117
117
  provider = new StaticCallerProvider(new FmsgClient(cfg.apiUrl, cfg.apiKey, { allowInsecureHttp: cfg.allowInsecureHttp }));
118
- console.error(safeErrorMessage(`fmsg-mcp ${VERSION} serving stdio for ${cfg.apiUrl}`));
118
+ console.error(safeErrorMessage(`fmsg-mcp ${VERSION_LABEL} serving stdio for ${cfg.apiUrl}`));
119
119
  }
120
120
  else {
121
121
  const missing = [!cfg.apiUrl && "FMSG_API_URL", !cfg.apiKey && "FMSG_API_KEY"].filter(Boolean).join(" and ");
@@ -153,7 +153,7 @@ async function main() {
153
153
  });
154
154
  const addr = server.address();
155
155
  const shown = typeof addr === "object" && addr ? `${addr.address}:${addr.port}` : `${config.http.host}:${config.http.port}`;
156
- console.error(safeErrorMessage(`fmsg-mcp ${VERSION} serving Streamable HTTP at http://${shown}${MCP_PATH} for ${config.apiUrl}`));
156
+ console.error(safeErrorMessage(`fmsg-mcp ${VERSION_LABEL} serving Streamable HTTP at http://${shown}${MCP_PATH} for ${config.apiUrl}`));
157
157
  onShutdown(() => close());
158
158
  }
159
159
  /** Stop on SIGINT/SIGTERM within a bounded time; a second signal exits at once. */
@@ -0,0 +1,35 @@
1
+ import type { Caller } from "./context.js";
2
+ /** The emoji indicate shows by default: working on a reply. */
3
+ export declare const WORKING_EMOJI = "\u23F3";
4
+ export type IndicatorOptions = {
5
+ /** How often an indicator is re-sent (default 30 s); never more often than half its ttl allows, nor below 1 s. */
6
+ refreshMs?: number;
7
+ /** How long an indicator is kept up before it is cleared (default 5 minutes). */
8
+ maxMs?: number;
9
+ /** Most indicators kept up at once per caller (default 10); the oldest is cleared to make room. */
10
+ maxActive?: number;
11
+ /** Debug log for swallowed failures; nothing is logged by default. */
12
+ log?: (line: string) => void;
13
+ };
14
+ export declare class IndicatorRegistry {
15
+ private readonly callers;
16
+ private readonly settings;
17
+ private closed;
18
+ constructor(options?: IndicatorOptions);
19
+ /** The caller's indicators, if any, switched to its current client (a renewed OAuth token brings a new one). */
20
+ private existing;
21
+ private of;
22
+ /** After a reply was sent to `parentId`: stop what is shown on it and anywhere in its thread. */
23
+ replied(caller: Caller, parentId: string, rootId: string | null): void;
24
+ /** After a reaction or mark_read: the caller has handled these messages. */
25
+ handled(caller: Caller, ids: string[]): void;
26
+ /**
27
+ * The indicate tool: show `emoji` and keep it up, or clear with an empty one. Throws the host's refusal. `rootId` is
28
+ * the message's thread root, when known, so a reply anywhere in that thread stops it.
29
+ */
30
+ indicate(caller: Caller, id: string, emoji: string, ttl: number, rootId?: string | null): Promise<void>;
31
+ /** Message ids with an indicator kept up for this caller. */
32
+ active(caller: Caller): string[];
33
+ /** Stop every indicator, clearing them in the background. Idempotent. */
34
+ close(): void;
35
+ }