@markmnl/fmsg-mcp 0.2.0 → 0.2.2

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
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Add HTTP-only `get_attachment_download_url` and authenticated binary attachment GETs. Links
6
+ contain no credentials; downloads reuse caller authentication and OAuth read scope, stream the
7
+ original bytes, and keep Web API access checks authoritative. API-key deployments configure
8
+ `FMSG_MCP_PUBLIC_URL`; OAuth reuses its resource URL. Inline downloads and stdio saves remain available.
9
+ - Abort failed response streams so a truncated download cannot appear successful.
10
+ - Close unused replacement connections on HTTP shutdown after cancelled downloads.
11
+
3
12
  ## 0.2.0 (unreleased)
4
13
 
5
14
  This is the next planned release; publication still happens through a `v0.2.0` GitHub release.
package/README.md CHANGED
@@ -10,23 +10,45 @@ attachments and wait for replies, through a deployed
10
10
  [fmsg Web API](https://github.com/markmnl/fmsg-webapi). Connect through stdio in hosts such as
11
11
  Claude Code, Claude Desktop, Cursor and VS Code, or through HTTP using API-key headers or configured OAuth.
12
12
 
13
+ Use your choice of fmsg hosting provider, or self-host. No hosting provider or identity provider
14
+ is built in.
15
+
13
16
  - **stdio** for local hosts: one address per server process, configured by two environment variables.
14
17
  - **Streamable HTTP** for shared or remote deployments: one endpoint serving many users, each
15
18
  authenticated by their own fmsg API key or OAuth connection.
16
19
  - The fmsg Web API client is exported for reuse: `import { FmsgClient } from "@markmnl/fmsg-mcp/client"`.
17
20
 
18
- ## 1. Get an fmsg address and API key
21
+ ## 1. Choose a connection
22
+
23
+ ### OAuth: connect and sign in
19
24
 
20
- Connecting to an existing OAuth-enabled endpoint? Add its MCP URL to your host and sign in;
21
- you can skip the API-key setup below. Operators can enable this with [HTTP OAuth](docs/oauth.md).
25
+ Get an OAuth-enabled MCP URL from your fmsg hosting provider, add it to your AI host, and sign in.
26
+ You do not need an API key or a local installation for this connection. Your AI host must support
27
+ the authorization server's client registration method; see [OAuth onboarding](docs/oauth.md#discovery-and-client-onboarding).
28
+ Operators can enable this with [HTTP OAuth](docs/oauth.md).
29
+
30
+ ### API key: use locally or over HTTP
22
31
 
23
32
  You send as an fmsg address, authenticated by an API key (`fmsgk_…`) issued by your fmsg host:
24
33
 
25
- - **No host yet?** Create an account at a public fmsg host such as [fmsg.io](https://fmsg.io) and
26
- add an agent (sub-account) to get an API URL and key.
34
+ - **Using a hosting provider?** Create an account with an fmsg hosting provider and obtain an
35
+ API URL and API key for the address your agent will use.
27
36
  - **Self-hosting?** Run the stack with [fmsg-docker](https://github.com/markmnl/fmsg-docker) and issue
28
37
  a key with `fmsg-webapi api-key create`.
29
38
 
39
+ Use the API URL and key for local installation below, or use your provider's API-key MCP endpoint
40
+ with an `Authorization: Bearer fmsgk_...` header. The endpoint must use the fmsg Web API that accepts
41
+ your key.
42
+
43
+ ### MCP Registry
44
+
45
+ Find `io.github.markmnl/fmsg-mcp` in the [official MCP Registry](https://registry.modelcontextprotocol.io/?q=io.github.markmnl%2Ffmsg-mcp).
46
+ The listing offers local npm installation and configurable remote connections for OAuth or API keys.
47
+ For a remote connection, supply the MCP endpoint from your fmsg hosting provider or your own deployment;
48
+ the registry input takes its hostname and path without the `https://` prefix, such as `mcp.example.com/mcp`.
49
+ OAuth sign-in is discovered from that endpoint; the API-key option additionally asks for your key.
50
+ If your AI host cannot configure registry URL templates, add the full MCP URL directly instead.
51
+
30
52
  ## 2. Install
31
53
 
32
54
  Requires Node.js 22 or later.
@@ -115,6 +137,7 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
115
137
  | `react` | Set or clear your emoji reaction |
116
138
  | `mark_read` | Mark received messages read |
117
139
  | `download_attachment` | Fetch a small attachment inline: text as text, images as image blocks, other files as base64 resources |
140
+ | `get_attachment_download_url` | Get a credential-free URL and resource link for an authenticated binary download; HTTP only, requires a public MCP URL |
118
141
  | `save_attachment` | Stream an attachment to the configured local folder; stdio only, enabled by `FMSG_MCP_DOWNLOAD_DIR` |
119
142
  | `delivery_status` | Per-recipient delivery times and host response codes |
120
143
  | `wait_for_message` | Block until the next inbound message (WebSocket push), batched per thread, with thread context |
@@ -132,6 +155,7 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
132
155
  | `FMSG_API_URL` | — | Base URL of the fmsg Web API (required) |
133
156
  | `FMSG_API_KEY` | — | `fmsgk_…` key; stdio mode only |
134
157
  | `FMSG_MCP_AUTH_MODE` | `api-key` | HTTP authentication: `api-key` or `oauth`; see [OAuth settings](docs/oauth.md#operator-configuration) |
158
+ | `FMSG_MCP_PUBLIC_URL` | OAuth resource URL, otherwise unset | Public MCP endpoint, including `/mcp`; enables the HTTP download-link tool. HTTPS required except loopback; in OAuth mode must equal `FMSG_MCP_OAUTH_RESOURCE_URL` |
135
159
  | `FMSG_ALLOW_INSECURE_HTTP` | disabled | Set to `1` only to permit cleartext API access on a trusted development/private network; loopback HTTP is allowed by default |
136
160
  | `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@<domain>` |
137
161
  | `FMSG_DIRECTORY` | — | JSON file mapping short names to full addresses |
@@ -156,7 +180,22 @@ while making progress; a 60-second idle timeout detects stalled transfers.
156
180
 
157
181
  Inline downloads default to 256 KiB to keep file content manageable for the model. Use
158
182
  `save_attachment` for larger local files, or raise `max_inline_bytes` explicitly when your AI host
159
- can handle more inline content. HTTP clients use inline downloads or their host's file capabilities.
183
+ can handle more inline content.
184
+
185
+ For remote file downloads, call `get_attachment_download_url` with the message ID and filename.
186
+ It returns metadata and an HTTPS `resource_link`; your AI host downloads the original bytes using
187
+ the existing MCP connection's Authorization header. API-key operators enable the tool with
188
+ `FMSG_MCP_PUBLIC_URL=https://mcp.example.com/mcp`; OAuth deployments reuse their configured resource
189
+ URL automatically. The [reverse proxy must forward the attachment route](docs/http-deployment.md#binary-attachment-downloads).
190
+ Links contain no credentials and grant no access by themselves. The host must support authenticated
191
+ HTTP downloads; an ordinary browser click without the header returns 401. Use inline downloads when
192
+ that host capability is unavailable. Downloads stream without the inline size budget, using the
193
+ original Content-Type and download filename. No files are stored on the MCP server.
194
+
195
+ The Web API already transfers attachments as raw bytes. Base64 is used only when embedding binary
196
+ content in MCP's JSON results, as required by [MCP binary resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources#binary-content).
197
+ `save_attachment` and authenticated HTTP downloads avoid that encoding and keep file bytes out of
198
+ model context. See [issue #6](https://github.com/markmnl/fmsg-mcp/issues/6).
160
199
 
161
200
  Over stdio, missing or invalid configuration still allows hosts to discover the tools. Tool calls
162
201
  explain the configuration error and how to fix it; restart the MCP server after correcting settings.
@@ -213,4 +252,11 @@ bash .github/scripts/run-fmsg-docker-e2e.sh # end to end on
213
252
  See [AGENTS.md](./AGENTS.md) for layout and conventions, [ROADMAP.md](./ROADMAP.md) for remaining
214
253
  integration work, and [CHANGELOG.md](./CHANGELOG.md) for release notes.
215
254
 
255
+ ### Releasing
256
+
257
+ Publish a stable GitHub release tagged `vX.Y.Z` to run the checks, publish the npm package, and
258
+ then update the MCP Registry with the same version. Both use OIDC authentication; no dedicated
259
+ registry secret is needed. Drafts and prereleases do not publish. Registry publishing runs as a
260
+ separate job, so if it fails after npm succeeds, use **Re-run failed jobs** on the release workflow.
261
+
216
262
  [MIT licensed](./LICENSE)
package/SECURITY.md CHANGED
@@ -37,6 +37,12 @@ rather than a public issue.
37
37
  - Attachment transfers retain caller cancellation and client shutdown signals. They use a response
38
38
  header deadline followed by a per-read idle timeout (60 seconds each by default), so a progressing
39
39
  large download is not subject to a 60-second total duration limit.
40
+ - HTTP attachment links contain no credentials and do not confer access. Every GET authenticates
41
+ the caller and uses its upstream client; OAuth also requires `fmsg:read`. The Web API decides
42
+ attachment visibility. Public URLs come from operator configuration, never forwarded headers.
43
+ Downloads stream with backpressure and force attachment disposition, no-store caching and nosniff.
44
+ Failed streams terminate the connection so partial files cannot appear successfully completed.
45
+ Query parameters are refused. Authenticated download links require support in the AI host.
40
46
  - Error previews are limited to 2 KiB while reading, except canonical JSON HTTP 400/413 responses:
41
47
  those retain the host's acceptance/size-policy explanation. Selected credentials are still redacted;
42
48
  oversized previews are explicitly marked as truncated.
package/dist/config.d.ts CHANGED
@@ -3,6 +3,8 @@ export type Transport = "stdio" | "http";
3
3
  export type HttpConfig = {
4
4
  host: string;
5
5
  port: number;
6
+ /** Canonical public MCP endpoint for credential-free attachment links. */
7
+ publicUrl?: string;
6
8
  /** Hostnames accepted in the Host header. Empty means: derive from the bind address (loopback only). */
7
9
  allowedHosts: string[];
8
10
  /** Exact browser origins. Empty permits same-origin and, on loopback binds, loopback origins on any port. */
package/dist/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { normalizeFmsgAddress } from "./address.js";
3
3
  import { normalizeApiUrl, normalizeOrigin } from "./client/url.js";
4
- import { loadOAuthConfig } from "./oauth/config.js";
4
+ import { loadOAuthConfig, oauthUrl } from "./oauth/config.js";
5
5
  export const DEFAULT_HTTP_PORT = 8765;
6
6
  export const DEFAULT_WAIT_MAX_SECONDS = 230;
7
7
  function intEnv(env, name, fallback, min = 1) {
@@ -63,6 +63,11 @@ export function loadConfig(env, transport, overrides = {}, options = {}) {
63
63
  if (!Number.isInteger(port) || port < 0 || port > 65535)
64
64
  throw new Error("FMSG_MCP_PORT must be between 0 and 65535");
65
65
  const host = overrides.host ?? env.FMSG_MCP_HOST?.trim() ?? "127.0.0.1";
66
+ const publicUrl = transport === "http" ? env.FMSG_MCP_PUBLIC_URL?.trim() || oauth?.resourceUrl : undefined;
67
+ if (publicUrl)
68
+ oauthUrl(publicUrl, "FMSG_MCP_PUBLIC_URL");
69
+ if (oauth && publicUrl !== oauth.resourceUrl)
70
+ throw new Error("FMSG_MCP_PUBLIC_URL must match FMSG_MCP_OAUTH_RESOURCE_URL in OAuth mode");
66
71
  return {
67
72
  transport,
68
73
  apiUrl: normalizedApiUrl,
@@ -76,6 +81,7 @@ export function loadConfig(env, transport, overrides = {}, options = {}) {
76
81
  http: {
77
82
  host,
78
83
  port,
84
+ ...(publicUrl ? { publicUrl } : {}),
79
85
  allowedHosts: listEnv(env, "FMSG_MCP_ALLOWED_HOSTS"),
80
86
  allowedOrigins: listEnv(env, "FMSG_MCP_ALLOWED_ORIGINS").map(normalizeOrigin),
81
87
  keyCacheMax: intEnv(env, "FMSG_MCP_KEY_CACHE_MAX", 500),
@@ -0,0 +1,11 @@
1
+ import type { Config } from "./config.js";
2
+ export declare const DOWNLOAD_PATH = "/mcp/attachments";
3
+ export declare function downloadBaseUrl(config: Config): string | undefined;
4
+ export declare function attachmentFilename(filename: string): string;
5
+ export declare function attachmentDownloadUrl(baseUrl: string, id: string, filename: string): string;
6
+ export declare function parseDownloadPath(pathname: string): {
7
+ id: string;
8
+ filename: string;
9
+ };
10
+ /** Always download untrusted files; preserve Unicode filenames without raw header characters. */
11
+ export declare function downloadHeaders(filename: string, contentType?: string): Headers;
@@ -0,0 +1,34 @@
1
+ import { normalizeMessageId } from "./client/message-id.js";
2
+ export const DOWNLOAD_PATH = "/mcp/attachments";
3
+ export function downloadBaseUrl(config) {
4
+ return config.transport === "http" ? config.http.publicUrl ?? config.oauth?.resourceUrl : undefined;
5
+ }
6
+ export function attachmentFilename(filename) {
7
+ if (!filename || filename === "." || filename === ".." || /[/\\\x00-\x1f\x7f]/u.test(filename)) {
8
+ throw new Error("use an attachment filename without directory components or control characters");
9
+ }
10
+ // Also reject unpaired UTF-16 surrogates before building a URL/header.
11
+ encodeURIComponent(filename);
12
+ return filename;
13
+ }
14
+ export function attachmentDownloadUrl(baseUrl, id, filename) {
15
+ return `${baseUrl.replace(/\/$/u, "")}/attachments/${normalizeMessageId(id)}/${encodeURIComponent(attachmentFilename(filename))}`;
16
+ }
17
+ export function parseDownloadPath(pathname) {
18
+ const parts = pathname.slice(DOWNLOAD_PATH.length + 1).split("/");
19
+ if (parts.length !== 2)
20
+ throw new Error("invalid attachment download path");
21
+ return { id: normalizeMessageId(parts[0]), filename: attachmentFilename(decodeURIComponent(parts[1])) };
22
+ }
23
+ /** Always download untrusted files; preserve Unicode filenames without raw header characters. */
24
+ export function downloadHeaders(filename, contentType) {
25
+ const fallback = filename.replace(/[^A-Za-z0-9._-]/gu, "_");
26
+ const encoded = encodeURIComponent(filename).replace(/[!'()*]/gu, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
27
+ return new Headers({
28
+ "content-type": contentType ?? "application/octet-stream",
29
+ "content-disposition": `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`,
30
+ "cache-control": "no-store",
31
+ "x-content-type-options": "nosniff",
32
+ "content-security-policy": "sandbox",
33
+ });
34
+ }
package/dist/http.js CHANGED
@@ -5,7 +5,10 @@ import { ApiKeyCallerProvider, FMSG_SCOPE } from "./auth.js";
5
5
  import { createFmsgMcpServer } from "./server.js";
6
6
  import { OAuthCallerProvider } from "./oauth/provider.js";
7
7
  import { invalidToken, insufficientScope, OAuthRequestError, oauthErrorResponse, oauthRequestState } from "./oauth/errors.js";
8
- import { MESSAGING_SCOPES, requestScopes } from "./oauth/scopes.js";
8
+ import { MESSAGING_SCOPES, READ_SCOPE, requestScopes } from "./oauth/scopes.js";
9
+ import { DOWNLOAD_PATH, downloadBaseUrl, downloadHeaders, parseDownloadPath } from "./download.js";
10
+ import { FmsgHttpError } from "./client/errors.js";
11
+ import { describeError } from "./errors.js";
9
12
  import { VERSION } from "./version.js";
10
13
  import { safeErrorMessage } from "./client/redact.js";
11
14
  import { isLoopbackHost, normalizeOrigin } from "./client/url.js";
@@ -76,10 +79,18 @@ export async function sendWebResponse(res, response, failure) {
76
79
  });
77
80
  }
78
81
  }
82
+ catch (error) {
83
+ // Once bytes have been sent, terminate the transfer so a truncated file is
84
+ // not reported as a successful download. Before headers, let the caller map the error.
85
+ if (res.headersSent)
86
+ res.destroy();
87
+ throw error;
88
+ }
79
89
  finally {
80
90
  res.off("close", abort);
81
91
  await reader.cancel().catch(() => undefined);
82
- res.end();
92
+ if (res.headersSent && !res.destroyed)
93
+ res.end();
83
94
  }
84
95
  }
85
96
  export function createHttpServer(config, log = (l) => console.error(l)) {
@@ -96,6 +107,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
96
107
  const safeLog = (line) => log(safeErrorMessage(line));
97
108
  const provider = config.oauth ? new OAuthCallerProvider(config, safeLog) : new ApiKeyCallerProvider(config, safeLog);
98
109
  const resource = config.oauth ? new URL(config.oauth.resourceUrl) : undefined;
110
+ const publicOrigin = downloadBaseUrl(config) ? new URL(downloadBaseUrl(config)).origin : undefined;
99
111
  const metadataPath = resource ? `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}` : undefined;
100
112
  const metadataUrl = resource ? `${resource.origin}${metadataPath}` : "";
101
113
  const handler = createMcpHandler(({ authInfo }) => createFmsgMcpServer(provider, config, authInfo ? {
@@ -111,6 +123,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
111
123
  res.once("close", abort);
112
124
  void (async () => {
113
125
  const url = new URL(req.url ?? "/", "http://localhost");
126
+ const isDownload = url.pathname.startsWith(`${DOWNLOAD_PATH}/`);
114
127
  if (url.pathname === "/healthz") {
115
128
  res.writeHead(200, { "content-type": "application/json" });
116
129
  res.end(JSON.stringify({ ok: true, name: "fmsg-mcp", version: VERSION }));
@@ -129,7 +142,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
129
142
  authorization_servers: [config.oauth.issuerUrl], scopes_supported: MESSAGING_SCOPES,
130
143
  bearer_methods_supported: ["header"], resource_name: "fmsg messaging", }, { headers }));
131
144
  }
132
- if (url.pathname !== MCP_PATH) {
145
+ if (url.pathname !== MCP_PATH && !isDownload) {
133
146
  res.writeHead(404, { "content-type": "text/plain" });
134
147
  res.end("not found; the MCP endpoint is /mcp");
135
148
  return;
@@ -148,26 +161,82 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
148
161
  const localDevelopment = isLoopbackHost(config.http.host) && !allowedOrigins.length &&
149
162
  /^https?:$/u.test(parsed.protocol) && isLoopbackHost(parsed.hostname);
150
163
  valid = parsed.origin === origin &&
151
- (localDevelopment || allowedOrigins.includes(origin) || origin === (resource?.origin ?? new URL(request.url).origin));
164
+ (localDevelopment || allowedOrigins.includes(origin) || origin === (publicOrigin ?? resource?.origin ?? new URL(request.url).origin));
152
165
  }
153
166
  catch { /* malformed origins are rejected */ }
154
167
  if (!valid)
155
168
  return sendWebResponse(res, new Response("origin not allowed", { status: 403 }));
156
169
  res.setHeader("access-control-allow-origin", origin);
157
- res.setHeader("access-control-expose-headers", "WWW-Authenticate, MCP-Session-Id, MCP-Protocol-Version, Retry-After");
170
+ res.setHeader("access-control-expose-headers", "WWW-Authenticate, MCP-Session-Id, MCP-Protocol-Version, Retry-After, Content-Disposition");
158
171
  }
159
172
  res.setHeader("vary", "Origin");
160
173
  res.setHeader("cache-control", "no-store");
161
174
  if (req.method === "OPTIONS") {
162
175
  const method = request.headers.get("access-control-request-method") ?? "";
163
176
  const headers = (request.headers.get("access-control-request-headers") ?? "").split(",").map((v) => v.trim().toLowerCase()).filter(Boolean);
164
- if (!origin || !CORS_METHODS.includes(method) || headers.some((h) => !CORS_HEADERS.includes(h))) {
177
+ const methods = isDownload ? ["GET"] : CORS_METHODS;
178
+ if (!origin || !methods.includes(method) || headers.some((h) => !CORS_HEADERS.includes(h))) {
165
179
  return sendWebResponse(res, new Response("preflight not allowed", { status: 403 }));
166
180
  }
167
- res.setHeader("access-control-allow-methods", CORS_METHODS.join(", "));
181
+ res.setHeader("access-control-allow-methods", methods.join(", "));
168
182
  res.setHeader("access-control-allow-headers", CORS_HEADERS.join(", "));
169
183
  return sendWebResponse(res, new Response(null, { status: 204 }));
170
184
  }
185
+ let download;
186
+ if (isDownload) {
187
+ if (req.method !== "GET")
188
+ return sendWebResponse(res, new Response("use GET to download attachments", { status: 405, headers: { allow: "GET, OPTIONS" } }));
189
+ // Authentication belongs only in the Authorization header, never the URL.
190
+ if (url.search)
191
+ return sendWebResponse(res, new Response("attachment URLs must not contain query parameters", { status: 400 }));
192
+ try {
193
+ download = parseDownloadPath(url.pathname);
194
+ }
195
+ catch {
196
+ return sendWebResponse(res, new Response("invalid attachment download path", { status: 400 }));
197
+ }
198
+ }
199
+ const serveDownload = async (auth) => {
200
+ let caller;
201
+ try {
202
+ caller = await provider.forRequest(auth);
203
+ const { stream, contentType } = await caller.client.streamAttachment(download.id, download.filename, controller.signal);
204
+ let response;
205
+ try {
206
+ response = new Response(stream, { headers: downloadHeaders(download.filename, contentType) });
207
+ }
208
+ catch (error) {
209
+ await stream.cancel().catch(() => undefined);
210
+ throw error;
211
+ }
212
+ return await sendWebResponse(res, response);
213
+ }
214
+ catch (error) {
215
+ const rejectedAuth = (error instanceof OAuthRequestError && error.status === 401) ||
216
+ (error instanceof FmsgHttpError && (error.status === 401 || (error.path === "/fmsg/token" && [400, 403].includes(error.status))));
217
+ if (caller && rejectedAuth)
218
+ provider.invalidate(caller);
219
+ if (res.destroyed || controller.signal.aborted)
220
+ return;
221
+ if (res.headersSent) {
222
+ res.destroy();
223
+ return;
224
+ }
225
+ if (provider instanceof OAuthCallerProvider) {
226
+ if (error instanceof OAuthRequestError)
227
+ return sendWebResponse(res, oauthErrorResponse(error, metadataUrl));
228
+ if (rejectedAuth)
229
+ return sendWebResponse(res, oauthErrorResponse(invalidToken(), metadataUrl));
230
+ if (error instanceof FmsgHttpError && error.insufficientScope) {
231
+ return sendWebResponse(res, oauthErrorResponse(new OAuthRequestError(403, "insufficient_scope", error.message, [READ_SCOPE]), metadataUrl));
232
+ }
233
+ }
234
+ const status = rejectedAuth ? 401 : error instanceof FmsgHttpError && error.status >= 400 && error.status <= 599 ? error.status : 502;
235
+ return sendWebResponse(res, Response.json({ error: describeError(error) }, {
236
+ status, headers: status === 401 ? { "www-authenticate": 'Bearer error="invalid_token"' } : {},
237
+ }));
238
+ }
239
+ };
171
240
  if (provider instanceof OAuthCallerProvider) {
172
241
  try {
173
242
  const bearer = /^Bearer ([A-Za-z0-9._~+\/-]+=*)$/iu.exec(request.headers.get("authorization") ?? "");
@@ -185,9 +254,11 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
185
254
  if (Array.isArray(parsedBody))
186
255
  return sendWebResponse(res, new Response("MCP batch requests are not supported", { status: 400 }));
187
256
  }
188
- const scopes = requestScopes(parsedBody);
257
+ const scopes = download ? [READ_SCOPE] : requestScopes(parsedBody);
189
258
  if (scopes.some(scope => !authenticated.scopes.includes(scope)))
190
259
  throw insufficientScope(scopes);
260
+ if (download)
261
+ return await serveDownload(authenticated);
191
262
  // Discover/list operations only need the incoming JWT. Resolve an
192
263
  // exchanged credential before starting any protected tool response.
193
264
  if (scopes.length) {
@@ -218,11 +289,18 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
218
289
  const auth = await gate(request);
219
290
  if (auth instanceof Response)
220
291
  return sendWebResponse(res, auth);
292
+ if (download)
293
+ return serveDownload(auth);
221
294
  return sendWebResponse(res, await handler.fetch(request, { authInfo: auth }));
222
295
  })().catch((error) => {
223
296
  safeLog(`request failed: ${error instanceof Error ? error.message : String(error)}`);
224
- if (!res.headersSent)
225
- res.writeHead(500, { "content-type": "text/plain" });
297
+ if (res.destroyed)
298
+ return;
299
+ if (res.headersSent) {
300
+ res.destroy();
301
+ return;
302
+ }
303
+ res.writeHead(500, { "content-type": "text/plain" });
226
304
  res.end("internal error");
227
305
  }).finally(() => {
228
306
  if (authenticated)
@@ -241,7 +319,12 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
241
319
  controller.abort();
242
320
  provider.close();
243
321
  await handler.close();
244
- await new Promise((resolve) => server.close(() => resolve()));
322
+ await new Promise((resolve) => {
323
+ server.close(() => resolve());
324
+ // Aborted fetches may open replacement sockets without a request, so
325
+ // they are absent from `active` and must also be closed on shutdown.
326
+ server.closeAllConnections();
327
+ });
245
328
  };
246
329
  return { server, close, provider };
247
330
  }
@@ -10,7 +10,9 @@ export function buildInstructions(ctx = {}) {
10
10
  "Use its tools for everything fmsg: inbox, threads, attachments, sending, replying, reactions, " +
11
11
  "delivery status and waiting for new messages. Do not use an fmsg command-line tool, local config " +
12
12
  "files or cached credentials instead; they may belong to a different address or host. If a tool " +
13
- "reports the server is not configured, explain the reported configuration fix and restart requirement.",
13
+ "reports the server is not configured, explain the reported configuration fix and restart requirement. " +
14
+ "For URLs returned by get_attachment_download_url, use your host's authenticated download facility " +
15
+ "with this MCP connection; never search for credentials or put them in prompts or URLs.",
14
16
  "Carry out the user's requested messaging task or authorized automation without repeatedly asking for " +
15
17
  "confirmation. Sending is immediate and sent messages cannot be edited or recalled. Ask the user only " +
16
18
  "when a decision is needed to resolve unclear intent, recipients or content. The AI host controls tool " +
@@ -7,7 +7,7 @@ export const TOOL_SCOPES = {
7
7
  whoami: [READ_SCOPE], resolve_address: [READ_SCOPE],
8
8
  list_messages: [READ_SCOPE], list_sent: [READ_SCOPE], get_message: [READ_SCOPE],
9
9
  get_thread: [READ_SCOPE], delivery_status: [READ_SCOPE], download_attachment: [READ_SCOPE],
10
- save_attachment: [READ_SCOPE], wait_for_message: [READ_SCOPE],
10
+ save_attachment: [READ_SCOPE], get_attachment_download_url: [READ_SCOPE], wait_for_message: [READ_SCOPE],
11
11
  send_message: [WRITE_SCOPE], reply: MESSAGING_SCOPES,
12
12
  mark_read: [WRITE_SCOPE], add_recipients: [WRITE_SCOPE], react: [WRITE_SCOPE],
13
13
  };
package/dist/server.js CHANGED
@@ -8,6 +8,7 @@ import { registerReadTools } from "./tools/read.js";
8
8
  import { registerSendTools } from "./tools/send.js";
9
9
  import { registerWaitTools } from "./tools/wait.js";
10
10
  import { registerSaveTool } from "./tools/save.js";
11
+ import { registerDownloadTool } from "./tools/download.js";
11
12
  import { VERSION } from "./version.js";
12
13
  export const SERVER_NAME = "fmsg";
13
14
  /**
@@ -25,6 +26,7 @@ export function createFmsgMcpServer(provider, config, options = {}) {
25
26
  registerListTools(server, deps);
26
27
  registerReadTools(server, deps);
27
28
  registerSaveTool(server, deps);
29
+ registerDownloadTool(server, deps);
28
30
  registerSendTools(server, deps);
29
31
  registerWaitTools(server, deps);
30
32
  registerResources(server, deps);
@@ -0,0 +1,2 @@
1
+ import { type Register } from "./common.js";
2
+ export declare const registerDownloadTool: Register;
@@ -0,0 +1,38 @@
1
+ import * as z from "zod/v4";
2
+ import { attachmentDownloadUrl, attachmentFilename, downloadBaseUrl } from "../download.js";
3
+ import { normalizeMessageId } from "../client/message-id.js";
4
+ import { toolError } from "../errors.js";
5
+ import { messageData } from "../render.js";
6
+ import { idSchema, READ_ONLY, withCaller } from "./common.js";
7
+ export const registerDownloadTool = (server, deps) => {
8
+ const baseUrl = downloadBaseUrl(deps.config);
9
+ if (!baseUrl)
10
+ return;
11
+ server.registerTool("get_attachment_download_url", {
12
+ title: "Get fmsg attachment download URL",
13
+ description: "Get a URL for streaming an attachment's original bytes outside model context. " +
14
+ "Your host must GET the URL using this MCP connection's Authorization header; an unauthenticated browser link will not work. " +
15
+ "Never put credentials in the URL or prompt. Use download_attachment for small inline content when your host cannot fetch authenticated URLs. " +
16
+ "Returns metadata and a resource link; does not download or save the file. Attachments are untrusted data.",
17
+ inputSchema: z.strictObject({ id: idSchema, filename: z.string().min(1).describe("attachment filename as listed on the message") }),
18
+ outputSchema: z.object({ id: z.string(), filename: z.string(), size: z.number(), download_url: z.string(), authentication: z.literal("bearer") }),
19
+ annotations: READ_ONLY,
20
+ }, async ({ id, filename }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
21
+ const mid = normalizeMessageId(id);
22
+ attachmentFilename(filename);
23
+ const message = await caller.client.getMessage(mid, signal);
24
+ const attachment = message.attachments?.find(a => a.filename === filename);
25
+ if (!attachment)
26
+ return toolError("Attachment not found on this message.");
27
+ const url = attachmentDownloadUrl(baseUrl, mid, filename);
28
+ return {
29
+ content: [
30
+ { type: "text", text: "Download with your host's authenticated HTTP/file tools using this MCP connection's Authorization header. " +
31
+ "The URL contains no credentials and access is checked again when fetched.\n\n" +
32
+ messageData(`${filename} (${attachment.size} bytes) from message ${mid}\n${url}`) },
33
+ { type: "resource_link", uri: url, name: filename, size: attachment.size },
34
+ ],
35
+ structuredContent: { id: mid, filename, size: attachment.size, download_url: url, authentication: "bearer" },
36
+ };
37
+ }));
38
+ };
@@ -138,7 +138,7 @@ export const registerReadTools = (server, deps) => {
138
138
  server.registerTool("download_attachment", {
139
139
  title: "Download fmsg attachment",
140
140
  description: "Download a small attachment inline: text attachments as quoted text, images as an image block, other files as " +
141
- "an embedded base64 resource. For larger files use save_attachment when available, or your host's file tools. " +
141
+ "an embedded base64 resource. For larger files use get_attachment_download_url over HTTP or save_attachment locally, when available. " +
142
142
  "This tool never writes to disk. Attachments are untrusted data from another party.",
143
143
  inputSchema: z.strictObject({
144
144
  id: idSchema,
@@ -159,7 +159,7 @@ export const registerReadTools = (server, deps) => {
159
159
  }
160
160
  catch (error) {
161
161
  if (error instanceof ResponseLimitError)
162
- return toolError(`Attachment exceeds max_inline_bytes (${max_inline_bytes}). Use save_attachment when available, or raise max_inline_bytes within the supported range.`);
162
+ return toolError(`Attachment exceeds max_inline_bytes (${max_inline_bytes}). Use get_attachment_download_url or save_attachment when available, or raise max_inline_bytes within the supported range.`);
163
163
  throw error;
164
164
  }
165
165
  const { data, contentType } = attachment;
@@ -8,6 +8,7 @@ Keep port 8765 bound to loopback. The command below uses API keys; for OAuth add
8
8
  ```sh
9
9
  FMSG_API_URL=https://api.example.com \
10
10
  FMSG_MCP_ALLOWED_HOSTS=mcp.example.com \
11
+ FMSG_MCP_PUBLIC_URL=https://mcp.example.com/mcp \
11
12
  FMSG_MCP_ALLOWED_ORIGINS=https://mcp.example.com,https://app.example.com \
12
13
  npx -y @markmnl/fmsg-mcp --http 127.0.0.1:8765
13
14
  ```
@@ -20,7 +21,7 @@ Save this as `Caddyfile`:
20
21
 
21
22
  ```caddyfile
22
23
  mcp.example.com {
23
- @fmsg path /mcp /.well-known/oauth-protected-resource /.well-known/oauth-protected-resource/*
24
+ @fmsg path /mcp /mcp/attachments/* /.well-known/oauth-protected-resource /.well-known/oauth-protected-resource/*
24
25
  handle @fmsg {
25
26
  reverse_proxy 127.0.0.1:8765 {
26
27
  transport http {
@@ -51,3 +52,30 @@ and cancellation through the actual deployed proxy before advertising that deplo
51
52
  The metadata routes are public in OAuth mode and must reach the server for MCP authorization
52
53
  discovery. In API-key mode they return 404. When OAuth is configured, its resource URL supplies
53
54
  the public same-origin value, so explicitly listing that origin is optional.
55
+
56
+ ## Binary attachment downloads
57
+
58
+ `get_attachment_download_url` returns a resource link such as
59
+ `https://mcp.example.com/mcp/attachments/123/report.pdf`. The host fetches it with an authenticated
60
+ GET, using the same `Authorization: Bearer ...` header as its MCP connection. OAuth requires
61
+ `fmsg:read`; the server exchanges the incoming token for a Web API token as usual. The Web API
62
+ checks visibility on every download, even when a link was obtained earlier.
63
+
64
+ Set `FMSG_MCP_PUBLIC_URL` to the exact external MCP endpoint when using API keys. OAuth defaults
65
+ to `FMSG_MCP_OAUTH_RESOURCE_URL`; if both are set, they must match. These URLs require HTTPS outside
66
+ loopback and cannot contain credentials, queries or fragments. Request Host and forwarded headers
67
+ never select the download URL. If the proxy exposes MCP under a different public path, map that
68
+ path and its `/attachments/*` suffix to `/mcp` and `/mcp/attachments/*` respectively.
69
+
70
+ Downloads use the original MIME type, `Content-Disposition: attachment`, Unicode filename encoding,
71
+ `Cache-Control: no-store` and `X-Content-Type-Options: nosniff`. The body streams with backpressure;
72
+ disconnects cancel the upstream request and incomplete transfers abort rather than complete as a
73
+ truncated file. No temporary server files, signed URLs or tokens in query strings are used.
74
+ Range/resume requests are not implemented: GET returns the full file. Treat a failed transfer as
75
+ incomplete and discard its partial local output before retrying.
76
+
77
+ Host and Origin validation applies to downloads too. Allowed browser clients can preflight GET
78
+ with Authorization and read Content-Disposition. Hosts must attach the connection credential
79
+ themselves; never ask the model to locate or copy tokens. Clients that cannot fetch authenticated
80
+ links can still use `download_attachment` inline. Compatibility of authenticated links with each
81
+ third-party AI host must be tested before advertising support.
package/docs/oauth.md CHANGED
@@ -80,6 +80,12 @@ use pre-registered clients or Client ID Metadata Documents where supported by bo
80
80
  host. Check the intended host's registration support before advertising compatibility. This
81
81
  implementation does not add a registration proxy, login UI or consent UI.
82
82
 
83
+ The resource URL also supplies the public URL for `get_attachment_download_url`. Its links contain
84
+ no credentials; hosts fetch them using the current MCP bearer token, with `fmsg:read`. The binary
85
+ GET route uses the same token validation and Web API token exchange, and exposes the same metadata
86
+ challenge on authentication failure. See [binary downloads](http-deployment.md#binary-attachment-downloads)
87
+ for reverse-proxy routing and host compatibility requirements.
88
+
83
89
  ## Token validation and scopes
84
90
 
85
91
  Incoming access tokens must be signed EdDSA JWTs with `typ: at+jwt`, a nonempty `kid` in the
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": "0.2.0",
4
+ "version": "0.2.2",
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
@@ -1,25 +1,27 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.markmnl/fmsg-mcp",
4
- "description": "Send and receive fmsg federated messages from any AI agent via a deployed fmsg Web API",
4
+ "title": "fmsg",
5
+ "description": "Federated messaging with your fmsg hosting provider. Connect with OAuth or an API key.",
6
+ "websiteUrl": "https://github.com/markmnl/fmsg-mcp#readme",
5
7
  "repository": {
6
8
  "url": "https://github.com/markmnl/fmsg-mcp",
7
9
  "source": "github"
8
10
  },
9
- "version": "0.2.0",
11
+ "version": "0.2.2",
10
12
  "packages": [
11
13
  {
12
14
  "registryType": "npm",
13
15
  "registryBaseUrl": "https://registry.npmjs.org",
14
16
  "identifier": "@markmnl/fmsg-mcp",
15
- "version": "0.2.0",
17
+ "version": "0.2.2",
16
18
  "transport": {
17
19
  "type": "stdio"
18
20
  },
19
21
  "environmentVariables": [
20
22
  {
21
23
  "name": "FMSG_API_URL",
22
- "description": "Base URL of the fmsg Web API",
24
+ "description": "Base URL of your hosting provider's or self-hosted fmsg Web API",
23
25
  "isRequired": true
24
26
  },
25
27
  {
@@ -40,5 +42,48 @@
40
42
  }
41
43
  ]
42
44
  }
45
+ ],
46
+ "remotes": [
47
+ {
48
+ "type": "streamable-http",
49
+ "url": "https://{oauth_mcp_endpoint}",
50
+ "variables": {
51
+ "oauth_mcp_endpoint": {
52
+ "description": "OAuth MCP endpoint from your fmsg hosting provider: hostname and path without https://. Sign in through your AI host; no API key needed.",
53
+ "isRequired": true,
54
+ "format": "string",
55
+ "placeholder": "mcp.example.com/mcp"
56
+ }
57
+ }
58
+ },
59
+ {
60
+ "type": "streamable-http",
61
+ "url": "https://{api_key_mcp_endpoint}",
62
+ "variables": {
63
+ "api_key_mcp_endpoint": {
64
+ "description": "API-key MCP endpoint from your fmsg hosting provider: hostname and path without https://. Use an endpoint that accepts your key.",
65
+ "isRequired": true,
66
+ "format": "string",
67
+ "placeholder": "mcp.example.com/mcp"
68
+ }
69
+ },
70
+ "headers": [
71
+ {
72
+ "name": "Authorization",
73
+ "description": "Your fmsg API key for the selected MCP endpoint",
74
+ "isRequired": true,
75
+ "isSecret": true,
76
+ "value": "Bearer {api_key}",
77
+ "variables": {
78
+ "api_key": {
79
+ "description": "API key accepted by this endpoint's fmsg Web API, starting with fmsgk_",
80
+ "isRequired": true,
81
+ "isSecret": true,
82
+ "format": "string"
83
+ }
84
+ }
85
+ }
86
+ ]
87
+ }
43
88
  ]
44
89
  }