@markmnl/fmsg-mcp 0.1.4 → 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.
- package/CHANGELOG.md +49 -0
- package/README.md +78 -22
- package/ROADMAP.md +52 -0
- package/SECURITY.md +72 -0
- package/dist/auth.d.ts +14 -6
- package/dist/auth.js +115 -49
- package/dist/client/client.d.ts +34 -24
- package/dist/client/client.js +152 -105
- package/dist/client/errors.d.ts +19 -0
- package/dist/client/errors.js +51 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/stream.d.ts +12 -0
- package/dist/client/stream.js +83 -0
- package/dist/client/token-provider.d.ts +27 -0
- package/dist/client/token-provider.js +54 -0
- package/dist/client/types.d.ts +8 -4
- package/dist/client/url.d.ts +4 -0
- package/dist/client/url.js +35 -0
- package/dist/client/ws.d.ts +2 -1
- package/dist/client/ws.js +9 -3
- package/dist/config.d.ts +7 -3
- package/dist/config.js +15 -6
- package/dist/context.d.ts +6 -3
- package/dist/context.js +9 -2
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +18 -26
- package/dist/http.d.ts +4 -3
- package/dist/http.js +158 -23
- package/dist/index.js +27 -11
- package/dist/instructions.js +10 -5
- package/dist/oauth/config.d.ts +11 -0
- package/dist/oauth/config.js +46 -0
- package/dist/oauth/errors.d.ts +18 -0
- package/dist/oauth/errors.js +40 -0
- package/dist/oauth/issuer.d.ts +31 -0
- package/dist/oauth/issuer.js +185 -0
- package/dist/oauth/provider.d.ts +23 -0
- package/dist/oauth/provider.js +95 -0
- package/dist/oauth/scopes.d.ts +6 -0
- package/dist/oauth/scopes.js +31 -0
- package/dist/render.d.ts +6 -1
- package/dist/render.js +27 -11
- package/dist/resources.js +18 -9
- package/dist/server.js +2 -0
- package/dist/thread.js +15 -14
- package/dist/tools/common.js +8 -3
- package/dist/tools/identity.js +1 -1
- package/dist/tools/list.js +3 -3
- package/dist/tools/read.js +26 -43
- package/dist/tools/save.d.ts +2 -0
- package/dist/tools/save.js +84 -0
- package/dist/tools/send.js +9 -13
- package/dist/tools/wait.js +8 -6
- package/dist/wait.d.ts +1 -1
- package/dist/wait.js +161 -67
- package/docs/http-deployment.md +53 -0
- package/docs/oauth.md +163 -0
- package/docs/token-providers.md +94 -0
- package/package.json +8 -1
- 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).
|
|
11
|
-
|
|
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
|
|
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 .
|
|
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.
|
|
74
|
-
`Authorization: Bearer fmsgk_...`
|
|
75
|
-
|
|
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
|
|
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
|
|
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
|
|
118
|
-
| `FMSG_MCP_ALLOWED_ORIGINS` | same
|
|
119
|
-
| `FMSG_MCP_KEY_CACHE_MAX` / `FMSG_MCP_KEY_CACHE_TTL_SECONDS` | `500` / `1800` | HTTP
|
|
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
|
-
|
|
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`
|
|
129
|
-
-
|
|
130
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
if (now - entry.lastUsed
|
|
25
|
-
this.
|
|
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.
|
|
39
|
+
const oldest = [...this.entries.values()].sort((a, b) => a.lastUsed - b.lastUsed)[0];
|
|
29
40
|
if (!oldest)
|
|
30
41
|
break;
|
|
31
|
-
this.
|
|
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 (
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
throw new OAuthError(OAuthErrorCode.ServerError, "fmsg host unavailable for token exchange");
|
|
84
|
+
entry = await pending;
|
|
54
85
|
}
|
|
55
|
-
entry
|
|
56
|
-
|
|
57
|
-
lastUsed: Date.now(),
|
|
58
|
-
};
|
|
59
|
-
this.entries.set(key, entry);
|
|
86
|
+
entry.active++;
|
|
87
|
+
entry.lastUsed = Date.now();
|
|
60
88
|
this.evict();
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
scopes:
|
|
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
|
|
75
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
}
|