@markmnl/fmsg-mcp 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +78 -22
  3. package/ROADMAP.md +52 -0
  4. package/SECURITY.md +72 -0
  5. package/dist/auth.d.ts +14 -6
  6. package/dist/auth.js +115 -49
  7. package/dist/client/client.d.ts +34 -24
  8. package/dist/client/client.js +152 -105
  9. package/dist/client/errors.d.ts +19 -0
  10. package/dist/client/errors.js +51 -0
  11. package/dist/client/index.d.ts +1 -0
  12. package/dist/client/message-id.d.ts +1 -0
  13. package/dist/client/message-id.js +7 -0
  14. package/dist/client/stream.d.ts +12 -0
  15. package/dist/client/stream.js +83 -0
  16. package/dist/client/token-provider.d.ts +27 -0
  17. package/dist/client/token-provider.js +54 -0
  18. package/dist/client/types.d.ts +8 -4
  19. package/dist/client/url.d.ts +4 -0
  20. package/dist/client/url.js +35 -0
  21. package/dist/client/ws.d.ts +2 -1
  22. package/dist/client/ws.js +9 -3
  23. package/dist/config.d.ts +7 -3
  24. package/dist/config.js +15 -6
  25. package/dist/context.d.ts +6 -3
  26. package/dist/context.js +9 -2
  27. package/dist/errors.d.ts +1 -1
  28. package/dist/errors.js +18 -26
  29. package/dist/http.d.ts +4 -3
  30. package/dist/http.js +158 -23
  31. package/dist/index.js +27 -11
  32. package/dist/instructions.js +10 -5
  33. package/dist/oauth/config.d.ts +11 -0
  34. package/dist/oauth/config.js +46 -0
  35. package/dist/oauth/errors.d.ts +18 -0
  36. package/dist/oauth/errors.js +40 -0
  37. package/dist/oauth/issuer.d.ts +31 -0
  38. package/dist/oauth/issuer.js +185 -0
  39. package/dist/oauth/provider.d.ts +23 -0
  40. package/dist/oauth/provider.js +95 -0
  41. package/dist/oauth/scopes.d.ts +6 -0
  42. package/dist/oauth/scopes.js +31 -0
  43. package/dist/render.d.ts +6 -1
  44. package/dist/render.js +27 -11
  45. package/dist/resources.js +18 -9
  46. package/dist/server.js +2 -0
  47. package/dist/thread.js +15 -14
  48. package/dist/tools/common.js +8 -3
  49. package/dist/tools/identity.js +1 -1
  50. package/dist/tools/list.js +3 -3
  51. package/dist/tools/read.js +26 -43
  52. package/dist/tools/save.d.ts +2 -0
  53. package/dist/tools/save.js +84 -0
  54. package/dist/tools/send.js +9 -13
  55. package/dist/tools/wait.js +14 -5
  56. package/dist/wait.d.ts +15 -1
  57. package/dist/wait.js +232 -84
  58. package/docs/http-deployment.md +53 -0
  59. package/docs/oauth.md +163 -0
  60. package/docs/token-providers.md +94 -0
  61. package/package.json +8 -1
  62. package/server.json +7 -2
package/CHANGELOG.md ADDED
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 (unreleased)
4
+
5
+ This is the next planned release; publication still happens through a `v0.2.0` GitHub release.
6
+
7
+ ### Breaking changes
8
+
9
+ - `download_attachment` no longer accepts `save_to` or returns `saved_to`. It is read-only. Enable
10
+ the separate stdio-only `save_attachment` tool with `FMSG_MCP_DOWNLOAD_DIR` for direct streaming
11
+ to generated filenames; it never accepts a destination path or overwrites an existing file.
12
+ - Non-loopback HTTP binds require `FMSG_MCP_ALLOWED_HOSTS`. Browser origin entries must include
13
+ scheme and port. Loopback browser origins work automatically on loopback binds unless an explicit
14
+ list is set.
15
+ - Upstream API URLs require HTTPS outside loopback unless `FMSG_ALLOW_INSECURE_HTTP=1` explicitly
16
+ enables a trusted private development network. Authenticated redirects are refused.
17
+ - Text attachments return readable text; images return one image block rather than also duplicating
18
+ the image in an embedded resource. The default inline budget is 256 KiB; callers can raise it explicitly.
19
+
20
+ ### Fixes and improvements
21
+
22
+ - Add opt-in, vendor-neutral HTTP OAuth with protected-resource discovery, signed JWT validation,
23
+ per-tool scopes and authenticated RFC 8693 exchange. Isolate caches per incoming token, cap
24
+ upstream credentials at five minutes and reconnect waits on expiry. Preserve API-key mode.
25
+ Deployed IdP and actual hosted-client acceptance remain rollout checks.
26
+
27
+ - Accept caller-bound `TokenProvider` implementations in the client library alongside API keys.
28
+ Share renewal across concurrent requests, pin the address, bound acquisition time, and propagate
29
+ cancellation. Cap early renewal for short-lived tokens and reuse renewal after late 401 responses.
30
+ This provides the credential lifecycle used by API keys and HTTP OAuth.
31
+ - Retry protected reads when a WebSocket announces a message before it is readable. If retries run
32
+ out, schedule a delayed inbox catch-up without requiring another push. Fix pre-cancelled waits
33
+ and preserve request deadlines.
34
+ - Stream attachment bodies with an idle timeout instead of a total download deadline. Repeated saves
35
+ create numbered files without overwriting. Registry metadata lists the optional download folder.
36
+ - Deduplicate token exchanges and close evicted/invalidated clients once active requests finish.
37
+ Request identity survives cache eviction and SDK cloning of authentication metadata.
38
+ - Keep each message body fenced separately from its escaped header, and server guidance outside
39
+ the data. Clarify authorized conversation behavior and restore reversible/idempotent reaction annotations.
40
+ - Bound inline attachment reads and error previews while streaming. Preserve the host's canonical
41
+ JSON 400/413 policy explanations and per-recipient delivery codes, except selected secret redaction.
42
+ - Surface invalid stdio configuration through discoverable tools with corrective guidance.
43
+ - `FmsgClient.send()` reports `redactions` and the transmitted `topic`; selected credential formats
44
+ in bodies/topics are replaced once at the client boundary. Attachments remain unchanged.
45
+ - Custom HTTP adapters using `ApiKeyCallerProvider` must call `release(authInfo)` when each verified
46
+ request finishes; the built-in HTTP adapter handles this automatically.
47
+
48
+ Messaging permissions and quotas remain in fmsg-webapi. No additional MCP messaging approval flow
49
+ is introduced. See [GitHub releases](https://github.com/markmnl/fmsg-mcp/releases) for earlier notes.
package/README.md CHANGED
@@ -7,16 +7,19 @@
7
7
  An [MCP](https://modelcontextprotocol.io) server that gives any AI agent its own
8
8
  [fmsg](https://github.com/markmnl/fmsg) address: send messages, follow threads, react, exchange
9
9
  attachments and wait for replies, through a deployed
10
- [fmsg Web API](https://github.com/markmnl/fmsg-webapi). Works with Claude Code, Claude Desktop,
11
- Cursor, VS Code, claude.ai remote connectors and any other MCP host.
10
+ [fmsg Web API](https://github.com/markmnl/fmsg-webapi). Connect through stdio in hosts such as
11
+ Claude Code, Claude Desktop, Cursor and VS Code, or through HTTP using API-key headers or configured OAuth.
12
12
 
13
13
  - **stdio** for local hosts: one address per server process, configured by two environment variables.
14
14
  - **Streamable HTTP** for shared or remote deployments: one endpoint serving many users, each
15
- authenticated by their own fmsg API key.
15
+ authenticated by their own fmsg API key or OAuth connection.
16
16
  - The fmsg Web API client is exported for reuse: `import { FmsgClient } from "@markmnl/fmsg-mcp/client"`.
17
17
 
18
18
  ## 1. Get an fmsg address and API key
19
19
 
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).
22
+
20
23
  You send as an fmsg address, authenticated by an API key (`fmsgk_…`) issued by your fmsg host:
21
24
 
22
25
  - **No host yet?** Create an account at a public fmsg host such as [fmsg.io](https://fmsg.io) and
@@ -65,18 +68,36 @@ Run one server for many users. Each client sends **its own** fmsg API key as a b
65
68
  server exchanges it at the fmsg host and acts as that address. `FMSG_API_KEY` must not be set.
66
69
 
67
70
  ```sh
68
- FMSG_API_URL=https://api.example.com npx -y @markmnl/fmsg-mcp --http 0.0.0.0:8765
71
+ FMSG_API_URL=https://api.example.com FMSG_MCP_ALLOWED_HOSTS=mcp.example.com \
72
+ npx -y @markmnl/fmsg-mcp --http 0.0.0.0:8765
69
73
  # or
70
- docker build -t fmsg-mcp . && docker run -e FMSG_API_URL=https://api.example.com -p 8765:8765 fmsg-mcp
74
+ docker build -t fmsg-mcp .
75
+ docker run -e FMSG_API_URL=https://api.example.com \
76
+ -e FMSG_MCP_ALLOWED_HOSTS=mcp.example.com -p 8765:8765 fmsg-mcp
71
77
  ```
72
78
 
73
- The MCP endpoint is `/mcp`; `/healthz` reports liveness. Point a host at it with
74
- `Authorization: Bearer fmsgk_...` — for claude.ai, add a custom connector with that URL and header;
75
- for Claude Code, `claude mcp add --transport http fmsg https://mcp.example.com/mcp --header "Authorization: Bearer fmsgk_..."`.
79
+ The MCP endpoint is `/mcp`; `/healthz` reports liveness. Use a client that supports an explicitly
80
+ configured `Authorization: Bearer fmsgk_...` header. Each caller supplies its own key; a shared
81
+ header means a shared fmsg identity. For browser sign-in without user API keys, configure
82
+ [HTTP OAuth](docs/oauth.md): the operator supplies an issuer, resource URI and exchange client.
83
+ Users add the public MCP URL in a host supporting that issuer's client registration method.
84
+
85
+ For [Claude Code over HTTP](https://code.claude.com/docs/en/mcp):
86
+
87
+ ```sh
88
+ claude mcp add --transport http fmsg --scope user https://mcp.example.com/mcp \
89
+ --header "Authorization: Bearer fmsgk_..."
90
+ ```
76
91
 
77
92
  Deploy behind a TLS-terminating reverse proxy and set `FMSG_MCP_ALLOWED_HOSTS` to the public hostname
78
- when binding to a non-loopback address. `wait_for_message` holds a request open for up to
93
+ when binding to a non-loopback address; startup fails without it. Browser clients on another origin
94
+ also need `FMSG_MCP_ALLOWED_ORIGINS` containing exact origins, such as `https://app.example.com`.
95
+ For loopback binds, loopback browser origins on any port work by default, including MCP Inspector
96
+ at `http://localhost:6274`. Setting an explicit origin list replaces that loopback default.
97
+ Allowed preflights need no credentials; actual MCP requests always require authentication.
98
+ `wait_for_message` holds a request open for up to
79
99
  `FMSG_MCP_WAIT_MAX_SECONDS` (230), so give the proxy an idle timeout of at least 240 s.
100
+ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback deployment with Caddy.
80
101
 
81
102
  ## Tools
82
103
 
@@ -93,7 +114,8 @@ when binding to a non-loopback address. `wait_for_message` holds a request open
93
114
  | `add_recipients` | Add recipients to a sent message |
94
115
  | `react` | Set or clear your emoji reaction |
95
116
  | `mark_read` | Mark received messages read |
96
- | `download_attachment` | Fetch an attachment inline (base64, images as image blocks) or, over stdio, save it to disk |
117
+ | `download_attachment` | Fetch a small attachment inline: text as text, images as image blocks, other files as base64 resources |
118
+ | `save_attachment` | Stream an attachment to the configured local folder; stdio only, enabled by `FMSG_MCP_DOWNLOAD_DIR` |
97
119
  | `delivery_status` | Per-recipient delivery times and host response codes |
98
120
  | `wait_for_message` | Block until the next inbound message (WebSocket push), batched per thread, with thread context |
99
121
 
@@ -109,30 +131,52 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
109
131
  |---|---|---|
110
132
  | `FMSG_API_URL` | — | Base URL of the fmsg Web API (required) |
111
133
  | `FMSG_API_KEY` | — | `fmsgk_…` key; stdio mode only |
134
+ | `FMSG_MCP_AUTH_MODE` | `api-key` | HTTP authentication: `api-key` or `oauth`; see [OAuth settings](docs/oauth.md#operator-configuration) |
135
+ | `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 |
112
136
  | `FMSG_DEFAULT_DOMAIN` | — | Lets short names resolve: `bob` → `@bob@<domain>` |
113
137
  | `FMSG_DIRECTORY` | — | JSON file mapping short names to full addresses |
138
+ | `FMSG_MCP_DOWNLOAD_DIR` | — | Enable `save_attachment` in stdio; folder for new files named from message ID and filename |
114
139
  | `FMSG_MCP_WAIT_MAX_SECONDS` | `230` | Cap on one `wait_for_message` call |
115
- | `FMSG_MCP_DOWNLOAD_DIR` | — | Restrict `download_attachment` `save_to` to this directory (stdio) |
116
140
  | `FMSG_MCP_HOST` / `FMSG_MCP_PORT` | `127.0.0.1` / `8765` | HTTP bind address (or `--http host:port`) |
117
- | `FMSG_MCP_ALLOWED_HOSTS` | loopback names | Comma-separated `Host` header allowlist for HTTP mode |
118
- | `FMSG_MCP_ALLOWED_ORIGINS` | same as hosts | `Origin` allowlist for browser-based callers |
119
- | `FMSG_MCP_KEY_CACHE_MAX` / `FMSG_MCP_KEY_CACHE_TTL_SECONDS` | `500` / `1800` | HTTP mode per-key client cache |
141
+ | `FMSG_MCP_ALLOWED_HOSTS` | loopback names | Comma-separated `Host` header allowlist; required for non-loopback binds |
142
+ | `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 |
143
+ | `FMSG_MCP_KEY_CACHE_MAX` / `FMSG_MCP_KEY_CACHE_TTL_SECONDS` | `500` / `1800` | HTTP client cache bound; TTL applies only to API-key mode |
120
144
 
121
145
  The API key is exchanged for a short-lived access token that the server renews automatically.
146
+ API URLs must not contain credentials, query strings or fragments. Authenticated requests do not
147
+ follow redirects; configure the final API URL directly.
122
148
 
123
- Over stdio the server also starts with no credentials at all, so hosts and directories can list its tools; every tool call then returns a message naming the missing variables.
149
+ To save attachments directly to disk, add `FMSG_MCP_DOWNLOAD_DIR` to your stdio server's environment,
150
+ for example `/home/you/Downloads/fmsg`. The optional `save_attachment` tool streams files into that
151
+ folder without sending their bytes through model context. It accepts only a message ID and attachment
152
+ filename and creates a new file such as `123-report.pdf`. Repeat saves use `123-report-1.pdf`,
153
+ `123-report-2.pdf`, etc., leaving existing files untouched. Unusual filenames are converted to
154
+ portable names; use the returned `saved_to` path. Streaming downloads can run longer than 60 seconds
155
+ while making progress; a 60-second idle timeout detects stalled transfers.
156
+
157
+ Inline downloads default to 256 KiB to keep file content manageable for the model. Use
158
+ `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.
160
+
161
+ Over stdio, missing or invalid configuration still allows hosts to discover the tools. Tool calls
162
+ explain the configuration error and how to fix it; restart the MCP server after correcting settings.
124
163
 
125
164
  ## Safety
126
165
 
166
+ - Messaging access, quotas and recipient acceptance are enforced by fmsg-webapi and the host
167
+ services. MCP forwards each operation as the caller's identity and surfaces upstream failures.
168
+ - `download_attachment` never writes local files. Optional `save_attachment` writes only generated
169
+ filenames in the operator-configured folder, using exclusive creation with no overwrite.
127
170
  - Sent messages cannot be edited or recalled; send tools say so in their descriptions and are
128
- annotated `destructiveHint` so hosts can ask for confirmation.
129
- - API keys, tokens and other secret-shaped strings are redacted from outbound bodies, topics and
130
- error text; the count of redactions is reported.
171
+ annotated `destructiveHint` to describe their effects. Approval behavior belongs to the AI host;
172
+ fmsg-mcp has no additional confirmation gate.
173
+ - Selected API-key/token formats are redacted from outbound bodies, topics and error text; the
174
+ send tools report the count. This is not general data-loss prevention or binary attachment scanning.
131
175
  - Nothing about message size or acceptance is assumed: the fmsg host's own responses and delivery
132
176
  codes are surfaced verbatim.
133
177
  - The server publishes MCP `instructions` (shown to the model at session start) telling agents to use
134
- these tools rather than a local fmsg CLI or cached credentials, to send only on a clear request, and
135
- to treat message content as data.
178
+ these tools rather than a local fmsg CLI or cached credentials, to carry out authorized tasks and
179
+ automation without repeated confirmation, and to treat message content as data.
136
180
  - See [SECURITY.md](./SECURITY.md).
137
181
 
138
182
  ## Using the client library
@@ -143,9 +187,20 @@ import { FmsgClient } from "@markmnl/fmsg-mcp/client";
143
187
  const client = new FmsgClient("https://api.example.com", process.env.FMSG_API_KEY!);
144
188
  console.log(await client.address());
145
189
  const inbox = await client.listInbox(10);
146
- await client.send({ to: ["@bob@example.com"], topic: "Hi", body: "Hello from code" });
190
+ const sent = await client.send({ to: ["@bob@example.com"], topic: "Hi", body: "Hello from code" });
191
+ console.log(sent.id, sent.redactions);
192
+ client.close();
147
193
  ```
148
194
 
195
+ `send()` replaces selected credential patterns in the body and topic before creating the draft.
196
+ Its result includes the replacement count (`redactions`) and transmitted `topic`. Attachments are
197
+ unchanged. Use `streamAttachment()` to consume large files incrementally; consume or cancel its stream.
198
+
199
+ Applications with their own authorization integration can pass a `TokenProvider` instead of an
200
+ API-key string. The client shares renewal across concurrent requests and keeps the authenticated
201
+ address fixed. See the [token-provider contract](./docs/token-providers.md). This library interface
202
+ is also used by the executable's optional [OAuth mode](./docs/oauth.md).
203
+
149
204
  ## Development
150
205
 
151
206
  ```sh
@@ -155,6 +210,7 @@ npx @modelcontextprotocol/inspector node dist/index.js # stdio, with FM
155
210
  bash .github/scripts/run-fmsg-docker-e2e.sh # end to end on two real fmsg stacks
156
211
  ```
157
212
 
158
- See [AGENTS.md](./AGENTS.md) for layout and conventions.
213
+ See [AGENTS.md](./AGENTS.md) for layout and conventions, [ROADMAP.md](./ROADMAP.md) for remaining
214
+ integration work, and [CHANGELOG.md](./CHANGELOG.md) for release notes.
159
215
 
160
216
  [MIT licensed](./LICENSE)
package/ROADMAP.md ADDED
@@ -0,0 +1,52 @@
1
+ # Integration roadmap
2
+
3
+ Target: an MCP-capable agent can connect through a documented, tested path, identify its fmsg
4
+ account, and complete authorized messaging without exposing credentials or unexpected capabilities.
5
+ Support claims must name tested clients and versions; agents without MCP need an adapter.
6
+
7
+ ## Constraints
8
+
9
+ - fmsg-webapi and host services own messaging permissions, visibility, quotas and acceptance.
10
+ MCP binds each operation to its caller and surfaces upstream decisions.
11
+ - Normal stdio setup requires only an HTTPS API URL and key. No second login, duplicate ACLs,
12
+ recipient policies or per-message confirmation gates. The AI host owns tool approval settings.
13
+ - Keep authorized conversations and automation convenient. Incoming messages do not authorize
14
+ adding recipients, contacting new parties or disclosing other data.
15
+ - Remove obsolete server API fields directly while there are no external consumers requiring
16
+ compatibility. Keep protocol compatibility needed by supported MCP hosts.
17
+
18
+ ## Implementation sequence
19
+
20
+ | Work | Scope and completion criteria |
21
+ |---|---|
22
+ | A — MCP boundaries ([PR #2](https://github.com/markmnl/fmsg-mcp/pull/2)) | Separate read-only downloads from opt-in streamed saving; validate HTTP access; manage caller credentials and cancellation; preserve upstream authorization; frame untrusted content without obscuring server guidance. Regression and real-stack isolation tests cover these boundaries. |
23
+ | B — Receive reliability | Scan backlogs to a safe cursor boundary, including bursts, pending batches, interleaved threads and reconnects. Never advance past unseen work. Bound stream reads, response assembly, concurrent waits and overall deadlines. |
24
+ | C — Action outcomes | Preserve upstream denials and delivery codes. Return a durable reference and recovery guidance when a send may have committed but its response was lost; coordinate idempotency with the upstream API. |
25
+ | D — Painless local integration | Add a non-sending doctor command, separate host recipes and a versioned compatibility matrix. Verify wait defaults, attachment save/upload workflows, independent Python clients, conformance, supported OSes and clean installation of the actual npm tarball. |
26
+ | E — Hosted OAuth | Provider-neutral discovery, account linkage, consent, token refresh and revocation, coordinated with the host/account system. Prove per-user isolation in actual hosted clients. Retain explicit API-key integration. |
27
+ | F — Release trust and operations | Synchronize the existing MCP Registry listing after npm publication; verify the published version. Harden release inputs and gates, reusing D's artifact checks. Add deployment metrics, runbooks, load testing and independent review when supporting shared hosted service. |
28
+
29
+ Ship A first, then B–D. Plan E with the host/account-system maintainer. Release work in F can proceed
30
+ earlier; hosted-service promises depend on verified OAuth and operational behavior.
31
+
32
+ E's [HTTP OAuth implementation](docs/oauth.md) includes discovery, incoming JWT validation, tool
33
+ scopes, token exchange, isolated renewal and expiring-socket reconnects. Local signed-token tests
34
+ cover these boundaries. Deployed IdP/Web API and hosted-client acceptance remain open. The Web API
35
+ must enforce delegated scopes and closed owner routes before OAuth is enabled; messaging
36
+ credentials must not inherit owner credential-management privileges.
37
+
38
+ Release-triggered npm publication, OIDC trusted publishing, provenance generation and version
39
+ synchronization already exist in [publish.yml](.github/workflows/publish.yml). Preserve them.
40
+ [CI](https://github.com/markmnl/fmsg-mcp/actions/workflows/tests.yml) already covers Node 22/24,
41
+ the Docker image and real two-host acceptance. PR checks record validation for each revision.
42
+
43
+ ## Broad-integration release criteria
44
+
45
+ - Fresh installs on every claimed client/OS reach `whoami` and inbox using the documented setup.
46
+ - Caller isolation and upstream authorization hold across tools, resources, attachments and waits.
47
+ - Backlog/reconnect tests prove no silent cursor loss; cancellation releases work promptly.
48
+ - Tool deadlines and payload budgets fit verified host configurations; large files are practical
49
+ without manual base64 handling or overflowing model context.
50
+ - Ambiguous sends have a documented reconciliation path that avoids blind duplicate sends.
51
+ - Advertised hosted integrations pass identity, refresh, revocation and disconnect checks.
52
+ - The npm artifact, registry metadata, release notes and compatibility results agree.
package/SECURITY.md ADDED
@@ -0,0 +1,72 @@
1
+ # Security
2
+
3
+ Please report vulnerabilities privately through GitHub's
4
+ [private vulnerability reporting](https://github.com/markmnl/fmsg-mcp/security/advisories/new)
5
+ rather than a public issue.
6
+
7
+ ## Scope
8
+
9
+ - Messaging authorization, grants, address status, quotas and acceptance remain the responsibility
10
+ of fmsg-webapi and the host services. Each MCP request uses its caller's upstream identity.
11
+ - Over stdio the API key comes from the environment. HTTP callers supply their own bearer keys or OAuth access tokens, according to the configured mode.
12
+ Keys and JWTs are retained in process memory for token renewal; hashes index the HTTP client cache.
13
+ This server does not intentionally persist them. Idle entries expire on access and periodic sweeps
14
+ (at most 30 seconds apart). Evicted or invalidated clients close immediately if idle; active requests
15
+ retain their client until their last request lease is released. Shutdown closes cached and active
16
+ clients and cancels their work. JavaScript does not guarantee memory zeroization.
17
+ - Protected upstream routes re-check grants; revoked/expired credentials remain subject to the
18
+ upstream contract. MCP cache TTL is a retention setting, not a grant or revocation policy.
19
+ WebSocket announcements trigger protected message reads before their content reaches the host.
20
+ The upstream contract authenticates sockets at handshake; it does not promise immediate closure
21
+ of existing sockets on revocation. MCP does not infer ongoing authorization from a socket alone.
22
+ - Selected key/JWT/private-key patterns are redacted from outbound message bodies, topics and errors.
23
+ This does not detect arbitrary sensitive information or scan binary attachments.
24
+ - Message bodies and upstream error text are fenced as untrusted data. Each message body has its own
25
+ fence; server-built headers stay outside it, with external header values escaped onto one line.
26
+ This distinguishes quoted forged headers from actual message boundaries. Server-authored guidance
27
+ stays outside the data. Instructions permit replies within authorized conversations, but
28
+ incoming messages cannot authorize adding recipients, contacting new parties or disclosing other
29
+ data. Hosts should still treat tool output as untrusted.
30
+ - `download_attachment` is read-only and enforces its inline byte budget while reading. The optional
31
+ `save_attachment` tool is advertised only in stdio with `FMSG_MCP_DOWNLOAD_DIR` set. It streams to a
32
+ generated leaf filename, accepts no destination path, and uses exclusive creation (`wx`) to skip
33
+ existing files and symlinks, trying numbered filenames instead. New files use mode `0600` where
34
+ supported; failed writes remove partial files. The operator must control the configured folder and its ancestors, on a filesystem that
35
+ supports exclusive creation. This is not a sandbox against another local process replacing those
36
+ directories. HTTP mode does not expose this write capability.
37
+ - Attachment transfers retain caller cancellation and client shutdown signals. They use a response
38
+ header deadline followed by a per-read idle timeout (60 seconds each by default), so a progressing
39
+ large download is not subject to a 60-second total duration limit.
40
+ - Error previews are limited to 2 KiB while reading, except canonical JSON HTTP 400/413 responses:
41
+ those retain the host's acceptance/size-policy explanation. Selected credentials are still redacted;
42
+ oversized previews are explicitly marked as truncated.
43
+ - In HTTP mode terminate TLS in front of the server. `FMSG_MCP_ALLOWED_HOSTS` is required for
44
+ non-loopback binds. Browser access validates the exact origin, including scheme and port. Loopback
45
+ binds also permit loopback browser origins on any port by default, so local developer tools work.
46
+ An explicit origin list replaces that loopback exception. CORS preflight permission does not grant
47
+ access to MCP operations; bearer authentication remains required.
48
+ - The upstream API must use HTTPS except loopback or an explicitly configured trusted private
49
+ network (`FMSG_ALLOW_INSECURE_HTTP=1`). Authenticated HTTP redirects are refused.
50
+ - Tool annotations and message-data labels guide the AI host; they do not prove user approval or
51
+ prevent prompt injection. The AI host owns tool-use permissions and authorization of automation.
52
+ OAuth mode adds resource-token validation and messaging scope checks, with sign-in/consent at
53
+ the configured authorization server. fmsg-mcp adds no recipient ACL, approval gate or per-message
54
+ confirmation requirement. Normal stdio setup needs only an HTTPS API URL and API key; token renewal and cache
55
+ management run automatically. Guidance permits ongoing work within the user's authorized task or
56
+ automation, subject to the AI host's own approval settings.
57
+
58
+ ## OAuth boundary
59
+
60
+ OAuth mode validates the configured issuer, exact MCP audience, EdDSA signature, key ID,
61
+ `at+jwt` type, lifetime and address. Only the configured issuer's discovered JWKS is trusted.
62
+ Incoming tokens are exchanged with confidential-client authentication and are never forwarded
63
+ to the Web API. No `X-FMSG-Act-As` header is sent. The Web API must enforce delegated scopes
64
+ and refuse owner-only routes independently of this server.
65
+
66
+ Exchanged tokens are cached per incoming token for at most five minutes and never past either
67
+ token's expiry. Existing sockets close and renew within that deadline. Incoming tokens validate
68
+ offline until expiry; immediate revocation is checked at exchange. No local cache makes
69
+ revocation immediate or shortens the lifetime of a copied token at another service. See the
70
+ [OAuth contract and revocation limits](docs/oauth.md#exchange-renewal-and-revocation).
71
+
72
+ When reporting, please remove API keys, tokens, addresses and message bodies from logs.
package/dist/auth.d.ts CHANGED
@@ -2,20 +2,28 @@ import { type AuthInfo, type OAuthTokenVerifier } from "@modelcontextprotocol/se
2
2
  import type { Config } from "./config.js";
3
3
  import type { Caller, CallerProvider } from "./context.js";
4
4
  export declare const FMSG_SCOPE = "fmsg";
5
- /**
6
- * HTTP mode: each request carries an fmsg API key as its bearer token. The key
7
- * is exchanged once at the fmsg host, and the resulting client (which renews
8
- * its own JWT) is cached by the key's hash. Raw keys are never stored or logged.
9
- */
5
+ /** Per-key clients retain credentials for renewal; upstream decides access. */
10
6
  export declare class ApiKeyCallerProvider implements CallerProvider, OAuthTokenVerifier {
11
7
  private readonly config;
12
8
  private readonly log;
13
9
  private readonly entries;
10
+ private readonly pending;
11
+ private readonly pendingClients;
12
+ private readonly leases;
13
+ private readonly timer;
14
+ private closed;
14
15
  constructor(config: Config, log?: (line: string) => void);
15
16
  static cacheKey(apiKey: string): string;
17
+ private releaseEntry;
18
+ private drop;
16
19
  private evict;
17
- /** Bearer verifier for the MCP gate: exchange the key, return the caller's identity. */
20
+ private createEntry;
18
21
  verifyAccessToken(token: string): Promise<AuthInfo>;
22
+ private entryFor;
19
23
  forRequest(authInfo: AuthInfo | undefined): Promise<Caller>;
24
+ /** The HTTP adapter releases this lease when the response or connection ends. */
25
+ release(authInfo: AuthInfo): void;
26
+ invalidate(caller: Caller): void;
27
+ close(): void;
20
28
  get size(): number;
21
29
  }
package/dist/auth.js CHANGED
@@ -1,84 +1,150 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { OAuthError, OAuthErrorCode } from "@modelcontextprotocol/server";
3
3
  import { FmsgClient, FmsgHttpError } from "./client/client.js";
4
+ import { safeErrorMessage } from "./client/redact.js";
4
5
  export const FMSG_SCOPE = "fmsg";
5
- /**
6
- * HTTP mode: each request carries an fmsg API key as its bearer token. The key
7
- * is exchanged once at the fmsg host, and the resulting client (which renews
8
- * its own JWT) is cached by the key's hash. Raw keys are never stored or logged.
9
- */
6
+ /** Per-key clients retain credentials for renewal; upstream decides access. */
10
7
  export class ApiKeyCallerProvider {
11
8
  config;
12
9
  log;
13
10
  entries = new Map();
11
+ pending = new Map();
12
+ pendingClients = new Set();
13
+ // An opaque request lease survives SDK cloning and pins the caller across eviction.
14
+ leases = new Map();
15
+ timer;
16
+ closed = false;
14
17
  constructor(config, log = () => undefined) {
15
18
  this.config = config;
16
19
  this.log = log;
20
+ this.timer = setInterval(() => this.evict(), Math.min(config.http.keyCacheTtlMs, 30_000)).unref();
17
21
  }
18
- static cacheKey(apiKey) {
19
- return createHash("sha256").update(apiKey).digest("hex");
22
+ static cacheKey(apiKey) { return createHash("sha256").update(apiKey).digest("hex"); }
23
+ releaseEntry(entry) {
24
+ if (!entry.active && this.entries.get(entry.key) !== entry)
25
+ entry.caller.client.close();
26
+ }
27
+ drop(entry) {
28
+ if (this.entries.get(entry.key) === entry)
29
+ this.entries.delete(entry.key);
30
+ this.releaseEntry(entry);
20
31
  }
21
32
  evict() {
22
33
  const now = Date.now();
23
- for (const [key, entry] of this.entries) {
24
- if (now - entry.lastUsed > this.config.http.keyCacheTtlMs)
25
- this.entries.delete(key);
34
+ for (const entry of this.entries.values()) {
35
+ if (now - entry.lastUsed >= this.config.http.keyCacheTtlMs)
36
+ this.drop(entry);
26
37
  }
27
38
  while (this.entries.size > this.config.http.keyCacheMax) {
28
- const oldest = [...this.entries.entries()].sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
39
+ const oldest = [...this.entries.values()].sort((a, b) => a.lastUsed - b.lastUsed)[0];
29
40
  if (!oldest)
30
41
  break;
31
- this.entries.delete(oldest[0]);
42
+ this.drop(oldest);
43
+ }
44
+ }
45
+ async createEntry(token, key) {
46
+ const client = new FmsgClient(this.config.apiUrl, token, { allowInsecureHttp: this.config.allowInsecureHttp });
47
+ this.pendingClients.add(client);
48
+ try {
49
+ const address = await client.address();
50
+ if (this.closed)
51
+ throw new Error("server is closing");
52
+ const entry = { key, caller: { client, address, tokenExpiresAt: async () => (await client.getToken()).expiresAtMs }, lastUsed: Date.now(), active: 0 };
53
+ this.entries.set(key, entry);
54
+ this.log(safeErrorMessage(`authenticated ${address} (key ${key.slice(0, 8)}…)`));
55
+ return entry;
56
+ }
57
+ catch (error) {
58
+ client.close();
59
+ throw error;
60
+ }
61
+ finally {
62
+ this.pendingClients.delete(client);
32
63
  }
33
64
  }
34
- /** Bearer verifier for the MCP gate: exchange the key, return the caller's identity. */
35
65
  async verifyAccessToken(token) {
36
- if (!token.startsWith("fmsgk_")) {
66
+ if (this.closed)
67
+ throw new OAuthError(OAuthErrorCode.ServerError, "server is closing");
68
+ if (!token.startsWith("fmsgk_"))
37
69
  throw new OAuthError(OAuthErrorCode.InvalidToken, "bearer token must be an fmsg API key (fmsgk_...)");
38
- }
39
70
  const key = ApiKeyCallerProvider.cacheKey(token);
40
- let entry = this.entries.get(key);
41
- if (!entry) {
42
- const client = new FmsgClient(this.config.apiUrl, token);
43
- let address;
44
- try {
45
- address = await client.address();
46
- }
47
- catch (error) {
48
- if (error instanceof FmsgHttpError && (error.status === 401 || error.status === 403 || error.status === 400)) {
49
- this.log(`rejected api key ${key.slice(0, 8)}…: ${error.status} ${error.message}`);
50
- throw new OAuthError(OAuthErrorCode.InvalidToken, `fmsg host rejected the API key: ${error.message}`);
71
+ this.evict();
72
+ let entry;
73
+ try {
74
+ entry = this.entries.get(key);
75
+ if (!entry) {
76
+ let pending = this.pending.get(key);
77
+ if (!pending) {
78
+ if (this.pending.size >= this.config.http.keyCacheMax)
79
+ throw new Error("too many concurrent token exchanges");
80
+ pending = this.createEntry(token, key);
81
+ this.pending.set(key, pending);
82
+ void pending.finally(() => this.pending.delete(key)).catch(() => undefined);
51
83
  }
52
- this.log(`token exchange failed for ${key.slice(0, 8)}…: ${error instanceof Error ? error.message : String(error)}`);
53
- throw new OAuthError(OAuthErrorCode.ServerError, "fmsg host unavailable for token exchange");
84
+ entry = await pending;
54
85
  }
55
- entry = {
56
- caller: { client, address, tokenExpiresAt: async () => (await client.getToken()).expiresAtMs },
57
- lastUsed: Date.now(),
58
- };
59
- this.entries.set(key, entry);
86
+ entry.active++;
87
+ entry.lastUsed = Date.now();
60
88
  this.evict();
61
- this.log(`authenticated ${address} (key ${key.slice(0, 8)}…)`);
89
+ const expiresAtMs = await entry.caller.tokenExpiresAt();
90
+ if (this.closed)
91
+ throw new Error("server is closing");
92
+ const lease = randomUUID();
93
+ this.leases.set(lease, entry);
94
+ return {
95
+ token: key, clientId: entry.caller.address, scopes: [FMSG_SCOPE],
96
+ expiresAt: Math.floor(expiresAtMs / 1000), extra: { cacheKey: key, callerLease: lease },
97
+ };
98
+ }
99
+ catch (error) {
100
+ if (entry) {
101
+ entry.active--;
102
+ this.drop(entry);
103
+ }
104
+ this.log(safeErrorMessage(`token exchange failed for ${key.slice(0, 8)}…: ${error instanceof Error ? error.message : String(error)}`));
105
+ if (error instanceof FmsgHttpError && [400, 401, 403].includes(error.status)) {
106
+ throw new OAuthError(OAuthErrorCode.InvalidToken, `fmsg host rejected the API key: ${safeErrorMessage(error)}`);
107
+ }
108
+ throw new OAuthError(OAuthErrorCode.ServerError, "fmsg host unavailable for token exchange");
62
109
  }
63
- entry.lastUsed = Date.now();
64
- const expiresAtMs = await entry.caller.tokenExpiresAt();
65
- return {
66
- token: key,
67
- clientId: entry.caller.address,
68
- scopes: [FMSG_SCOPE],
69
- expiresAt: Math.floor(expiresAtMs / 1000),
70
- extra: { cacheKey: key },
71
- };
110
+ }
111
+ entryFor(auth) {
112
+ const lease = auth?.extra?.callerLease;
113
+ const entry = typeof lease === "string" ? this.leases.get(lease) : undefined;
114
+ return entry && auth?.token === entry.key && auth.extra?.cacheKey === entry.key &&
115
+ auth.clientId === entry.caller.address && auth.scopes.includes(FMSG_SCOPE) ? entry : undefined;
72
116
  }
73
117
  async forRequest(authInfo) {
74
- const key = typeof authInfo?.extra?.cacheKey === "string" ? authInfo.extra.cacheKey : authInfo?.token;
75
- const entry = key ? this.entries.get(key) : undefined;
76
- if (!entry)
118
+ const entry = this.entryFor(authInfo);
119
+ if (this.closed || !entry)
77
120
  throw new Error("not authenticated: send your fmsg API key as `Authorization: Bearer fmsgk_...`");
78
121
  entry.lastUsed = Date.now();
79
122
  return entry.caller;
80
123
  }
81
- get size() {
82
- return this.entries.size;
124
+ /** The HTTP adapter releases this lease when the response or connection ends. */
125
+ release(authInfo) {
126
+ const entry = this.entryFor(authInfo);
127
+ if (!entry)
128
+ return;
129
+ this.leases.delete(authInfo.extra.callerLease);
130
+ entry.active--;
131
+ this.releaseEntry(entry);
132
+ }
133
+ invalidate(caller) {
134
+ for (const entry of this.entries.values())
135
+ if (entry.caller === caller)
136
+ this.drop(entry);
137
+ }
138
+ close() {
139
+ this.closed = true;
140
+ clearInterval(this.timer);
141
+ for (const entry of [...this.entries.values(), ...this.leases.values()])
142
+ entry.caller.client.close();
143
+ for (const client of this.pendingClients)
144
+ client.close();
145
+ this.entries.clear();
146
+ this.leases.clear();
147
+ this.pending.clear();
83
148
  }
149
+ get size() { return this.entries.size; }
84
150
  }