@markmnl/fmsg-mcp 1.2.3 → 1.4.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 +25 -0
- package/README.md +36 -5
- package/dist/build-info.json +5 -0
- package/dist/client/client.d.ts +6 -0
- package/dist/client/client.js +21 -1
- package/dist/client/types.d.ts +11 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +10 -0
- package/dist/http.js +126 -12
- package/dist/index.js +3 -3
- package/dist/indicators.d.ts +35 -0
- package/dist/indicators.js +186 -0
- package/dist/instructions.js +5 -3
- package/dist/links.d.ts +63 -0
- package/dist/links.js +104 -0
- package/dist/oauth/scopes.js +2 -1
- package/dist/prompts.js +4 -2
- package/dist/public.d.ts +1 -0
- package/dist/public.js +1 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +14 -2
- package/dist/tools/attachments.d.ts +6 -2
- package/dist/tools/attachments.js +17 -6
- package/dist/tools/common.d.ts +6 -0
- package/dist/tools/download.js +86 -7
- package/dist/tools/draft.js +17 -10
- package/dist/tools/identity.js +9 -3
- package/dist/tools/parent.d.ts +33 -0
- package/dist/tools/parent.js +77 -0
- package/dist/tools/read.d.ts +7 -3
- package/dist/tools/read.js +10 -8
- package/dist/tools/send.d.ts +5 -2
- package/dist/tools/send.js +53 -13
- package/dist/upload.d.ts +22 -0
- package/dist/upload.js +80 -0
- package/dist/version.d.ts +15 -0
- package/dist/version.js +27 -1
- package/docs/http-deployment.md +15 -0
- package/docs/oauth.md +1 -1
- package/package.json +3 -2
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- FMSG-006 indicators. The new `indicate` tool shows the other participants of a message a short-lived
|
|
6
|
+
status such as ⏳ or ✍️, refreshed at half its ttl (every 1–30 s) and cleared when the agent replies in
|
|
7
|
+
that thread, reacts, marks it read, after 5 minutes, or when the session ends; an empty emoji clears it
|
|
8
|
+
now. Nothing is shown automatically: reading a message never starts one. Refreshes and automatic clears
|
|
9
|
+
are best effort and never fail a tool. `FmsgClient.indicate()` posts to `/fmsg/{id}/indicate`;
|
|
10
|
+
`createFmsgMcpServer` takes an optional shared `indicators` registry for per-request HTTP factories.
|
|
11
|
+
- Presigned links for hosts that cannot send the connection's Authorization header with their own HTTP
|
|
12
|
+
requests: `get_attachment_download_url` with `presigned: true`, and the new `get_attachment_upload_url` for
|
|
13
|
+
draft files. Each link is single-use, valid for 5 minutes and bound to one file's size and sha256 (`/mcp/links/{token}`),
|
|
14
|
+
and comes with its `expires_at`, `size`, `sha256` and a ready-to-run `curl` command. A download whose
|
|
15
|
+
attachment changed since the link was issued is refused or cut off before its last bytes. `FMSG_MCP_STATE_SECRET` (at least 32 bytes)
|
|
16
|
+
sets the signing key.
|
|
17
|
+
- `data_base64` is described as best effort, because the base64 passes through the model: fine for small
|
|
18
|
+
files (verified exact up to 16 KB), always with `sha256`, and `path` or a presigned link for anything larger.
|
|
19
|
+
- A recipient added to a message after it was sent can reply to it. The Web API takes such a reply only
|
|
20
|
+
through the hash of the add-to batch that added them; `reply`, `create_draft` (and so `send_draft`) now
|
|
21
|
+
send it as the parent, where before the host refused with "not a participant of this parent". A reply
|
|
22
|
+
from an added recipient to an earlier message in the thread, which they cannot reply to, is refused with
|
|
23
|
+
an error naming the message they were added to.
|
|
24
|
+
- `whoami` reports the server version in its structured result (`server_version`, plus `server_commit` and
|
|
25
|
+
`server_build` when recorded), not only in its text, which hosts that show models only the structured
|
|
26
|
+
result never passed on. `/healthz` adds the commit. `npm run build` records the version and commit from
|
|
27
|
+
git; the Docker image takes them as build arguments. Builds from source no longer report the stale
|
|
28
|
+
`package.json` version 0.1.4: it now matches the last release, and a release tag takes precedence.
|
|
29
|
+
|
|
5
30
|
- Over OAuth, the owner comes from the `owner` claim the IdP puts on the app's access token
|
|
6
31
|
(and on the exchanged Web API token), so agents that sign in (ChatGPT connectors) include
|
|
7
32
|
their owner like API-key callers do. 1.2.0 only read the API-key token's claim, so a
|
package/README.md
CHANGED
|
@@ -98,12 +98,16 @@ server exchanges it at the fmsg host and acts as that address. `FMSG_API_KEY` mu
|
|
|
98
98
|
FMSG_API_URL=https://api.example.com FMSG_MCP_ALLOWED_HOSTS=mcp.example.com \
|
|
99
99
|
npx -y @markmnl/fmsg-mcp --http 0.0.0.0:8765
|
|
100
100
|
# or
|
|
101
|
-
docker build -t fmsg-mcp
|
|
101
|
+
docker build -t fmsg-mcp --build-arg FMSG_MCP_COMMIT=$(git rev-parse HEAD) \
|
|
102
|
+
--build-arg FMSG_MCP_VERSION=$(git describe --tags --abbrev=0) .
|
|
102
103
|
docker run -e FMSG_API_URL=https://api.example.com \
|
|
103
104
|
-e FMSG_MCP_ALLOWED_HOSTS=mcp.example.com -p 8765:8765 fmsg-mcp
|
|
104
105
|
```
|
|
105
106
|
|
|
106
|
-
The MCP endpoint is `/mcp`; `/healthz` reports liveness
|
|
107
|
+
The MCP endpoint is `/mcp`; `/healthz` reports liveness, the version and the commit. `whoami` reports the
|
|
108
|
+
same, so a client can confirm which deploy it is talking to: `npm run build` records them from git, and the
|
|
109
|
+
Docker build arguments above do so where the build has no git history (`FMSG_MCP_COMMIT` can also be set at
|
|
110
|
+
run time). Use a client that supports an explicitly
|
|
107
111
|
configured `Authorization: Bearer fmsgk_...` header. Each caller supplies its own key; a shared
|
|
108
112
|
header means a shared fmsg identity. For browser sign-in without user API keys, configure
|
|
109
113
|
[HTTP OAuth](docs/oauth.md): the operator supplies an issuer, resource URI and exchange client.
|
|
@@ -133,7 +137,7 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
|
|
|
133
137
|
|
|
134
138
|
| Tool | What it does |
|
|
135
139
|
|---|---|
|
|
136
|
-
| `whoami` | The address this server acts as, its `owner` if it is a sub-address, the public API URL (when known), the transport (`stdio` or `http`, which is Streamable HTTP)
|
|
140
|
+
| `whoami` | The address this server acts as, its `owner` if it is a sub-address, the public API URL (when known), the transport (`stdio` or `http`, which is Streamable HTTP), short-name defaults, and this server's version and commit |
|
|
137
141
|
| `resolve_address` | Turn a short name into `@user@domain` (directory, then default domain) |
|
|
138
142
|
| `list_messages` | Inbox, newest first, with previews, attachment types and `size` (`compressed` marks a body sent deflated); reactions hidden and never counted as unread unless `include_reactions` |
|
|
139
143
|
| `list_sent` | Sent messages with per-recipient delivery state |
|
|
@@ -146,9 +150,11 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
|
|
|
146
150
|
| `delete_draft` | Discard an unsent draft |
|
|
147
151
|
| `add_recipients` | Add `recipients` to a sent message (`add_to` is a deprecated alias) |
|
|
148
152
|
| `react` | Set or clear your emoji reaction |
|
|
153
|
+
| `indicate` | Show the other participants a short-lived status on a message, such as ⏳ while working on a reply or ✍️ while composing (FMSG-006 indicator; `ttl` 1–120 s, default 60), kept up until you reply, react or mark it read (up to 5 minutes); an empty `emoji` clears it. Reading a message never shows one |
|
|
149
154
|
| `mark_read` | Mark received messages read |
|
|
150
155
|
| `download_attachment` | Fetch a small attachment inline: text as text, images as image blocks, other files as base64 resources; a wrong filename lists the names the message has |
|
|
151
|
-
| `get_attachment_download_url` | Get a credential-free URL and resource link for an authenticated binary download;
|
|
156
|
+
| `get_attachment_download_url` | Get a credential-free URL and resource link for an authenticated binary download; with `presigned: true`, also a single-use link valid for 5 minutes that needs no Authorization header, with its size, sha256 and a curl command. HTTP only, requires a public MCP URL |
|
|
157
|
+
| `get_attachment_upload_url` | Get a single-use link valid for 5 minutes that PUTs one file of a given size and sha256 into a draft without the Authorization header, with a curl command; then `send_draft`. HTTP only, requires a public MCP URL |
|
|
152
158
|
| `save_attachment` | Stream an attachment to the configured local folder; stdio only, enabled by `FMSG_MCP_DOWNLOAD_DIR` |
|
|
153
159
|
| `delivery_status` | Per-recipient delivery times and fmsg response codes (`200` accepted; `null` when not recorded); for a received message, the host's own receipt records; `via` is `to` or `add_to` (added later) |
|
|
154
160
|
| `wait_for_message` | Block until the next inbound message (WebSocket push), batched per thread, with thread context, skipped reactions and a `next` step that names messages from other threads (`pending_ids`) the cursor moved past |
|
|
@@ -174,6 +180,21 @@ required. Releases add fields and values; they do not remove or rename them. Sch
|
|
|
174
180
|
0.2.6 and earlier were closed, so a host that cached one rejects results with newer fields (for
|
|
175
181
|
example "must NOT have additional properties"); remove and re-add the connector once to refresh it.
|
|
176
182
|
|
|
183
|
+
### Indicators
|
|
184
|
+
|
|
185
|
+
`indicate` shows a message's other participants a short-lived status, such as ⏳ while the agent
|
|
186
|
+
works on a reply or ✍️ while composing: an
|
|
187
|
+
[FMSG-006](https://github.com/markmnl/fmsg/blob/main/standards/fmsg-006-indicators.md) indicator.
|
|
188
|
+
Nothing is shown unless the agent calls it; reading a message never does. It is sent at once with
|
|
189
|
+
its `ttl` (default 60 s) and refreshed at half the ttl (every 1–30 s) until the agent replies to
|
|
190
|
+
that message or anywhere in its thread (`reply`, or `send_draft` of a reply), reacts to it, marks it
|
|
191
|
+
read, clears it with an empty `emoji`, or after 5 minutes, and when the session ends or the server
|
|
192
|
+
shuts down. At most 10 are kept up per address, the oldest cleared first. `indicate` returns the
|
|
193
|
+
host's refusal (such as a terminal message, or a host without indicators); refreshes and automatic
|
|
194
|
+
clears are best effort and never fail another tool. Over HTTP each MCP request gets a fresh server
|
|
195
|
+
instance, so indicators are kept per address by the HTTP server; embedders that build one server per
|
|
196
|
+
request pass a shared `IndicatorRegistry` as `indicators`.
|
|
197
|
+
|
|
177
198
|
Resources `fmsg://message/{id}` and `fmsg://thread/{id}` expose the same content to hosts that
|
|
178
199
|
attach resources; prompts `chat` and `reply` script the wait → reply loop and a guided reply, and
|
|
179
200
|
`send_file` sends files by the most efficient route the server offers.
|
|
@@ -193,6 +214,7 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
|
|
|
193
214
|
| `FMSG_MCP_DOWNLOAD_DIR` | — | Enable `save_attachment` in stdio; folder for new files named from message ID and filename |
|
|
194
215
|
| `FMSG_MCP_UPLOAD_DIR` | — | Allow attachment `path` arguments in stdio: files in this folder (or below it) are read by the server instead of written out by the model |
|
|
195
216
|
| `FMSG_MCP_WAIT_MAX_SECONDS` | `230` | Cap on one `wait_for_message` call |
|
|
217
|
+
| `FMSG_MCP_STATE_SECRET` | per-process random | At least 32 bytes; HMAC key for presigned links. Links still work only on the instance that issued them |
|
|
196
218
|
| `FMSG_MCP_HOST` / `FMSG_MCP_PORT` | `127.0.0.1` / `8765` | HTTP bind address (or `--http host:port`) |
|
|
197
219
|
| `FMSG_MCP_ALLOWED_HOSTS` | loopback names | Comma-separated `Host` header allowlist; required for non-loopback binds |
|
|
198
220
|
| `FMSG_MCP_ALLOWED_ORIGINS` | same origin; loopback origins on loopback binds | Comma-separated browser origins including scheme and port; an explicit list replaces the loopback default; hostname-only values are rejected |
|
|
@@ -229,7 +251,9 @@ original Content-Type and download filename. No files are stored on the MCP serv
|
|
|
229
251
|
|
|
230
252
|
`send_message`, `reply` and `create_draft` take each attachment as exactly one of:
|
|
231
253
|
|
|
232
|
-
- `data_base64`: base64 written by the model
|
|
254
|
+
- `data_base64`: base64 written by the model. It passes through the model, so it is best effort: fine
|
|
255
|
+
for small files (verified exact up to 16 KB). Always pass `sha256` with it, and prefer `path` or a
|
|
256
|
+
presigned upload link for anything larger. The standard or URL-safe
|
|
233
257
|
alphabet is accepted, but not both in one value. Decoding is strict: any other character, a bad
|
|
234
258
|
length or padding, or a stray ending is refused with the attachment's name, rather than sent as a
|
|
235
259
|
corrupted file.
|
|
@@ -244,6 +268,13 @@ with `x-content-sha256`), then `send_draft`; see
|
|
|
244
268
|
[attachment uploads](docs/http-deployment.md#attachment-uploads). Send files as they are: fmsg
|
|
245
269
|
compresses message bodies and attachments itself when it sends them.
|
|
246
270
|
|
|
271
|
+
Hosts that cannot attach the connection's credentials to their own HTTP requests can still keep file
|
|
272
|
+
bytes out of the model. `get_attachment_upload_url` returns a single-use, 5-minute link per draft
|
|
273
|
+
file, bound to its size and sha256, with a ready-to-run `curl` command. `get_attachment_download_url`
|
|
274
|
+
with `presigned: true` does the same for downloads. The link itself authorizes that one transfer. It
|
|
275
|
+
holds no API key or access token, but treat it as a secret until it is used or expires. It works only
|
|
276
|
+
on the server instance that issued it.
|
|
277
|
+
|
|
247
278
|
Models learn the routes from the server instructions and tool descriptions, which name only the
|
|
248
279
|
routes the running server offers. A model that writes a large file as base64 although a better
|
|
249
280
|
route exists gets a `hint` in the result naming it. The `send_file` prompt walks through the best
|
package/dist/client/client.d.ts
CHANGED
|
@@ -90,6 +90,12 @@ export declare class FmsgClient {
|
|
|
90
90
|
added: number;
|
|
91
91
|
}>;
|
|
92
92
|
react(id: string, emoji: string | null, signal?: AbortSignal): Promise<ReactResult>;
|
|
93
|
+
/**
|
|
94
|
+
* Show an FMSG-006 indicator, a short-lived emoji such as ⏳, on a message to its other participants for `ttl`
|
|
95
|
+
* seconds (1 to 120; the host clamps it). An empty emoji or a ttl of 0 clears it. The host answers 202 with no body
|
|
96
|
+
* and may drop indicators set more than once a second on one message. Hosts without indicators answer 404 or 405.
|
|
97
|
+
*/
|
|
98
|
+
indicate(id: string, emoji: string, ttl: number, signal?: AbortSignal): Promise<void>;
|
|
93
99
|
downloadAttachment(id: string, filename: string, signal?: AbortSignal, maxBytes?: number): Promise<{
|
|
94
100
|
data: Uint8Array;
|
|
95
101
|
contentType?: string;
|
package/dist/client/client.js
CHANGED
|
@@ -307,6 +307,21 @@ export class FmsgClient {
|
|
|
307
307
|
time: result.time ?? null,
|
|
308
308
|
};
|
|
309
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* Show an FMSG-006 indicator, a short-lived emoji such as ⏳, on a message to its other participants for `ttl`
|
|
312
|
+
* seconds (1 to 120; the host clamps it). An empty emoji or a ttl of 0 clears it. The host answers 202 with no body
|
|
313
|
+
* and may drop indicators set more than once a second on one message. Hosts without indicators answer 404 or 405.
|
|
314
|
+
*/
|
|
315
|
+
async indicate(id, emoji, ttl, signal) {
|
|
316
|
+
const mid = normalizeMessageId(id);
|
|
317
|
+
const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/indicate`, {
|
|
318
|
+
method: "POST",
|
|
319
|
+
headers: { "content-type": "application/json" },
|
|
320
|
+
body: JSON.stringify({ emoji, ttl: emoji ? ttl : 0 }),
|
|
321
|
+
...(signal ? { signal } : {}),
|
|
322
|
+
});
|
|
323
|
+
await response.body?.cancel();
|
|
324
|
+
}
|
|
310
325
|
async downloadAttachment(id, filename, signal, maxBytes) {
|
|
311
326
|
const { stream, contentType } = await this.streamAttachment(id, filename, signal);
|
|
312
327
|
const { data } = await readBytes(stream, maxBytes);
|
|
@@ -340,7 +355,9 @@ export class FmsgClient {
|
|
|
340
355
|
...(input.important ? { important: true } : {}),
|
|
341
356
|
...(input.noReply ? { no_reply: true } : {}),
|
|
342
357
|
};
|
|
343
|
-
|
|
358
|
+
// The Web API takes the parent as a numeric id or a hash string; an added recipient must use their batch's hash.
|
|
359
|
+
const serialized = input.parentSha256 ? JSON.stringify({ ...body, pid: input.parentSha256 })
|
|
360
|
+
: input.pid ? stringifyWithIds(body, { pid: input.pid }) : JSON.stringify(body);
|
|
344
361
|
const response = await this.request("/fmsg", {
|
|
345
362
|
method: "POST",
|
|
346
363
|
headers: { "content-type": "application/json" },
|
|
@@ -361,6 +378,9 @@ export class FmsgClient {
|
|
|
361
378
|
throw new Error("at least one recipient is required");
|
|
362
379
|
if (input.pid && input.topic)
|
|
363
380
|
throw new Error("a reply (pid) cannot carry a topic");
|
|
381
|
+
if (input.parentSha256 !== undefined && (!input.pid || !/^[0-9a-f]{64}$/u.test(input.parentSha256))) {
|
|
382
|
+
throw new Error("parentSha256 must be 64 lowercase hexadecimal characters, given with pid");
|
|
383
|
+
}
|
|
364
384
|
const from = await this.address(input.signal);
|
|
365
385
|
const body = redactSecrets(input.body);
|
|
366
386
|
const topic = redactSecrets(input.pid ? "" : (input.topic ?? ""));
|
package/dist/client/types.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export type RecipientDelivery = {
|
|
|
10
10
|
response_code: number | null;
|
|
11
11
|
};
|
|
12
12
|
export type AddToBatch = {
|
|
13
|
+
/** The batch's protocol hash (lowercase hex). A recipient added only by this batch replies with it as the parent. */
|
|
14
|
+
sha256?: string | null;
|
|
13
15
|
batch_id?: string;
|
|
14
16
|
add_to_from?: string;
|
|
15
17
|
to?: string[];
|
|
@@ -27,6 +29,10 @@ export type ReactionGroup = {
|
|
|
27
29
|
export type FmsgMessage = {
|
|
28
30
|
/** Present on list items and WebSocket events; absent on GET /fmsg/:id (filled in by the client). */
|
|
29
31
|
id: string;
|
|
32
|
+
/** Protocol hash (lowercase hex); null for drafts and on hosts that predate message hashes. */
|
|
33
|
+
sha256?: string | null;
|
|
34
|
+
/** The exact parent hash: the parent message's, or the add-to batch's a reply was made through. */
|
|
35
|
+
psha256?: string | null;
|
|
30
36
|
version?: number;
|
|
31
37
|
has_pid?: boolean;
|
|
32
38
|
has_add_to?: boolean;
|
|
@@ -121,6 +127,11 @@ export type SendInput = {
|
|
|
121
127
|
type?: string;
|
|
122
128
|
topic?: string;
|
|
123
129
|
pid?: string;
|
|
130
|
+
/**
|
|
131
|
+
* Reply through this hash instead of `pid`: the add-to batch that added the sender to the parent. A recipient
|
|
132
|
+
* added after a message was sent may reply to it only this way. `pid` must still name the parent.
|
|
133
|
+
*/
|
|
134
|
+
parentSha256?: string;
|
|
124
135
|
important?: boolean;
|
|
125
136
|
noReply?: boolean;
|
|
126
137
|
attachments?: OutboundAttachment[];
|
package/dist/config.d.ts
CHANGED
|
@@ -11,6 +11,11 @@ export type HttpConfig = {
|
|
|
11
11
|
allowedOrigins: string[];
|
|
12
12
|
keyCacheMax: number;
|
|
13
13
|
keyCacheTtlMs: number;
|
|
14
|
+
/**
|
|
15
|
+
* FMSG_MCP_STATE_SECRET: the HMAC key for presigned links, file-upload targets and requestState, so any instance
|
|
16
|
+
* can verify what another issued. Without it each process makes its own.
|
|
17
|
+
*/
|
|
18
|
+
stateSecret?: string;
|
|
14
19
|
};
|
|
15
20
|
export type Config = {
|
|
16
21
|
transport: Transport;
|
package/dist/config.js
CHANGED
|
@@ -55,6 +55,15 @@ function resolveApiPublicUrl(env, transport, apiUrl, allowInsecureHttp) {
|
|
|
55
55
|
return undefined;
|
|
56
56
|
return apiUrl;
|
|
57
57
|
}
|
|
58
|
+
/** FMSG_MCP_STATE_SECRET, checked; HTTP only. */
|
|
59
|
+
function stateSecret(env, transport) {
|
|
60
|
+
const secret = env.FMSG_MCP_STATE_SECRET?.trim();
|
|
61
|
+
if (transport !== "http" || !secret)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (Buffer.byteLength(secret) < 32)
|
|
64
|
+
throw new Error("FMSG_MCP_STATE_SECRET must be at least 32 bytes");
|
|
65
|
+
return secret;
|
|
66
|
+
}
|
|
58
67
|
export function loadConfig(env, transport, overrides = {}, options = {}) {
|
|
59
68
|
const requireCredentials = options.requireCredentials ?? true;
|
|
60
69
|
const oauth = loadOAuthConfig(env, transport);
|
|
@@ -106,6 +115,7 @@ export function loadConfig(env, transport, overrides = {}, options = {}) {
|
|
|
106
115
|
allowedOrigins: listEnv(env, "FMSG_MCP_ALLOWED_ORIGINS").map(normalizeOrigin),
|
|
107
116
|
keyCacheMax: intEnv(env, "FMSG_MCP_KEY_CACHE_MAX", 500),
|
|
108
117
|
keyCacheTtlMs: intEnv(env, "FMSG_MCP_KEY_CACHE_TTL_SECONDS", 1800) * 1000,
|
|
118
|
+
...(stateSecret(env, transport) ? { stateSecret: stateSecret(env, transport) } : {}),
|
|
109
119
|
},
|
|
110
120
|
};
|
|
111
121
|
}
|
package/dist/http.js
CHANGED
|
@@ -7,15 +7,18 @@ import { OAuthCallerProvider } from "./oauth/provider.js";
|
|
|
7
7
|
import { invalidToken, insufficientScope, missingToken, notBearer, OAuthRequestError, oauthErrorResponse, oauthRequestState } from "./oauth/errors.js";
|
|
8
8
|
import { MESSAGING_SCOPES, READ_SCOPE, WRITE_SCOPE, requestScopes } from "./oauth/scopes.js";
|
|
9
9
|
import { DOWNLOAD_PATH, downloadBaseUrl, downloadHeaders, parseDownloadPath } from "./download.js";
|
|
10
|
-
import { CHECKSUM_HEADER, UPLOAD_DRAIN_IDLE_MS, UPLOAD_PATH, UploadBodyError, decodedUploadBody, drainableBody, parseUploadPath, uploadEncoding } from "./upload.js";
|
|
10
|
+
import { CHECKSUM_HEADER, UPLOAD_DRAIN_IDLE_MS, UPLOAD_PATH, UploadBodyError, decodedUploadBody, drainableBody, parseUploadPath, streamIntoDraft, uploadEncoding } from "./upload.js";
|
|
11
11
|
import { AttachmentChecksumError, UploadInterruptedError } from "./client/client.js";
|
|
12
12
|
import { FmsgHttpError } from "./client/errors.js";
|
|
13
13
|
import { sameAddress } from "./address.js";
|
|
14
14
|
import { attachmentType } from "./render.js";
|
|
15
15
|
import { describeError } from "./errors.js";
|
|
16
|
-
import { VERSION } from "./version.js";
|
|
16
|
+
import { BUILD, VERSION } from "./version.js";
|
|
17
17
|
import { safeErrorMessage } from "./client/redact.js";
|
|
18
18
|
import { isLoopbackHost, normalizeOrigin } from "./client/url.js";
|
|
19
|
+
import { LINK_PATH, PresignedLinks } from "./links.js";
|
|
20
|
+
import { IndicatorRegistry } from "./indicators.js";
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
19
22
|
export const MCP_PATH = "/mcp";
|
|
20
23
|
/** How long close() lets in-flight requests finish (waits end at once) before cutting them off. */
|
|
21
24
|
export const SHUTDOWN_GRACE_MS = 5000;
|
|
@@ -117,6 +120,99 @@ async function draftRefusal(caller, id, signal) {
|
|
|
117
120
|
return `message ${id} is not your draft; only its sender can add attachments`;
|
|
118
121
|
return undefined;
|
|
119
122
|
}
|
|
123
|
+
/** A presigned download's attachment is no longer the bytes its link was issued for. */
|
|
124
|
+
class AttachmentChangedError extends Error {
|
|
125
|
+
constructor() { super("the attachment changed since this link was issued; ask for a new link"); this.name = "AttachmentChangedError"; }
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The body as a stream that fails unless it is exactly `size` bytes with SHA-256 `sha256`. The last chunk is held
|
|
129
|
+
* back until the whole body is verified, so a client never receives all `size` bytes of a file that does not match.
|
|
130
|
+
*/
|
|
131
|
+
function verified(body, size, sha256) {
|
|
132
|
+
const hash = createHash("sha256");
|
|
133
|
+
let read = 0;
|
|
134
|
+
let held;
|
|
135
|
+
const changed = () => new AttachmentChangedError();
|
|
136
|
+
return body.pipeThrough(new TransformStream({
|
|
137
|
+
transform(chunk, controller) {
|
|
138
|
+
read += chunk.byteLength;
|
|
139
|
+
if (read > size) {
|
|
140
|
+
controller.error(changed());
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
hash.update(chunk);
|
|
144
|
+
if (held)
|
|
145
|
+
controller.enqueue(held);
|
|
146
|
+
held = chunk;
|
|
147
|
+
},
|
|
148
|
+
flush(controller) {
|
|
149
|
+
if (read !== size || hash.digest("hex") !== sha256) {
|
|
150
|
+
controller.error(changed());
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (held)
|
|
154
|
+
controller.enqueue(held);
|
|
155
|
+
},
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Serve a presigned link: no Authorization header, the link itself authorizes one transfer. GET streams the
|
|
160
|
+
* attachment, cutting the transfer off if its bytes are not the size and SHA-256 the link was issued for; PUT
|
|
161
|
+
* takes one file of the declared size and SHA-256 into the draft.
|
|
162
|
+
*/
|
|
163
|
+
async function serveLink(links, url, request, res, signal) {
|
|
164
|
+
const refuse = (status, error, headers = {}) => sendWebResponse(res, Response.json({ error }, { status, headers }));
|
|
165
|
+
const method = request.method;
|
|
166
|
+
if (method !== "GET" && method !== "PUT")
|
|
167
|
+
return refuse(405, "use GET or PUT, as the link says", { allow: "GET, PUT, OPTIONS" });
|
|
168
|
+
if (url.search)
|
|
169
|
+
return refuse(400, "presigned links must not have query parameters");
|
|
170
|
+
const token = url.pathname.slice(LINK_PATH.length + 1);
|
|
171
|
+
if (!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u.test(token))
|
|
172
|
+
return refuse(404, "unknown link");
|
|
173
|
+
const resolved = links.resolve(token, method);
|
|
174
|
+
if ("status" in resolved)
|
|
175
|
+
return refuse(resolved.status, resolved.error);
|
|
176
|
+
const { claims, caller } = resolved;
|
|
177
|
+
try {
|
|
178
|
+
if (claims.k === "get") {
|
|
179
|
+
const { stream, contentType } = await caller.client.streamAttachment(claims.m, claims.n, signal);
|
|
180
|
+
// Spend the link only once the attachment is there, so a concurrent request cannot use it twice.
|
|
181
|
+
if (!links.consume(claims.j)) {
|
|
182
|
+
await stream.cancel();
|
|
183
|
+
return refuse(409, "this link was already used; each link downloads once");
|
|
184
|
+
}
|
|
185
|
+
const headers = downloadHeaders(claims.n, contentType);
|
|
186
|
+
headers.set("content-length", String(claims.s));
|
|
187
|
+
headers.set(CHECKSUM_HEADER, claims.h);
|
|
188
|
+
return await sendWebResponse(res, new Response(verified(stream, claims.s, claims.h), { headers }));
|
|
189
|
+
}
|
|
190
|
+
let encoding;
|
|
191
|
+
try {
|
|
192
|
+
encoding = uploadEncoding(request.headers.get("content-encoding"));
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
return refuse(415, error.message);
|
|
196
|
+
}
|
|
197
|
+
const response = await streamIntoDraft(links, caller, {
|
|
198
|
+
draft: claims.m, filename: claims.n, size: claims.s, sha256: claims.h, mimeType: claims.t ?? attachmentType(claims.n),
|
|
199
|
+
}, request, encoding, signal, "link");
|
|
200
|
+
return await sendWebResponse(res, response);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error.undrained || res.headersSent) {
|
|
204
|
+
res.destroy();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (res.destroyed || signal.aborted)
|
|
208
|
+
return;
|
|
209
|
+
if (error instanceof AttachmentChangedError)
|
|
210
|
+
return refuse(409, error.message);
|
|
211
|
+
const status = error instanceof AttachmentChecksumError ? 422 : error instanceof UploadBodyError ? 400
|
|
212
|
+
: error instanceof FmsgHttpError && error.status >= 400 && error.status <= 599 && error.status !== 401 ? error.status : 502;
|
|
213
|
+
return refuse(status, describeError(error));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
120
216
|
export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
121
217
|
const allowedHosts = config.http.allowedHosts.length
|
|
122
218
|
? config.http.allowedHosts
|
|
@@ -138,11 +234,22 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
138
234
|
const metadataPath = resource ? `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}` : undefined;
|
|
139
235
|
const metadataUrl = resource ? `${resource.origin}${metadataPath}` : "";
|
|
140
236
|
const shutdown = new AbortController();
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
237
|
+
// Presigned links need the public URL to build them; they carry their own authorization.
|
|
238
|
+
const links = downloadBaseUrl(config) ? new PresignedLinks(downloadBaseUrl(config), config.http.stateSecret) : undefined;
|
|
239
|
+
// Each MCP request gets its own server instance, so indicators kept up between requests live here, one registry
|
|
240
|
+
// per provider so that combined mode never shares a caller's indicators across authentication paths.
|
|
241
|
+
const registries = [];
|
|
242
|
+
const mcpHandler = (provider, address) => {
|
|
243
|
+
const indicators = new IndicatorRegistry();
|
|
244
|
+
registries.push(indicators);
|
|
245
|
+
return createMcpHandler(({ authInfo }) => createFmsgMcpServer(provider, config, {
|
|
246
|
+
...(authInfo ? { address: address(authInfo) } : {}),
|
|
247
|
+
...(typeof authInfo?.extra?.owner === "string" ? { owner: authInfo.extra.owner } : {}),
|
|
248
|
+
shutdown: shutdown.signal,
|
|
249
|
+
...(links ? { links } : {}),
|
|
250
|
+
indicators,
|
|
251
|
+
}), { onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) });
|
|
252
|
+
};
|
|
146
253
|
const oauthHandler = oauthProvider && mcpHandler(oauthProvider, (authInfo) => String(authInfo.extra?.address ?? ""));
|
|
147
254
|
const apiKeyHandler = apiKeyProvider && mcpHandler(apiKeyProvider, (authInfo) => authInfo.clientId);
|
|
148
255
|
const active = new Map();
|
|
@@ -160,18 +267,20 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
160
267
|
res.once("close", abort);
|
|
161
268
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
162
269
|
const isUpload = url.pathname.startsWith(`${UPLOAD_PATH}/`);
|
|
270
|
+
const isLink = Boolean(links) && url.pathname.startsWith(`${LINK_PATH}/`);
|
|
271
|
+
const streamsBody = isUpload || (isLink && req.method === "PUT");
|
|
163
272
|
// A progressing upload may take longer than any fixed budget; the upload stream has an idle timeout instead.
|
|
164
273
|
// Closing the connection afterwards stops Node reading the rest of an upload refused before authentication;
|
|
165
274
|
// an authenticated caller's failed upload is drained first so its answer arrives (see serveUpload).
|
|
166
|
-
const bodyTimer =
|
|
275
|
+
const bodyTimer = streamsBody ? undefined : setTimeout(() => { if (!req.complete)
|
|
167
276
|
req.destroy(); }, REQUEST_BODY_TIMEOUT_MS).unref();
|
|
168
|
-
if (
|
|
277
|
+
if (streamsBody)
|
|
169
278
|
res.setHeader("connection", "close");
|
|
170
279
|
const done = (async () => {
|
|
171
280
|
const isDownload = url.pathname.startsWith(`${DOWNLOAD_PATH}/`);
|
|
172
281
|
if (url.pathname === "/healthz") {
|
|
173
282
|
res.writeHead(200, { "content-type": "application/json" });
|
|
174
|
-
res.end(JSON.stringify({ ok: true, name: "fmsg-mcp", version: VERSION }));
|
|
283
|
+
res.end(JSON.stringify({ ok: true, name: "fmsg-mcp", version: VERSION, ...(BUILD.commit ? { commit: BUILD.commit } : {}) }));
|
|
175
284
|
return;
|
|
176
285
|
}
|
|
177
286
|
if (resource && (url.pathname === metadataPath || url.pathname === "/.well-known/oauth-protected-resource")) {
|
|
@@ -187,7 +296,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
187
296
|
authorization_servers: [config.oauth.issuerUrl], scopes_supported: MESSAGING_SCOPES,
|
|
188
297
|
bearer_methods_supported: ["header"], resource_name: "fmsg messaging", }, { headers }));
|
|
189
298
|
}
|
|
190
|
-
if (url.pathname !== MCP_PATH && !isDownload && !isUpload) {
|
|
299
|
+
if (url.pathname !== MCP_PATH && !isDownload && !isUpload && !isLink) {
|
|
191
300
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
192
301
|
res.end("not found; the MCP endpoint is /mcp");
|
|
193
302
|
return;
|
|
@@ -219,7 +328,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
219
328
|
if (req.method === "OPTIONS") {
|
|
220
329
|
const method = request.headers.get("access-control-request-method") ?? "";
|
|
221
330
|
const headers = (request.headers.get("access-control-request-headers") ?? "").split(",").map((v) => v.trim().toLowerCase()).filter(Boolean);
|
|
222
|
-
const methods = isDownload ? ["GET"] : isUpload ? ["PUT"] : CORS_METHODS;
|
|
331
|
+
const methods = isDownload ? ["GET"] : isUpload ? ["PUT"] : isLink ? ["GET", "PUT"] : CORS_METHODS;
|
|
223
332
|
if (!origin || !methods.includes(method) || headers.some((h) => !CORS_HEADERS.includes(h))) {
|
|
224
333
|
return sendWebResponse(res, new Response("preflight not allowed", { status: 403 }));
|
|
225
334
|
}
|
|
@@ -241,6 +350,8 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
241
350
|
return sendWebResponse(res, new Response("invalid attachment download path", { status: 400 }));
|
|
242
351
|
}
|
|
243
352
|
}
|
|
353
|
+
if (isLink)
|
|
354
|
+
return serveLink(links, url, request, res, controller.signal);
|
|
244
355
|
let upload;
|
|
245
356
|
if (isUpload) {
|
|
246
357
|
// Refusals are JSON, like the errors from the upload itself.
|
|
@@ -439,6 +550,8 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
439
550
|
const close = ({ graceMs = SHUTDOWN_GRACE_MS } = {}) => closing ??= (async () => {
|
|
440
551
|
// Waits answer "interrupted" with a resumable cursor; new requests get 503.
|
|
441
552
|
shutdown.abort();
|
|
553
|
+
for (const indicators of registries)
|
|
554
|
+
indicators.close();
|
|
442
555
|
const closed = new Promise((resolve) => server.close(() => resolve()));
|
|
443
556
|
server.closeIdleConnections();
|
|
444
557
|
let timer;
|
|
@@ -454,6 +567,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
454
567
|
}
|
|
455
568
|
oauthProvider?.close();
|
|
456
569
|
apiKeyProvider?.close();
|
|
570
|
+
links?.close();
|
|
457
571
|
await Promise.all([oauthHandler?.close(), apiKeyHandler?.close()]);
|
|
458
572
|
// Aborted fetches may open replacement sockets without a request, so
|
|
459
573
|
// they are absent from `active` and must also be closed on shutdown.
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { loadConfig, DEFAULT_HTTP_PORT } from "./config.js";
|
|
|
8
8
|
import { StaticCallerProvider, UnconfiguredCallerProvider } from "./context.js";
|
|
9
9
|
import { createHttpServer, MCP_PATH, SHUTDOWN_GRACE_MS } from "./http.js";
|
|
10
10
|
import { createFmsgMcpServer } from "./server.js";
|
|
11
|
-
import { PACKAGE_NAME, VERSION } from "./version.js";
|
|
11
|
+
import { PACKAGE_NAME, VERSION, VERSION_LABEL } from "./version.js";
|
|
12
12
|
const USAGE = `${PACKAGE_NAME} ${VERSION} — MCP server for fmsg
|
|
13
13
|
|
|
14
14
|
Usage:
|
|
@@ -115,7 +115,7 @@ async function main() {
|
|
|
115
115
|
}
|
|
116
116
|
else if (cfg.apiUrl && cfg.apiKey) {
|
|
117
117
|
provider = new StaticCallerProvider(new FmsgClient(cfg.apiUrl, cfg.apiKey, { allowInsecureHttp: cfg.allowInsecureHttp }));
|
|
118
|
-
console.error(safeErrorMessage(`fmsg-mcp ${
|
|
118
|
+
console.error(safeErrorMessage(`fmsg-mcp ${VERSION_LABEL} serving stdio for ${cfg.apiUrl}`));
|
|
119
119
|
}
|
|
120
120
|
else {
|
|
121
121
|
const missing = [!cfg.apiUrl && "FMSG_API_URL", !cfg.apiKey && "FMSG_API_KEY"].filter(Boolean).join(" and ");
|
|
@@ -153,7 +153,7 @@ async function main() {
|
|
|
153
153
|
});
|
|
154
154
|
const addr = server.address();
|
|
155
155
|
const shown = typeof addr === "object" && addr ? `${addr.address}:${addr.port}` : `${config.http.host}:${config.http.port}`;
|
|
156
|
-
console.error(safeErrorMessage(`fmsg-mcp ${
|
|
156
|
+
console.error(safeErrorMessage(`fmsg-mcp ${VERSION_LABEL} serving Streamable HTTP at http://${shown}${MCP_PATH} for ${config.apiUrl}`));
|
|
157
157
|
onShutdown(() => close());
|
|
158
158
|
}
|
|
159
159
|
/** Stop on SIGINT/SIGTERM within a bounded time; a second signal exits at once. */
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Caller } from "./context.js";
|
|
2
|
+
/** The emoji indicate shows by default: working on a reply. */
|
|
3
|
+
export declare const WORKING_EMOJI = "\u23F3";
|
|
4
|
+
export type IndicatorOptions = {
|
|
5
|
+
/** How often an indicator is re-sent (default 30 s); never more often than half its ttl allows, nor below 1 s. */
|
|
6
|
+
refreshMs?: number;
|
|
7
|
+
/** How long an indicator is kept up before it is cleared (default 5 minutes). */
|
|
8
|
+
maxMs?: number;
|
|
9
|
+
/** Most indicators kept up at once per caller (default 10); the oldest is cleared to make room. */
|
|
10
|
+
maxActive?: number;
|
|
11
|
+
/** Debug log for swallowed failures; nothing is logged by default. */
|
|
12
|
+
log?: (line: string) => void;
|
|
13
|
+
};
|
|
14
|
+
export declare class IndicatorRegistry {
|
|
15
|
+
private readonly callers;
|
|
16
|
+
private readonly settings;
|
|
17
|
+
private closed;
|
|
18
|
+
constructor(options?: IndicatorOptions);
|
|
19
|
+
/** The caller's indicators, if any, switched to its current client (a renewed OAuth token brings a new one). */
|
|
20
|
+
private existing;
|
|
21
|
+
private of;
|
|
22
|
+
/** After a reply was sent to `parentId`: stop what is shown on it and anywhere in its thread. */
|
|
23
|
+
replied(caller: Caller, parentId: string, rootId: string | null): void;
|
|
24
|
+
/** After a reaction or mark_read: the caller has handled these messages. */
|
|
25
|
+
handled(caller: Caller, ids: string[]): void;
|
|
26
|
+
/**
|
|
27
|
+
* The indicate tool: show `emoji` and keep it up, or clear with an empty one. Throws the host's refusal. `rootId` is
|
|
28
|
+
* the message's thread root, when known, so a reply anywhere in that thread stops it.
|
|
29
|
+
*/
|
|
30
|
+
indicate(caller: Caller, id: string, emoji: string, ttl: number, rootId?: string | null): Promise<void>;
|
|
31
|
+
/** Message ids with an indicator kept up for this caller. */
|
|
32
|
+
active(caller: Caller): string[];
|
|
33
|
+
/** Stop every indicator, clearing them in the background. Idempotent. */
|
|
34
|
+
close(): void;
|
|
35
|
+
}
|