Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ FMSG_API_KEY=fmsgk_xxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# HTTP mode
#FMSG_MCP_HOST=127.0.0.1
#FMSG_MCP_PORT=8765
# Public MCP URL enables authenticated binary download links; OAuth reuses its resource URL.
#FMSG_MCP_PUBLIC_URL=https://mcp.example.com/mcp
#FMSG_MCP_ALLOWED_HOSTS=mcp.example.com
#FMSG_MCP_ALLOWED_ORIGINS=https://app.example.com
# Explicit opt-in for a trusted development/private HTTP API outside loopback:
Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ src/context.ts CallerProvider: fixed caller over stdio, per-bearer-credenti
src/auth.ts HTTP bearer verifier: key hash → cached FmsgClient + address
src/oauth/ HTTP OAuth discovery, validation, scopes and token exchange
src/http.ts node:http server, /mcp + /healthz, Host/Origin allowlist, bearer gate
src/download.ts binary download URLs, path validation and attachment headers
src/tools/*.ts one file per tool group; src/tools/common.ts has shared schemas/helpers
src/wait.ts wait_for_message engine (WebSocket first, inbox catch-up, settle batching)
src/thread.ts thread assembly via /thread/messages with a pid-walk fallback
Expand Down Expand Up @@ -60,6 +61,10 @@ separate Web API token. Never forward the incoming JWT or send `X-FMSG-Act-As`.
classification in `src/oauth/scopes.ts` synchronized with tools; reply needs read and write.
See `docs/oauth.md` for the vendor-neutral claims contract and deployment requirements.

HTTP attachment downloads at `/mcp/attachments/{id}/{filename}` reuse the bearer gate and upstream
caller; OAuth requires `fmsg:read`. Link generation uses only the configured public MCP URL (the
OAuth resource URL by default), never Host or forwarded headers. Keep credentials out of links.

## Adding a tool

1. Register it in the matching `src/tools/*.ts` (or a new file wired in `src/server.ts`) with
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## Unreleased

- Add HTTP-only `get_attachment_download_url` and authenticated binary attachment GETs. Links
contain no credentials; downloads reuse caller authentication and OAuth read scope, stream the
original bytes, and keep Web API access checks authoritative. API-key deployments configure
`FMSG_MCP_PUBLIC_URL`; OAuth reuses its resource URL. Inline downloads and stdio saves remain available.
- Abort failed response streams so a truncated download cannot appear successful.
- Close unused replacement connections on HTTP shutdown after cancelled downloads.

## 0.2.0 (unreleased)

This is the next planned release; publication still happens through a `v0.2.0` GitHub release.
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
| `react` | Set or clear your emoji reaction |
| `mark_read` | Mark received messages read |
| `download_attachment` | Fetch a small attachment inline: text as text, images as image blocks, other files as base64 resources |
| `get_attachment_download_url` | Get a credential-free URL and resource link for an authenticated binary download; HTTP only, requires a public MCP URL |
| `save_attachment` | Stream an attachment to the configured local folder; stdio only, enabled by `FMSG_MCP_DOWNLOAD_DIR` |
| `delivery_status` | Per-recipient delivery times and host response codes |
| `wait_for_message` | Block until the next inbound message (WebSocket push), batched per thread, with thread context |
Expand All @@ -154,6 +155,7 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
| `FMSG_API_URL` | — | Base URL of the fmsg Web API (required) |
| `FMSG_API_KEY` | — | `fmsgk_…` key; stdio mode only |
| `FMSG_MCP_AUTH_MODE` | `api-key` | HTTP authentication: `api-key` or `oauth`; see [OAuth settings](docs/oauth.md#operator-configuration) |
| `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` |
| `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 |
| `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@<domain>` |
| `FMSG_DIRECTORY` | — | JSON file mapping short names to full addresses |
Expand All @@ -178,7 +180,22 @@ while making progress; a 60-second idle timeout detects stalled transfers.

Inline downloads default to 256 KiB to keep file content manageable for the model. Use
`save_attachment` for larger local files, or raise `max_inline_bytes` explicitly when your AI host
can handle more inline content. HTTP clients use inline downloads or their host's file capabilities.
can handle more inline content.

For remote file downloads, call `get_attachment_download_url` with the message ID and filename.
It returns metadata and an HTTPS `resource_link`; your AI host downloads the original bytes using
the existing MCP connection's Authorization header. API-key operators enable the tool with
`FMSG_MCP_PUBLIC_URL=https://mcp.example.com/mcp`; OAuth deployments reuse their configured resource
URL automatically. The [reverse proxy must forward the attachment route](docs/http-deployment.md#binary-attachment-downloads).
Links contain no credentials and grant no access by themselves. The host must support authenticated
HTTP downloads; an ordinary browser click without the header returns 401. Use inline downloads when
that host capability is unavailable. Downloads stream without the inline size budget, using the
original Content-Type and download filename. No files are stored on the MCP server.

The Web API already transfers attachments as raw bytes. Base64 is used only when embedding binary
content in MCP's JSON results, as required by [MCP binary resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources#binary-content).
`save_attachment` and authenticated HTTP downloads avoid that encoding and keep file bytes out of
model context. See [issue #6](https://github.com/markmnl/fmsg-mcp/issues/6).

Over stdio, missing or invalid configuration still allows hosts to discover the tools. Tool calls
explain the configuration error and how to fix it; restart the MCP server after correcting settings.
Expand Down
6 changes: 6 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ rather than a public issue.
- Attachment transfers retain caller cancellation and client shutdown signals. They use a response
header deadline followed by a per-read idle timeout (60 seconds each by default), so a progressing
large download is not subject to a 60-second total duration limit.
- HTTP attachment links contain no credentials and do not confer access. Every GET authenticates
the caller and uses its upstream client; OAuth also requires `fmsg:read`. The Web API decides
attachment visibility. Public URLs come from operator configuration, never forwarded headers.
Downloads stream with backpressure and force attachment disposition, no-store caching and nosniff.
Failed streams terminate the connection so partial files cannot appear successfully completed.
Query parameters are refused. Authenticated download links require support in the AI host.
- Error previews are limited to 2 KiB while reading, except canonical JSON HTTP 400/413 responses:
those retain the host's acceptance/size-policy explanation. Selected credentials are still redacted;
oversized previews are explicitly marked as truncated.
Expand Down
30 changes: 29 additions & 1 deletion docs/http-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Keep port 8765 bound to loopback. The command below uses API keys; for OAuth add
```sh
FMSG_API_URL=https://api.example.com \
FMSG_MCP_ALLOWED_HOSTS=mcp.example.com \
FMSG_MCP_PUBLIC_URL=https://mcp.example.com/mcp \
FMSG_MCP_ALLOWED_ORIGINS=https://mcp.example.com,https://app.example.com \
npx -y @markmnl/fmsg-mcp --http 127.0.0.1:8765
```
Expand All @@ -20,7 +21,7 @@ Save this as `Caddyfile`:

```caddyfile
mcp.example.com {
@fmsg path /mcp /.well-known/oauth-protected-resource /.well-known/oauth-protected-resource/*
@fmsg path /mcp /mcp/attachments/* /.well-known/oauth-protected-resource /.well-known/oauth-protected-resource/*
handle @fmsg {
reverse_proxy 127.0.0.1:8765 {
transport http {
Expand Down Expand Up @@ -51,3 +52,30 @@ and cancellation through the actual deployed proxy before advertising that deplo
The metadata routes are public in OAuth mode and must reach the server for MCP authorization
discovery. In API-key mode they return 404. When OAuth is configured, its resource URL supplies
the public same-origin value, so explicitly listing that origin is optional.

## Binary attachment downloads

`get_attachment_download_url` returns a resource link such as
`https://mcp.example.com/mcp/attachments/123/report.pdf`. The host fetches it with an authenticated
GET, using the same `Authorization: Bearer ...` header as its MCP connection. OAuth requires
`fmsg:read`; the server exchanges the incoming token for a Web API token as usual. The Web API
checks visibility on every download, even when a link was obtained earlier.

Set `FMSG_MCP_PUBLIC_URL` to the exact external MCP endpoint when using API keys. OAuth defaults
to `FMSG_MCP_OAUTH_RESOURCE_URL`; if both are set, they must match. These URLs require HTTPS outside
loopback and cannot contain credentials, queries or fragments. Request Host and forwarded headers
never select the download URL. If the proxy exposes MCP under a different public path, map that
path and its `/attachments/*` suffix to `/mcp` and `/mcp/attachments/*` respectively.

Downloads use the original MIME type, `Content-Disposition: attachment`, Unicode filename encoding,
`Cache-Control: no-store` and `X-Content-Type-Options: nosniff`. The body streams with backpressure;
disconnects cancel the upstream request and incomplete transfers abort rather than complete as a
truncated file. No temporary server files, signed URLs or tokens in query strings are used.
Range/resume requests are not implemented: GET returns the full file. Treat a failed transfer as
incomplete and discard its partial local output before retrying.

Host and Origin validation applies to downloads too. Allowed browser clients can preflight GET
with Authorization and read Content-Disposition. Hosts must attach the connection credential
themselves; never ask the model to locate or copy tokens. Clients that cannot fetch authenticated
links can still use `download_attachment` inline. Compatibility of authenticated links with each
third-party AI host must be tested before advertising support.
6 changes: 6 additions & 0 deletions docs/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ use pre-registered clients or Client ID Metadata Documents where supported by bo
host. Check the intended host's registration support before advertising compatibility. This
implementation does not add a registration proxy, login UI or consent UI.

The resource URL also supplies the public URL for `get_attachment_download_url`. Its links contain
no credentials; hosts fetch them using the current MCP bearer token, with `fmsg:read`. The binary
GET route uses the same token validation and Web API token exchange, and exposes the same metadata
challenge on authentication failure. See [binary downloads](http-deployment.md#binary-attachment-downloads)
for reverse-proxy routing and host compatibility requirements.

## Token validation and scopes

Incoming access tokens must be signed EdDSA JWTs with `typ: at+jwt`, a nonempty `kid` in the
Expand Down
8 changes: 7 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { readFileSync } from "node:fs";
import { normalizeFmsgAddress } from "./address.js";
import { normalizeApiUrl, normalizeOrigin } from "./client/url.js";
import { loadOAuthConfig, type OAuthConfig } from "./oauth/config.js";
import { loadOAuthConfig, oauthUrl, type OAuthConfig } from "./oauth/config.js";

export type Transport = "stdio" | "http";

export type HttpConfig = {
host: string;
port: number;
/** Canonical public MCP endpoint for credential-free attachment links. */
publicUrl?: string;
/** Hostnames accepted in the Host header. Empty means: derive from the bind address (loopback only). */
allowedHosts: string[];
/** Exact browser origins. Empty permits same-origin and, on loopback binds, loopback origins on any port. */
Expand Down Expand Up @@ -114,6 +116,9 @@ export function loadConfig(
const port = overrides.port ?? intEnv(env, "FMSG_MCP_PORT", DEFAULT_HTTP_PORT, 0);
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("FMSG_MCP_PORT must be between 0 and 65535");
const host = overrides.host ?? env.FMSG_MCP_HOST?.trim() ?? "127.0.0.1";
const publicUrl = transport === "http" ? env.FMSG_MCP_PUBLIC_URL?.trim() || oauth?.resourceUrl : undefined;
if (publicUrl) oauthUrl(publicUrl, "FMSG_MCP_PUBLIC_URL");
if (oauth && publicUrl !== oauth.resourceUrl) throw new Error("FMSG_MCP_PUBLIC_URL must match FMSG_MCP_OAUTH_RESOURCE_URL in OAuth mode");

return {
transport,
Expand All @@ -128,6 +133,7 @@ export function loadConfig(
http: {
host,
port,
...(publicUrl ? { publicUrl } : {}),
allowedHosts: listEnv(env, "FMSG_MCP_ALLOWED_HOSTS"),
allowedOrigins: listEnv(env, "FMSG_MCP_ALLOWED_ORIGINS").map(normalizeOrigin),
keyCacheMax: intEnv(env, "FMSG_MCP_KEY_CACHE_MAX", 500),
Expand Down
40 changes: 40 additions & 0 deletions src/download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { normalizeMessageId } from "./client/message-id.js";
import type { Config } from "./config.js";

export const DOWNLOAD_PATH = "/mcp/attachments";

export function downloadBaseUrl(config: Config): string | undefined {
return config.transport === "http" ? config.http.publicUrl ?? config.oauth?.resourceUrl : undefined;
}

export function attachmentFilename(filename: string): string {
if (!filename || filename === "." || filename === ".." || /[/\\\x00-\x1f\x7f]/u.test(filename)) {
throw new Error("use an attachment filename without directory components or control characters");
}
// Also reject unpaired UTF-16 surrogates before building a URL/header.
encodeURIComponent(filename);
return filename;
}

export function attachmentDownloadUrl(baseUrl: string, id: string, filename: string): string {
return `${baseUrl.replace(/\/$/u, "")}/attachments/${normalizeMessageId(id)}/${encodeURIComponent(attachmentFilename(filename))}`;
}

export function parseDownloadPath(pathname: string): { id: string; filename: string } {
const parts = pathname.slice(DOWNLOAD_PATH.length + 1).split("/");
if (parts.length !== 2) throw new Error("invalid attachment download path");
return { id: normalizeMessageId(parts[0]), filename: attachmentFilename(decodeURIComponent(parts[1]!)) };
}

/** Always download untrusted files; preserve Unicode filenames without raw header characters. */
export function downloadHeaders(filename: string, contentType?: string): Headers {
const fallback = filename.replace(/[^A-Za-z0-9._-]/gu, "_");
const encoded = encodeURIComponent(filename).replace(/[!'()*]/gu, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
return new Headers({
"content-type": contentType ?? "application/octet-stream",
"content-disposition": `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`,
"cache-control": "no-store",
"x-content-type-options": "nosniff",
"content-security-policy": "sandbox",
});
}
Loading
Loading