@markmnl/fmsg-mcp 1.2.3 → 1.3.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,25 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Presigned links for hosts that cannot send the connection's Authorization header with their own HTTP
6
+ requests: `get_attachment_download_url` with `presigned: true`, and the new `get_attachment_upload_url` for
7
+ draft files. Each link is single-use, valid for 5 minutes and bound to one file's size and sha256 (`/mcp/links/{token}`),
8
+ and comes with its `expires_at`, `size`, `sha256` and a ready-to-run `curl` command. A download whose
9
+ attachment changed since the link was issued is refused or cut off before its last bytes. `FMSG_MCP_STATE_SECRET` (at least 32 bytes)
10
+ sets the signing key.
11
+ - `data_base64` is described as best effort, because the base64 passes through the model: fine for small
12
+ files (verified exact up to 16 KB), always with `sha256`, and `path` or a presigned link for anything larger.
13
+ - A recipient added to a message after it was sent can reply to it. The Web API takes such a reply only
14
+ through the hash of the add-to batch that added them; `reply`, `create_draft` (and so `send_draft`) now
15
+ send it as the parent, where before the host refused with "not a participant of this parent". A reply
16
+ from an added recipient to an earlier message in the thread, which they cannot reply to, is refused with
17
+ an error naming the message they were added to.
18
+ - `whoami` reports the server version in its structured result (`server_version`, plus `server_commit` and
19
+ `server_build` when recorded), not only in its text, which hosts that show models only the structured
20
+ result never passed on. `/healthz` adds the commit. `npm run build` records the version and commit from
21
+ git; the Docker image takes them as build arguments. Builds from source no longer report the stale
22
+ `package.json` version 0.1.4: it now matches the last release, and a release tag takes precedence.
23
+
5
24
  - Over OAuth, the owner comes from the `owner` claim the IdP puts on the app's access token
6
25
  (and on the exchanged Web API token), so agents that sign in (ChatGPT connectors) include
7
26
  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 |
@@ -148,7 +152,8 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
148
152
  | `react` | Set or clear your emoji reaction |
149
153
  | `mark_read` | Mark received messages read |
150
154
  | `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` |
155
+ | `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 |
156
+ | `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
157
  | `save_attachment` | Stream an attachment to the configured local folder; stdio only, enabled by `FMSG_MCP_DOWNLOAD_DIR` |
153
158
  | `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
159
  | `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 |
@@ -193,6 +198,7 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
193
198
  | `FMSG_MCP_DOWNLOAD_DIR` | — | Enable `save_attachment` in stdio; folder for new files named from message ID and filename |
194
199
  | `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
200
  | `FMSG_MCP_WAIT_MAX_SECONDS` | `230` | Cap on one `wait_for_message` call |
201
+ | `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
202
  | `FMSG_MCP_HOST` / `FMSG_MCP_PORT` | `127.0.0.1` / `8765` | HTTP bind address (or `--http host:port`) |
197
203
  | `FMSG_MCP_ALLOWED_HOSTS` | loopback names | Comma-separated `Host` header allowlist; required for non-loopback binds |
198
204
  | `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 +235,9 @@ original Content-Type and download filename. No files are stored on the MCP serv
229
235
 
230
236
  `send_message`, `reply` and `create_draft` take each attachment as exactly one of:
231
237
 
232
- - `data_base64`: base64 written by the model; fine for small files. The standard or URL-safe
238
+ - `data_base64`: base64 written by the model. It passes through the model, so it is best effort: fine
239
+ for small files (verified exact up to 16 KB). Always pass `sha256` with it, and prefer `path` or a
240
+ presigned upload link for anything larger. The standard or URL-safe
233
241
  alphabet is accepted, but not both in one value. Decoding is strict: any other character, a bad
234
242
  length or padding, or a stray ending is refused with the attachment's name, rather than sent as a
235
243
  corrupted file.
@@ -244,6 +252,13 @@ with `x-content-sha256`), then `send_draft`; see
244
252
  [attachment uploads](docs/http-deployment.md#attachment-uploads). Send files as they are: fmsg
245
253
  compresses message bodies and attachments itself when it sends them.
246
254
 
255
+ Hosts that cannot attach the connection's credentials to their own HTTP requests can still keep file
256
+ bytes out of the model. `get_attachment_upload_url` returns a single-use, 5-minute link per draft
257
+ file, bound to its size and sha256, with a ready-to-run `curl` command. `get_attachment_download_url`
258
+ with `presigned: true` does the same for downloads. The link itself authorizes that one transfer. It
259
+ holds no API key or access token, but treat it as a secret until it is used or expires. It works only
260
+ on the server instance that issued it.
261
+
247
262
  Models learn the routes from the server instructions and tool descriptions, which name only the
248
263
  routes the running server offers. A model that writes a large file as base64 although a better
249
264
  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.3.0",
3
+ "commit": "2e7b43195140e7ca0163084a3175ddd97baef3f8",
4
+ "describe": "v1.3.0"
5
+ }
@@ -340,7 +340,9 @@ export class FmsgClient {
340
340
  ...(input.important ? { important: true } : {}),
341
341
  ...(input.noReply ? { no_reply: true } : {}),
342
342
  };
343
- const serialized = input.pid ? stringifyWithIds(body, { pid: input.pid }) : JSON.stringify(body);
343
+ // The Web API takes the parent as a numeric id or a hash string; an added recipient must use their batch's hash.
344
+ const serialized = input.parentSha256 ? JSON.stringify({ ...body, pid: input.parentSha256 })
345
+ : input.pid ? stringifyWithIds(body, { pid: input.pid }) : JSON.stringify(body);
344
346
  const response = await this.request("/fmsg", {
345
347
  method: "POST",
346
348
  headers: { "content-type": "application/json" },
@@ -361,6 +363,9 @@ export class FmsgClient {
361
363
  throw new Error("at least one recipient is required");
362
364
  if (input.pid && input.topic)
363
365
  throw new Error("a reply (pid) cannot carry a topic");
366
+ if (input.parentSha256 !== undefined && (!input.pid || !/^[0-9a-f]{64}$/u.test(input.parentSha256))) {
367
+ throw new Error("parentSha256 must be 64 lowercase hexadecimal characters, given with pid");
368
+ }
364
369
  const from = await this.address(input.signal);
365
370
  const body = redactSecrets(input.body);
366
371
  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,17 @@ 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 { createHash } from "node:crypto";
19
21
  export const MCP_PATH = "/mcp";
20
22
  /** How long close() lets in-flight requests finish (waits end at once) before cutting them off. */
21
23
  export const SHUTDOWN_GRACE_MS = 5000;
@@ -117,6 +119,99 @@ async function draftRefusal(caller, id, signal) {
117
119
  return `message ${id} is not your draft; only its sender can add attachments`;
118
120
  return undefined;
119
121
  }
122
+ /** A presigned download's attachment is no longer the bytes its link was issued for. */
123
+ class AttachmentChangedError extends Error {
124
+ constructor() { super("the attachment changed since this link was issued; ask for a new link"); this.name = "AttachmentChangedError"; }
125
+ }
126
+ /**
127
+ * The body as a stream that fails unless it is exactly `size` bytes with SHA-256 `sha256`. The last chunk is held
128
+ * back until the whole body is verified, so a client never receives all `size` bytes of a file that does not match.
129
+ */
130
+ function verified(body, size, sha256) {
131
+ const hash = createHash("sha256");
132
+ let read = 0;
133
+ let held;
134
+ const changed = () => new AttachmentChangedError();
135
+ return body.pipeThrough(new TransformStream({
136
+ transform(chunk, controller) {
137
+ read += chunk.byteLength;
138
+ if (read > size) {
139
+ controller.error(changed());
140
+ return;
141
+ }
142
+ hash.update(chunk);
143
+ if (held)
144
+ controller.enqueue(held);
145
+ held = chunk;
146
+ },
147
+ flush(controller) {
148
+ if (read !== size || hash.digest("hex") !== sha256) {
149
+ controller.error(changed());
150
+ return;
151
+ }
152
+ if (held)
153
+ controller.enqueue(held);
154
+ },
155
+ }));
156
+ }
157
+ /**
158
+ * Serve a presigned link: no Authorization header, the link itself authorizes one transfer. GET streams the
159
+ * attachment, cutting the transfer off if its bytes are not the size and SHA-256 the link was issued for; PUT
160
+ * takes one file of the declared size and SHA-256 into the draft.
161
+ */
162
+ async function serveLink(links, url, request, res, signal) {
163
+ const refuse = (status, error, headers = {}) => sendWebResponse(res, Response.json({ error }, { status, headers }));
164
+ const method = request.method;
165
+ if (method !== "GET" && method !== "PUT")
166
+ return refuse(405, "use GET or PUT, as the link says", { allow: "GET, PUT, OPTIONS" });
167
+ if (url.search)
168
+ return refuse(400, "presigned links must not have query parameters");
169
+ const token = url.pathname.slice(LINK_PATH.length + 1);
170
+ if (!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u.test(token))
171
+ return refuse(404, "unknown link");
172
+ const resolved = links.resolve(token, method);
173
+ if ("status" in resolved)
174
+ return refuse(resolved.status, resolved.error);
175
+ const { claims, caller } = resolved;
176
+ try {
177
+ if (claims.k === "get") {
178
+ const { stream, contentType } = await caller.client.streamAttachment(claims.m, claims.n, signal);
179
+ // Spend the link only once the attachment is there, so a concurrent request cannot use it twice.
180
+ if (!links.consume(claims.j)) {
181
+ await stream.cancel();
182
+ return refuse(409, "this link was already used; each link downloads once");
183
+ }
184
+ const headers = downloadHeaders(claims.n, contentType);
185
+ headers.set("content-length", String(claims.s));
186
+ headers.set(CHECKSUM_HEADER, claims.h);
187
+ return await sendWebResponse(res, new Response(verified(stream, claims.s, claims.h), { headers }));
188
+ }
189
+ let encoding;
190
+ try {
191
+ encoding = uploadEncoding(request.headers.get("content-encoding"));
192
+ }
193
+ catch (error) {
194
+ return refuse(415, error.message);
195
+ }
196
+ const response = await streamIntoDraft(links, caller, {
197
+ draft: claims.m, filename: claims.n, size: claims.s, sha256: claims.h, mimeType: claims.t ?? attachmentType(claims.n),
198
+ }, request, encoding, signal, "link");
199
+ return await sendWebResponse(res, response);
200
+ }
201
+ catch (error) {
202
+ if (error.undrained || res.headersSent) {
203
+ res.destroy();
204
+ return;
205
+ }
206
+ if (res.destroyed || signal.aborted)
207
+ return;
208
+ if (error instanceof AttachmentChangedError)
209
+ return refuse(409, error.message);
210
+ const status = error instanceof AttachmentChecksumError ? 422 : error instanceof UploadBodyError ? 400
211
+ : error instanceof FmsgHttpError && error.status >= 400 && error.status <= 599 && error.status !== 401 ? error.status : 502;
212
+ return refuse(status, describeError(error));
213
+ }
214
+ }
120
215
  export function createHttpServer(config, log = (l) => console.error(l)) {
121
216
  const allowedHosts = config.http.allowedHosts.length
122
217
  ? config.http.allowedHosts
@@ -138,10 +233,13 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
138
233
  const metadataPath = resource ? `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}` : undefined;
139
234
  const metadataUrl = resource ? `${resource.origin}${metadataPath}` : "";
140
235
  const shutdown = new AbortController();
236
+ // Presigned links need the public URL to build them; they carry their own authorization.
237
+ const links = downloadBaseUrl(config) ? new PresignedLinks(downloadBaseUrl(config), config.http.stateSecret) : undefined;
141
238
  const mcpHandler = (provider, address) => createMcpHandler(({ authInfo }) => createFmsgMcpServer(provider, config, {
142
239
  ...(authInfo ? { address: address(authInfo) } : {}),
143
240
  ...(typeof authInfo?.extra?.owner === "string" ? { owner: authInfo.extra.owner } : {}),
144
241
  shutdown: shutdown.signal,
242
+ ...(links ? { links } : {}),
145
243
  }), { onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) });
146
244
  const oauthHandler = oauthProvider && mcpHandler(oauthProvider, (authInfo) => String(authInfo.extra?.address ?? ""));
147
245
  const apiKeyHandler = apiKeyProvider && mcpHandler(apiKeyProvider, (authInfo) => authInfo.clientId);
@@ -160,18 +258,20 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
160
258
  res.once("close", abort);
161
259
  const url = new URL(req.url ?? "/", "http://localhost");
162
260
  const isUpload = url.pathname.startsWith(`${UPLOAD_PATH}/`);
261
+ const isLink = Boolean(links) && url.pathname.startsWith(`${LINK_PATH}/`);
262
+ const streamsBody = isUpload || (isLink && req.method === "PUT");
163
263
  // A progressing upload may take longer than any fixed budget; the upload stream has an idle timeout instead.
164
264
  // Closing the connection afterwards stops Node reading the rest of an upload refused before authentication;
165
265
  // an authenticated caller's failed upload is drained first so its answer arrives (see serveUpload).
166
- const bodyTimer = isUpload ? undefined : setTimeout(() => { if (!req.complete)
266
+ const bodyTimer = streamsBody ? undefined : setTimeout(() => { if (!req.complete)
167
267
  req.destroy(); }, REQUEST_BODY_TIMEOUT_MS).unref();
168
- if (isUpload)
268
+ if (streamsBody)
169
269
  res.setHeader("connection", "close");
170
270
  const done = (async () => {
171
271
  const isDownload = url.pathname.startsWith(`${DOWNLOAD_PATH}/`);
172
272
  if (url.pathname === "/healthz") {
173
273
  res.writeHead(200, { "content-type": "application/json" });
174
- res.end(JSON.stringify({ ok: true, name: "fmsg-mcp", version: VERSION }));
274
+ res.end(JSON.stringify({ ok: true, name: "fmsg-mcp", version: VERSION, ...(BUILD.commit ? { commit: BUILD.commit } : {}) }));
175
275
  return;
176
276
  }
177
277
  if (resource && (url.pathname === metadataPath || url.pathname === "/.well-known/oauth-protected-resource")) {
@@ -187,7 +287,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
187
287
  authorization_servers: [config.oauth.issuerUrl], scopes_supported: MESSAGING_SCOPES,
188
288
  bearer_methods_supported: ["header"], resource_name: "fmsg messaging", }, { headers }));
189
289
  }
190
- if (url.pathname !== MCP_PATH && !isDownload && !isUpload) {
290
+ if (url.pathname !== MCP_PATH && !isDownload && !isUpload && !isLink) {
191
291
  res.writeHead(404, { "content-type": "text/plain" });
192
292
  res.end("not found; the MCP endpoint is /mcp");
193
293
  return;
@@ -219,7 +319,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
219
319
  if (req.method === "OPTIONS") {
220
320
  const method = request.headers.get("access-control-request-method") ?? "";
221
321
  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;
322
+ const methods = isDownload ? ["GET"] : isUpload ? ["PUT"] : isLink ? ["GET", "PUT"] : CORS_METHODS;
223
323
  if (!origin || !methods.includes(method) || headers.some((h) => !CORS_HEADERS.includes(h))) {
224
324
  return sendWebResponse(res, new Response("preflight not allowed", { status: 403 }));
225
325
  }
@@ -241,6 +341,8 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
241
341
  return sendWebResponse(res, new Response("invalid attachment download path", { status: 400 }));
242
342
  }
243
343
  }
344
+ if (isLink)
345
+ return serveLink(links, url, request, res, controller.signal);
244
346
  let upload;
245
347
  if (isUpload) {
246
348
  // Refusals are JSON, like the errors from the upload itself.
@@ -454,6 +556,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
454
556
  }
455
557
  oauthProvider?.close();
456
558
  apiKeyProvider?.close();
559
+ links?.close();
457
560
  await Promise.all([oauthHandler?.close(), apiKeyHandler?.close()]);
458
561
  // Aborted fetches may open replacement sockets without a request, so
459
562
  // 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. */
@@ -11,10 +11,10 @@ import { ownerSentence } from "./owner.js";
11
11
  /** How to send files, naming only the routes this server offers. */
12
12
  function filesParagraph(files) {
13
13
  const large = [
14
- ...(files?.uploadUrl ? ["create_draft, PUT each file to its upload URL, then send_draft"] : []),
14
+ ...(files?.uploadUrl ? [`create_draft, PUT each file to its upload URL${files.presigned ? " (or a get_attachment_upload_url link)" : ""}, then send_draft`] : []),
15
15
  ...(files?.uploadFolder ? ["give path, a file in the upload folder"] : []),
16
16
  ];
17
- return "Files: data_text for text, data_base64 for small files." + (large.length ? ` Large files: ${large.join("; or ")}.` : "");
17
+ return "Files: data_text for text, data_base64 for small files (best effort; always add sha256)." + (large.length ? ` Large files: ${large.join("; or ")}.` : "");
18
18
  }
19
19
  export function buildInstructions(ctx = {}) {
20
20
  const identity = ctx.address
@@ -0,0 +1,63 @@
1
+ import type { Caller } from "./context.js";
2
+ import type { UploadTracker } from "./upload.js";
3
+ export declare const LINK_PATH = "/mcp/links";
4
+ /** How long a presigned link stays usable. */
5
+ export declare const LINK_TTL_SECONDS = 300;
6
+ /** What a link allows, signed into it. */
7
+ export type LinkClaims = {
8
+ v: 1;
9
+ /** GET downloads an attachment; PUT uploads one file into a draft. */
10
+ k: "get" | "put";
11
+ /** Link id, for single use. */
12
+ j: string;
13
+ /** The fmsg address it acts for. */
14
+ a: string;
15
+ /** Message id (GET) or draft id (PUT). */
16
+ m: string;
17
+ /** Attachment name. */
18
+ n: string;
19
+ /** Size in bytes. */
20
+ s: number;
21
+ /** SHA-256, lowercase hex. */
22
+ h: string;
23
+ /** Media type (PUT). */
24
+ t?: string;
25
+ /** Expiry, unix seconds. */
26
+ e: number;
27
+ };
28
+ export declare class PresignedLinks implements UploadTracker {
29
+ private readonly publicUrl;
30
+ private readonly ttlSeconds;
31
+ private readonly key;
32
+ private readonly issued;
33
+ private readonly uploading;
34
+ private readonly verified;
35
+ private readonly sweeper;
36
+ constructor(publicUrl: string, secret: string | undefined, ttlSeconds?: number);
37
+ private sign;
38
+ /** Issue a link for `caller`. */
39
+ mint(caller: Caller, claims: Omit<LinkClaims, "v" | "j" | "a" | "e">): {
40
+ url: string;
41
+ expiresAt: number;
42
+ };
43
+ /** The link in a request path, with the caller it acts for, or the status and reason to refuse it with. */
44
+ resolve(token: string, method: string, now?: number): {
45
+ claims: LinkClaims;
46
+ caller: Caller;
47
+ } | {
48
+ status: number;
49
+ error: string;
50
+ };
51
+ /** Spend a download link. False when it was already spent. */
52
+ consume(id: string): boolean;
53
+ begin(key: string): boolean;
54
+ end(key: string): void;
55
+ recordVerified(key: string, sha256: string): void;
56
+ verifiedSha256(key: string): string | undefined;
57
+ private sweep;
58
+ close(): void;
59
+ }
60
+ /** A string as one POSIX shell word. */
61
+ export declare function shellQuote(value: string): string;
62
+ /** A local file name for curl -o: the attachment's name reduced to portable characters. */
63
+ export declare function localFileName(filename: string): string;
package/dist/links.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Presigned links: short-lived, single-use URLs that carry their own authorization, for hosts that cannot send this
3
+ * MCP connection's Authorization header with their HTTP requests. A link is a signed capability for exactly one
4
+ * transfer: GET one attachment, or PUT one file of a declared size and SHA-256 into one draft. It never contains
5
+ * an API key or access token. The caller it acts for is kept in this process, so a link works only on the instance
6
+ * that issued it.
7
+ */
8
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
9
+ export const LINK_PATH = "/mcp/links";
10
+ /** How long a presigned link stays usable. */
11
+ export const LINK_TTL_SECONDS = 300;
12
+ export class PresignedLinks {
13
+ publicUrl;
14
+ ttlSeconds;
15
+ key;
16
+ issued = new Map();
17
+ uploading = new Set();
18
+ verified = new Map();
19
+ sweeper;
20
+ constructor(publicUrl, secret, ttlSeconds = LINK_TTL_SECONDS) {
21
+ this.publicUrl = publicUrl;
22
+ this.ttlSeconds = ttlSeconds;
23
+ this.key = secret ? Buffer.from(secret, "utf8") : randomBytes(32);
24
+ this.sweeper = setInterval(() => this.sweep(), 60_000).unref();
25
+ }
26
+ sign(body) {
27
+ return createHmac("sha256", this.key).update(`fmsg-mcp.presigned-link.v1:${body}`).digest("base64url");
28
+ }
29
+ /** Issue a link for `caller`. */
30
+ mint(caller, claims) {
31
+ const expiresAt = Math.floor(Date.now() / 1000) + this.ttlSeconds;
32
+ const full = { v: 1, j: randomUUID(), a: caller.address, e: expiresAt, ...claims };
33
+ const body = Buffer.from(JSON.stringify(full)).toString("base64url");
34
+ this.issued.set(full.j, { caller, used: false, expires: expiresAt * 1000 });
35
+ return { url: `${this.publicUrl.replace(/\/$/u, "")}/links/${body}.${this.sign(body)}`, expiresAt };
36
+ }
37
+ /** The link in a request path, with the caller it acts for, or the status and reason to refuse it with. */
38
+ resolve(token, method, now = Date.now()) {
39
+ const [body, mac, extra] = token.split(".");
40
+ const unknown = { status: 404, error: "unknown link" };
41
+ if (!body || !mac || extra !== undefined)
42
+ return unknown;
43
+ const expected = Buffer.from(this.sign(body));
44
+ const given = Buffer.from(mac);
45
+ if (expected.length !== given.length || !timingSafeEqual(expected, given))
46
+ return unknown;
47
+ let claims;
48
+ try {
49
+ claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
50
+ }
51
+ catch {
52
+ return unknown;
53
+ }
54
+ if (claims.v !== 1)
55
+ return unknown;
56
+ if ((claims.k === "get") !== (method === "GET"))
57
+ return { status: 405, error: claims.k === "get" ? "this link is for GET" : "this link is for PUT" };
58
+ if (claims.e * 1000 <= now)
59
+ return { status: 410, error: `this link expired at ${new Date(claims.e * 1000).toISOString()}; ask for a new one` };
60
+ const issued = this.issued.get(claims.j);
61
+ // Signed by this key but not issued here: another instance (shared key) or before a restart.
62
+ if (!issued)
63
+ return { status: 404, error: "this link was issued by another server instance or before a restart; ask for a new one" };
64
+ if (claims.k === "get" && issued.used)
65
+ return { status: 409, error: "this link was already used; each link downloads once" };
66
+ return { claims, caller: issued.caller };
67
+ }
68
+ /** Spend a download link. False when it was already spent. */
69
+ consume(id) {
70
+ const issued = this.issued.get(id);
71
+ if (!issued || issued.used)
72
+ return false;
73
+ issued.used = true;
74
+ return true;
75
+ }
76
+ // UploadTracker: a PUT link takes one successful upload; failed attempts leave it usable until it expires.
77
+ begin(key) { if (this.uploading.has(key))
78
+ return false; this.uploading.add(key); return true; }
79
+ end(key) { this.uploading.delete(key); }
80
+ recordVerified(key, sha256) { this.verified.set(key, sha256); }
81
+ verifiedSha256(key) { return this.verified.get(key); }
82
+ sweep() {
83
+ const now = Date.now();
84
+ for (const [id, issued] of this.issued)
85
+ if (issued.expires <= now)
86
+ this.issued.delete(id);
87
+ if (!this.issued.size)
88
+ this.verified.clear();
89
+ }
90
+ close() {
91
+ clearInterval(this.sweeper);
92
+ this.issued.clear();
93
+ this.verified.clear();
94
+ }
95
+ }
96
+ /** A string as one POSIX shell word. */
97
+ export function shellQuote(value) {
98
+ return `'${value.replace(/'/gu, `'\\''`)}'`;
99
+ }
100
+ /** A local file name for curl -o: the attachment's name reduced to portable characters. */
101
+ export function localFileName(filename) {
102
+ const name = filename.normalize("NFKD").replace(/[\u0300-\u036f]/gu, "").replace(/[^A-Za-z0-9._-]+/gu, "_").replace(/^[.-]+/u, "");
103
+ return name || "attachment";
104
+ }
@@ -11,6 +11,7 @@ export const TOOL_SCOPES = {
11
11
  send_message: [WRITE_SCOPE], reply: MESSAGING_SCOPES,
12
12
  mark_read: [WRITE_SCOPE], add_recipients: [WRITE_SCOPE], react: [WRITE_SCOPE],
13
13
  create_draft: MESSAGING_SCOPES, send_draft: MESSAGING_SCOPES, delete_draft: MESSAGING_SCOPES,
14
+ get_attachment_upload_url: MESSAGING_SCOPES,
14
15
  };
15
16
  export function parseScopes(value) {
16
17
  if (typeof value !== "string")
package/dist/prompts.js CHANGED
@@ -4,13 +4,15 @@ function sendFileSteps(routes, to, replyTo) {
4
4
  const recipients = to ? `to ${to}, with a short topic` : "to the recipients I name (ask me if I have not)";
5
5
  const draft = replyTo ? `with reply_to "${replyTo}"${to ? ` and to: ${to}` : ""}` : recipients;
6
6
  const direct = replyTo ? `reply with id "${replyTo}"${to ? ` and recipients: ${to}` : ""}` : `send_message ${recipients}`;
7
- const inline = "attach text files as data_text and small binary files as data_base64, each with its sha256 when you can compute it";
7
+ const inline = "attach text files as data_text and small binary files as data_base64 with their sha256 (base64 is best effort, so keep it for small files)";
8
8
  if (routes.uploadUrl) {
9
9
  return [
10
10
  `1. Call create_draft ${draft}; ${inline}.`,
11
11
  "2. For each larger file, PUT its raw bytes to the draft's upload URL with your host's authenticated HTTP tools, " +
12
12
  "sending its sha256 in x-content-sha256, and check each response's size and sha256. If your host cannot send this " +
13
- "connection's credentials with its own requests, recreate the draft with the file as data_base64 instead; never look for or copy credentials.",
13
+ "connection's credentials with its own requests, " + (routes.presigned
14
+ ? "get a single-use link for each file from get_attachment_upload_url and run the curl command it returns"
15
+ : "recreate the draft with the file as data_base64 instead") + "; never look for or copy credentials.",
14
16
  "3. Call send_draft with the draft id and tell me what was sent. If a step fails, call delete_draft unless I want to keep the draft.",
15
17
  ];
16
18
  }
package/dist/server.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server";
2
2
  import type { Config } from "./config.js";
3
3
  import type { CallerProvider } from "./context.js";
4
+ import type { PresignedLinks } from "./links.js";
4
5
  export declare const SERVER_NAME = "fmsg";
5
6
  export type CreateServerOptions = {
6
7
  /** The caller's address when already known; makes the instructions name it. */
@@ -9,6 +10,8 @@ export type CreateServerOptions = {
9
10
  owner?: string;
10
11
  /** Aborted when the process begins shutting down, so wait_for_message returns "interrupted". */
11
12
  shutdown?: AbortSignal;
13
+ /** Issues presigned download and upload links (HTTP with a public URL). */
14
+ links?: PresignedLinks;
12
15
  };
13
16
  /**
14
17
  * Build an fmsg MCP server. Registration only — no I/O — so the same factory