@markmnl/fmsg-mcp 1.2.3 → 1.3.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 +19 -0
- package/README.md +20 -5
- package/dist/build-info.json +5 -0
- package/dist/client/client.js +6 -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 +110 -7
- package/dist/index.js +3 -3
- package/dist/instructions.js +2 -2
- package/dist/links.d.ts +63 -0
- package/dist/links.js +104 -0
- package/dist/oauth/scopes.js +1 -0
- package/dist/prompts.js +4 -2
- package/dist/server.d.ts +3 -0
- package/dist/server.js +3 -2
- package/dist/tools/attachments.d.ts +6 -2
- package/dist/tools/attachments.js +17 -6
- package/dist/tools/common.d.ts +3 -0
- package/dist/tools/download.js +86 -7
- package/dist/tools/draft.js +11 -7
- 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/send.d.ts +5 -2
- package/dist/tools/send.js +20 -10
- 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/package.json +3 -2
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Presigned links for hosts that cannot send the connection's Authorization header with their own HTTP
|
|
6
|
+
requests: `get_attachment_download_url` with `presigned: true`, and the new `get_attachment_upload_url` for
|
|
7
|
+
draft files. Each link is single-use, valid for 5 minutes and bound to one file's size and sha256 (`/mcp/links/{token}`),
|
|
8
|
+
and comes with its `expires_at`, `size`, `sha256` and a ready-to-run `curl` command. A download whose
|
|
9
|
+
attachment changed since the link was issued is refused or cut off before its last bytes. `FMSG_MCP_STATE_SECRET` (at least 32 bytes)
|
|
10
|
+
sets the signing key.
|
|
11
|
+
- `data_base64` is described as best effort, because the base64 passes through the model: fine for small
|
|
12
|
+
files (verified exact up to 16 KB), always with `sha256`, and `path` or a presigned link for anything larger.
|
|
13
|
+
- A recipient added to a message after it was sent can reply to it. The Web API takes such a reply only
|
|
14
|
+
through the hash of the add-to batch that added them; `reply`, `create_draft` (and so `send_draft`) now
|
|
15
|
+
send it as the parent, where before the host refused with "not a participant of this parent". A reply
|
|
16
|
+
from an added recipient to an earlier message in the thread, which they cannot reply to, is refused with
|
|
17
|
+
an error naming the message they were added to.
|
|
18
|
+
- `whoami` reports the server version in its structured result (`server_version`, plus `server_commit` and
|
|
19
|
+
`server_build` when recorded), not only in its text, which hosts that show models only the structured
|
|
20
|
+
result never passed on. `/healthz` adds the commit. `npm run build` records the version and commit from
|
|
21
|
+
git; the Docker image takes them as build arguments. Builds from source no longer report the stale
|
|
22
|
+
`package.json` version 0.1.4: it now matches the last release, and a release tag takes precedence.
|
|
23
|
+
|
|
5
24
|
- Over OAuth, the owner comes from the `owner` claim the IdP puts on the app's access token
|
|
6
25
|
(and on the exchanged Web API token), so agents that sign in (ChatGPT connectors) include
|
|
7
26
|
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 |
|
|
@@ -148,7 +152,8 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
|
|
|
148
152
|
| `react` | Set or clear your emoji reaction |
|
|
149
153
|
| `mark_read` | Mark received messages read |
|
|
150
154
|
| `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;
|
|
155
|
+
| `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 |
|
|
156
|
+
| `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
157
|
| `save_attachment` | Stream an attachment to the configured local folder; stdio only, enabled by `FMSG_MCP_DOWNLOAD_DIR` |
|
|
153
158
|
| `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
159
|
| `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 |
|
|
@@ -193,6 +198,7 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
|
|
|
193
198
|
| `FMSG_MCP_DOWNLOAD_DIR` | — | Enable `save_attachment` in stdio; folder for new files named from message ID and filename |
|
|
194
199
|
| `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
200
|
| `FMSG_MCP_WAIT_MAX_SECONDS` | `230` | Cap on one `wait_for_message` call |
|
|
201
|
+
| `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
202
|
| `FMSG_MCP_HOST` / `FMSG_MCP_PORT` | `127.0.0.1` / `8765` | HTTP bind address (or `--http host:port`) |
|
|
197
203
|
| `FMSG_MCP_ALLOWED_HOSTS` | loopback names | Comma-separated `Host` header allowlist; required for non-loopback binds |
|
|
198
204
|
| `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 +235,9 @@ original Content-Type and download filename. No files are stored on the MCP serv
|
|
|
229
235
|
|
|
230
236
|
`send_message`, `reply` and `create_draft` take each attachment as exactly one of:
|
|
231
237
|
|
|
232
|
-
- `data_base64`: base64 written by the model
|
|
238
|
+
- `data_base64`: base64 written by the model. It passes through the model, so it is best effort: fine
|
|
239
|
+
for small files (verified exact up to 16 KB). Always pass `sha256` with it, and prefer `path` or a
|
|
240
|
+
presigned upload link for anything larger. The standard or URL-safe
|
|
233
241
|
alphabet is accepted, but not both in one value. Decoding is strict: any other character, a bad
|
|
234
242
|
length or padding, or a stray ending is refused with the attachment's name, rather than sent as a
|
|
235
243
|
corrupted file.
|
|
@@ -244,6 +252,13 @@ with `x-content-sha256`), then `send_draft`; see
|
|
|
244
252
|
[attachment uploads](docs/http-deployment.md#attachment-uploads). Send files as they are: fmsg
|
|
245
253
|
compresses message bodies and attachments itself when it sends them.
|
|
246
254
|
|
|
255
|
+
Hosts that cannot attach the connection's credentials to their own HTTP requests can still keep file
|
|
256
|
+
bytes out of the model. `get_attachment_upload_url` returns a single-use, 5-minute link per draft
|
|
257
|
+
file, bound to its size and sha256, with a ready-to-run `curl` command. `get_attachment_download_url`
|
|
258
|
+
with `presigned: true` does the same for downloads. The link itself authorizes that one transfer. It
|
|
259
|
+
holds no API key or access token, but treat it as a secret until it is used or expires. It works only
|
|
260
|
+
on the server instance that issued it.
|
|
261
|
+
|
|
247
262
|
Models learn the routes from the server instructions and tool descriptions, which name only the
|
|
248
263
|
routes the running server offers. A model that writes a large file as base64 although a better
|
|
249
264
|
route exists gets a `hint` in the result naming it. The `send_file` prompt walks through the best
|
package/dist/client/client.js
CHANGED
|
@@ -340,7 +340,9 @@ export class FmsgClient {
|
|
|
340
340
|
...(input.important ? { important: true } : {}),
|
|
341
341
|
...(input.noReply ? { no_reply: true } : {}),
|
|
342
342
|
};
|
|
343
|
-
|
|
343
|
+
// The Web API takes the parent as a numeric id or a hash string; an added recipient must use their batch's hash.
|
|
344
|
+
const serialized = input.parentSha256 ? JSON.stringify({ ...body, pid: input.parentSha256 })
|
|
345
|
+
: input.pid ? stringifyWithIds(body, { pid: input.pid }) : JSON.stringify(body);
|
|
344
346
|
const response = await this.request("/fmsg", {
|
|
345
347
|
method: "POST",
|
|
346
348
|
headers: { "content-type": "application/json" },
|
|
@@ -361,6 +363,9 @@ export class FmsgClient {
|
|
|
361
363
|
throw new Error("at least one recipient is required");
|
|
362
364
|
if (input.pid && input.topic)
|
|
363
365
|
throw new Error("a reply (pid) cannot carry a topic");
|
|
366
|
+
if (input.parentSha256 !== undefined && (!input.pid || !/^[0-9a-f]{64}$/u.test(input.parentSha256))) {
|
|
367
|
+
throw new Error("parentSha256 must be 64 lowercase hexadecimal characters, given with pid");
|
|
368
|
+
}
|
|
364
369
|
const from = await this.address(input.signal);
|
|
365
370
|
const body = redactSecrets(input.body);
|
|
366
371
|
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,17 @@ 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 { createHash } from "node:crypto";
|
|
19
21
|
export const MCP_PATH = "/mcp";
|
|
20
22
|
/** How long close() lets in-flight requests finish (waits end at once) before cutting them off. */
|
|
21
23
|
export const SHUTDOWN_GRACE_MS = 5000;
|
|
@@ -117,6 +119,99 @@ async function draftRefusal(caller, id, signal) {
|
|
|
117
119
|
return `message ${id} is not your draft; only its sender can add attachments`;
|
|
118
120
|
return undefined;
|
|
119
121
|
}
|
|
122
|
+
/** A presigned download's attachment is no longer the bytes its link was issued for. */
|
|
123
|
+
class AttachmentChangedError extends Error {
|
|
124
|
+
constructor() { super("the attachment changed since this link was issued; ask for a new link"); this.name = "AttachmentChangedError"; }
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The body as a stream that fails unless it is exactly `size` bytes with SHA-256 `sha256`. The last chunk is held
|
|
128
|
+
* back until the whole body is verified, so a client never receives all `size` bytes of a file that does not match.
|
|
129
|
+
*/
|
|
130
|
+
function verified(body, size, sha256) {
|
|
131
|
+
const hash = createHash("sha256");
|
|
132
|
+
let read = 0;
|
|
133
|
+
let held;
|
|
134
|
+
const changed = () => new AttachmentChangedError();
|
|
135
|
+
return body.pipeThrough(new TransformStream({
|
|
136
|
+
transform(chunk, controller) {
|
|
137
|
+
read += chunk.byteLength;
|
|
138
|
+
if (read > size) {
|
|
139
|
+
controller.error(changed());
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
hash.update(chunk);
|
|
143
|
+
if (held)
|
|
144
|
+
controller.enqueue(held);
|
|
145
|
+
held = chunk;
|
|
146
|
+
},
|
|
147
|
+
flush(controller) {
|
|
148
|
+
if (read !== size || hash.digest("hex") !== sha256) {
|
|
149
|
+
controller.error(changed());
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (held)
|
|
153
|
+
controller.enqueue(held);
|
|
154
|
+
},
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Serve a presigned link: no Authorization header, the link itself authorizes one transfer. GET streams the
|
|
159
|
+
* attachment, cutting the transfer off if its bytes are not the size and SHA-256 the link was issued for; PUT
|
|
160
|
+
* takes one file of the declared size and SHA-256 into the draft.
|
|
161
|
+
*/
|
|
162
|
+
async function serveLink(links, url, request, res, signal) {
|
|
163
|
+
const refuse = (status, error, headers = {}) => sendWebResponse(res, Response.json({ error }, { status, headers }));
|
|
164
|
+
const method = request.method;
|
|
165
|
+
if (method !== "GET" && method !== "PUT")
|
|
166
|
+
return refuse(405, "use GET or PUT, as the link says", { allow: "GET, PUT, OPTIONS" });
|
|
167
|
+
if (url.search)
|
|
168
|
+
return refuse(400, "presigned links must not have query parameters");
|
|
169
|
+
const token = url.pathname.slice(LINK_PATH.length + 1);
|
|
170
|
+
if (!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u.test(token))
|
|
171
|
+
return refuse(404, "unknown link");
|
|
172
|
+
const resolved = links.resolve(token, method);
|
|
173
|
+
if ("status" in resolved)
|
|
174
|
+
return refuse(resolved.status, resolved.error);
|
|
175
|
+
const { claims, caller } = resolved;
|
|
176
|
+
try {
|
|
177
|
+
if (claims.k === "get") {
|
|
178
|
+
const { stream, contentType } = await caller.client.streamAttachment(claims.m, claims.n, signal);
|
|
179
|
+
// Spend the link only once the attachment is there, so a concurrent request cannot use it twice.
|
|
180
|
+
if (!links.consume(claims.j)) {
|
|
181
|
+
await stream.cancel();
|
|
182
|
+
return refuse(409, "this link was already used; each link downloads once");
|
|
183
|
+
}
|
|
184
|
+
const headers = downloadHeaders(claims.n, contentType);
|
|
185
|
+
headers.set("content-length", String(claims.s));
|
|
186
|
+
headers.set(CHECKSUM_HEADER, claims.h);
|
|
187
|
+
return await sendWebResponse(res, new Response(verified(stream, claims.s, claims.h), { headers }));
|
|
188
|
+
}
|
|
189
|
+
let encoding;
|
|
190
|
+
try {
|
|
191
|
+
encoding = uploadEncoding(request.headers.get("content-encoding"));
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
return refuse(415, error.message);
|
|
195
|
+
}
|
|
196
|
+
const response = await streamIntoDraft(links, caller, {
|
|
197
|
+
draft: claims.m, filename: claims.n, size: claims.s, sha256: claims.h, mimeType: claims.t ?? attachmentType(claims.n),
|
|
198
|
+
}, request, encoding, signal, "link");
|
|
199
|
+
return await sendWebResponse(res, response);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (error.undrained || res.headersSent) {
|
|
203
|
+
res.destroy();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (res.destroyed || signal.aborted)
|
|
207
|
+
return;
|
|
208
|
+
if (error instanceof AttachmentChangedError)
|
|
209
|
+
return refuse(409, error.message);
|
|
210
|
+
const status = error instanceof AttachmentChecksumError ? 422 : error instanceof UploadBodyError ? 400
|
|
211
|
+
: error instanceof FmsgHttpError && error.status >= 400 && error.status <= 599 && error.status !== 401 ? error.status : 502;
|
|
212
|
+
return refuse(status, describeError(error));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
120
215
|
export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
121
216
|
const allowedHosts = config.http.allowedHosts.length
|
|
122
217
|
? config.http.allowedHosts
|
|
@@ -138,10 +233,13 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
138
233
|
const metadataPath = resource ? `/.well-known/oauth-protected-resource${resource.pathname === "/" ? "" : resource.pathname}` : undefined;
|
|
139
234
|
const metadataUrl = resource ? `${resource.origin}${metadataPath}` : "";
|
|
140
235
|
const shutdown = new AbortController();
|
|
236
|
+
// Presigned links need the public URL to build them; they carry their own authorization.
|
|
237
|
+
const links = downloadBaseUrl(config) ? new PresignedLinks(downloadBaseUrl(config), config.http.stateSecret) : undefined;
|
|
141
238
|
const mcpHandler = (provider, address) => createMcpHandler(({ authInfo }) => createFmsgMcpServer(provider, config, {
|
|
142
239
|
...(authInfo ? { address: address(authInfo) } : {}),
|
|
143
240
|
...(typeof authInfo?.extra?.owner === "string" ? { owner: authInfo.extra.owner } : {}),
|
|
144
241
|
shutdown: shutdown.signal,
|
|
242
|
+
...(links ? { links } : {}),
|
|
145
243
|
}), { onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) });
|
|
146
244
|
const oauthHandler = oauthProvider && mcpHandler(oauthProvider, (authInfo) => String(authInfo.extra?.address ?? ""));
|
|
147
245
|
const apiKeyHandler = apiKeyProvider && mcpHandler(apiKeyProvider, (authInfo) => authInfo.clientId);
|
|
@@ -160,18 +258,20 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
160
258
|
res.once("close", abort);
|
|
161
259
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
162
260
|
const isUpload = url.pathname.startsWith(`${UPLOAD_PATH}/`);
|
|
261
|
+
const isLink = Boolean(links) && url.pathname.startsWith(`${LINK_PATH}/`);
|
|
262
|
+
const streamsBody = isUpload || (isLink && req.method === "PUT");
|
|
163
263
|
// A progressing upload may take longer than any fixed budget; the upload stream has an idle timeout instead.
|
|
164
264
|
// Closing the connection afterwards stops Node reading the rest of an upload refused before authentication;
|
|
165
265
|
// an authenticated caller's failed upload is drained first so its answer arrives (see serveUpload).
|
|
166
|
-
const bodyTimer =
|
|
266
|
+
const bodyTimer = streamsBody ? undefined : setTimeout(() => { if (!req.complete)
|
|
167
267
|
req.destroy(); }, REQUEST_BODY_TIMEOUT_MS).unref();
|
|
168
|
-
if (
|
|
268
|
+
if (streamsBody)
|
|
169
269
|
res.setHeader("connection", "close");
|
|
170
270
|
const done = (async () => {
|
|
171
271
|
const isDownload = url.pathname.startsWith(`${DOWNLOAD_PATH}/`);
|
|
172
272
|
if (url.pathname === "/healthz") {
|
|
173
273
|
res.writeHead(200, { "content-type": "application/json" });
|
|
174
|
-
res.end(JSON.stringify({ ok: true, name: "fmsg-mcp", version: VERSION }));
|
|
274
|
+
res.end(JSON.stringify({ ok: true, name: "fmsg-mcp", version: VERSION, ...(BUILD.commit ? { commit: BUILD.commit } : {}) }));
|
|
175
275
|
return;
|
|
176
276
|
}
|
|
177
277
|
if (resource && (url.pathname === metadataPath || url.pathname === "/.well-known/oauth-protected-resource")) {
|
|
@@ -187,7 +287,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
187
287
|
authorization_servers: [config.oauth.issuerUrl], scopes_supported: MESSAGING_SCOPES,
|
|
188
288
|
bearer_methods_supported: ["header"], resource_name: "fmsg messaging", }, { headers }));
|
|
189
289
|
}
|
|
190
|
-
if (url.pathname !== MCP_PATH && !isDownload && !isUpload) {
|
|
290
|
+
if (url.pathname !== MCP_PATH && !isDownload && !isUpload && !isLink) {
|
|
191
291
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
192
292
|
res.end("not found; the MCP endpoint is /mcp");
|
|
193
293
|
return;
|
|
@@ -219,7 +319,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
219
319
|
if (req.method === "OPTIONS") {
|
|
220
320
|
const method = request.headers.get("access-control-request-method") ?? "";
|
|
221
321
|
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;
|
|
322
|
+
const methods = isDownload ? ["GET"] : isUpload ? ["PUT"] : isLink ? ["GET", "PUT"] : CORS_METHODS;
|
|
223
323
|
if (!origin || !methods.includes(method) || headers.some((h) => !CORS_HEADERS.includes(h))) {
|
|
224
324
|
return sendWebResponse(res, new Response("preflight not allowed", { status: 403 }));
|
|
225
325
|
}
|
|
@@ -241,6 +341,8 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
241
341
|
return sendWebResponse(res, new Response("invalid attachment download path", { status: 400 }));
|
|
242
342
|
}
|
|
243
343
|
}
|
|
344
|
+
if (isLink)
|
|
345
|
+
return serveLink(links, url, request, res, controller.signal);
|
|
244
346
|
let upload;
|
|
245
347
|
if (isUpload) {
|
|
246
348
|
// Refusals are JSON, like the errors from the upload itself.
|
|
@@ -454,6 +556,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
|
|
|
454
556
|
}
|
|
455
557
|
oauthProvider?.close();
|
|
456
558
|
apiKeyProvider?.close();
|
|
559
|
+
links?.close();
|
|
457
560
|
await Promise.all([oauthHandler?.close(), apiKeyHandler?.close()]);
|
|
458
561
|
// Aborted fetches may open replacement sockets without a request, so
|
|
459
562
|
// 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. */
|
package/dist/instructions.js
CHANGED
|
@@ -11,10 +11,10 @@ import { ownerSentence } from "./owner.js";
|
|
|
11
11
|
/** How to send files, naming only the routes this server offers. */
|
|
12
12
|
function filesParagraph(files) {
|
|
13
13
|
const large = [
|
|
14
|
-
...(files?.uploadUrl ? [
|
|
14
|
+
...(files?.uploadUrl ? [`create_draft, PUT each file to its upload URL${files.presigned ? " (or a get_attachment_upload_url link)" : ""}, then send_draft`] : []),
|
|
15
15
|
...(files?.uploadFolder ? ["give path, a file in the upload folder"] : []),
|
|
16
16
|
];
|
|
17
|
-
return "Files: data_text for text, data_base64 for small files." + (large.length ? ` Large files: ${large.join("; or ")}.` : "");
|
|
17
|
+
return "Files: data_text for text, data_base64 for small files (best effort; always add sha256)." + (large.length ? ` Large files: ${large.join("; or ")}.` : "");
|
|
18
18
|
}
|
|
19
19
|
export function buildInstructions(ctx = {}) {
|
|
20
20
|
const identity = ctx.address
|
package/dist/links.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Caller } from "./context.js";
|
|
2
|
+
import type { UploadTracker } from "./upload.js";
|
|
3
|
+
export declare const LINK_PATH = "/mcp/links";
|
|
4
|
+
/** How long a presigned link stays usable. */
|
|
5
|
+
export declare const LINK_TTL_SECONDS = 300;
|
|
6
|
+
/** What a link allows, signed into it. */
|
|
7
|
+
export type LinkClaims = {
|
|
8
|
+
v: 1;
|
|
9
|
+
/** GET downloads an attachment; PUT uploads one file into a draft. */
|
|
10
|
+
k: "get" | "put";
|
|
11
|
+
/** Link id, for single use. */
|
|
12
|
+
j: string;
|
|
13
|
+
/** The fmsg address it acts for. */
|
|
14
|
+
a: string;
|
|
15
|
+
/** Message id (GET) or draft id (PUT). */
|
|
16
|
+
m: string;
|
|
17
|
+
/** Attachment name. */
|
|
18
|
+
n: string;
|
|
19
|
+
/** Size in bytes. */
|
|
20
|
+
s: number;
|
|
21
|
+
/** SHA-256, lowercase hex. */
|
|
22
|
+
h: string;
|
|
23
|
+
/** Media type (PUT). */
|
|
24
|
+
t?: string;
|
|
25
|
+
/** Expiry, unix seconds. */
|
|
26
|
+
e: number;
|
|
27
|
+
};
|
|
28
|
+
export declare class PresignedLinks implements UploadTracker {
|
|
29
|
+
private readonly publicUrl;
|
|
30
|
+
private readonly ttlSeconds;
|
|
31
|
+
private readonly key;
|
|
32
|
+
private readonly issued;
|
|
33
|
+
private readonly uploading;
|
|
34
|
+
private readonly verified;
|
|
35
|
+
private readonly sweeper;
|
|
36
|
+
constructor(publicUrl: string, secret: string | undefined, ttlSeconds?: number);
|
|
37
|
+
private sign;
|
|
38
|
+
/** Issue a link for `caller`. */
|
|
39
|
+
mint(caller: Caller, claims: Omit<LinkClaims, "v" | "j" | "a" | "e">): {
|
|
40
|
+
url: string;
|
|
41
|
+
expiresAt: number;
|
|
42
|
+
};
|
|
43
|
+
/** The link in a request path, with the caller it acts for, or the status and reason to refuse it with. */
|
|
44
|
+
resolve(token: string, method: string, now?: number): {
|
|
45
|
+
claims: LinkClaims;
|
|
46
|
+
caller: Caller;
|
|
47
|
+
} | {
|
|
48
|
+
status: number;
|
|
49
|
+
error: string;
|
|
50
|
+
};
|
|
51
|
+
/** Spend a download link. False when it was already spent. */
|
|
52
|
+
consume(id: string): boolean;
|
|
53
|
+
begin(key: string): boolean;
|
|
54
|
+
end(key: string): void;
|
|
55
|
+
recordVerified(key: string, sha256: string): void;
|
|
56
|
+
verifiedSha256(key: string): string | undefined;
|
|
57
|
+
private sweep;
|
|
58
|
+
close(): void;
|
|
59
|
+
}
|
|
60
|
+
/** A string as one POSIX shell word. */
|
|
61
|
+
export declare function shellQuote(value: string): string;
|
|
62
|
+
/** A local file name for curl -o: the attachment's name reduced to portable characters. */
|
|
63
|
+
export declare function localFileName(filename: string): string;
|
package/dist/links.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presigned links: short-lived, single-use URLs that carry their own authorization, for hosts that cannot send this
|
|
3
|
+
* MCP connection's Authorization header with their HTTP requests. A link is a signed capability for exactly one
|
|
4
|
+
* transfer: GET one attachment, or PUT one file of a declared size and SHA-256 into one draft. It never contains
|
|
5
|
+
* an API key or access token. The caller it acts for is kept in this process, so a link works only on the instance
|
|
6
|
+
* that issued it.
|
|
7
|
+
*/
|
|
8
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
9
|
+
export const LINK_PATH = "/mcp/links";
|
|
10
|
+
/** How long a presigned link stays usable. */
|
|
11
|
+
export const LINK_TTL_SECONDS = 300;
|
|
12
|
+
export class PresignedLinks {
|
|
13
|
+
publicUrl;
|
|
14
|
+
ttlSeconds;
|
|
15
|
+
key;
|
|
16
|
+
issued = new Map();
|
|
17
|
+
uploading = new Set();
|
|
18
|
+
verified = new Map();
|
|
19
|
+
sweeper;
|
|
20
|
+
constructor(publicUrl, secret, ttlSeconds = LINK_TTL_SECONDS) {
|
|
21
|
+
this.publicUrl = publicUrl;
|
|
22
|
+
this.ttlSeconds = ttlSeconds;
|
|
23
|
+
this.key = secret ? Buffer.from(secret, "utf8") : randomBytes(32);
|
|
24
|
+
this.sweeper = setInterval(() => this.sweep(), 60_000).unref();
|
|
25
|
+
}
|
|
26
|
+
sign(body) {
|
|
27
|
+
return createHmac("sha256", this.key).update(`fmsg-mcp.presigned-link.v1:${body}`).digest("base64url");
|
|
28
|
+
}
|
|
29
|
+
/** Issue a link for `caller`. */
|
|
30
|
+
mint(caller, claims) {
|
|
31
|
+
const expiresAt = Math.floor(Date.now() / 1000) + this.ttlSeconds;
|
|
32
|
+
const full = { v: 1, j: randomUUID(), a: caller.address, e: expiresAt, ...claims };
|
|
33
|
+
const body = Buffer.from(JSON.stringify(full)).toString("base64url");
|
|
34
|
+
this.issued.set(full.j, { caller, used: false, expires: expiresAt * 1000 });
|
|
35
|
+
return { url: `${this.publicUrl.replace(/\/$/u, "")}/links/${body}.${this.sign(body)}`, expiresAt };
|
|
36
|
+
}
|
|
37
|
+
/** The link in a request path, with the caller it acts for, or the status and reason to refuse it with. */
|
|
38
|
+
resolve(token, method, now = Date.now()) {
|
|
39
|
+
const [body, mac, extra] = token.split(".");
|
|
40
|
+
const unknown = { status: 404, error: "unknown link" };
|
|
41
|
+
if (!body || !mac || extra !== undefined)
|
|
42
|
+
return unknown;
|
|
43
|
+
const expected = Buffer.from(this.sign(body));
|
|
44
|
+
const given = Buffer.from(mac);
|
|
45
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given))
|
|
46
|
+
return unknown;
|
|
47
|
+
let claims;
|
|
48
|
+
try {
|
|
49
|
+
claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return unknown;
|
|
53
|
+
}
|
|
54
|
+
if (claims.v !== 1)
|
|
55
|
+
return unknown;
|
|
56
|
+
if ((claims.k === "get") !== (method === "GET"))
|
|
57
|
+
return { status: 405, error: claims.k === "get" ? "this link is for GET" : "this link is for PUT" };
|
|
58
|
+
if (claims.e * 1000 <= now)
|
|
59
|
+
return { status: 410, error: `this link expired at ${new Date(claims.e * 1000).toISOString()}; ask for a new one` };
|
|
60
|
+
const issued = this.issued.get(claims.j);
|
|
61
|
+
// Signed by this key but not issued here: another instance (shared key) or before a restart.
|
|
62
|
+
if (!issued)
|
|
63
|
+
return { status: 404, error: "this link was issued by another server instance or before a restart; ask for a new one" };
|
|
64
|
+
if (claims.k === "get" && issued.used)
|
|
65
|
+
return { status: 409, error: "this link was already used; each link downloads once" };
|
|
66
|
+
return { claims, caller: issued.caller };
|
|
67
|
+
}
|
|
68
|
+
/** Spend a download link. False when it was already spent. */
|
|
69
|
+
consume(id) {
|
|
70
|
+
const issued = this.issued.get(id);
|
|
71
|
+
if (!issued || issued.used)
|
|
72
|
+
return false;
|
|
73
|
+
issued.used = true;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
// UploadTracker: a PUT link takes one successful upload; failed attempts leave it usable until it expires.
|
|
77
|
+
begin(key) { if (this.uploading.has(key))
|
|
78
|
+
return false; this.uploading.add(key); return true; }
|
|
79
|
+
end(key) { this.uploading.delete(key); }
|
|
80
|
+
recordVerified(key, sha256) { this.verified.set(key, sha256); }
|
|
81
|
+
verifiedSha256(key) { return this.verified.get(key); }
|
|
82
|
+
sweep() {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
for (const [id, issued] of this.issued)
|
|
85
|
+
if (issued.expires <= now)
|
|
86
|
+
this.issued.delete(id);
|
|
87
|
+
if (!this.issued.size)
|
|
88
|
+
this.verified.clear();
|
|
89
|
+
}
|
|
90
|
+
close() {
|
|
91
|
+
clearInterval(this.sweeper);
|
|
92
|
+
this.issued.clear();
|
|
93
|
+
this.verified.clear();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** A string as one POSIX shell word. */
|
|
97
|
+
export function shellQuote(value) {
|
|
98
|
+
return `'${value.replace(/'/gu, `'\\''`)}'`;
|
|
99
|
+
}
|
|
100
|
+
/** A local file name for curl -o: the attachment's name reduced to portable characters. */
|
|
101
|
+
export function localFileName(filename) {
|
|
102
|
+
const name = filename.normalize("NFKD").replace(/[\u0300-\u036f]/gu, "").replace(/[^A-Za-z0-9._-]+/gu, "_").replace(/^[.-]+/u, "");
|
|
103
|
+
return name || "attachment";
|
|
104
|
+
}
|
package/dist/oauth/scopes.js
CHANGED
|
@@ -11,6 +11,7 @@ export const TOOL_SCOPES = {
|
|
|
11
11
|
send_message: [WRITE_SCOPE], reply: MESSAGING_SCOPES,
|
|
12
12
|
mark_read: [WRITE_SCOPE], add_recipients: [WRITE_SCOPE], react: [WRITE_SCOPE],
|
|
13
13
|
create_draft: MESSAGING_SCOPES, send_draft: MESSAGING_SCOPES, delete_draft: MESSAGING_SCOPES,
|
|
14
|
+
get_attachment_upload_url: MESSAGING_SCOPES,
|
|
14
15
|
};
|
|
15
16
|
export function parseScopes(value) {
|
|
16
17
|
if (typeof value !== "string")
|
package/dist/prompts.js
CHANGED
|
@@ -4,13 +4,15 @@ function sendFileSteps(routes, to, replyTo) {
|
|
|
4
4
|
const recipients = to ? `to ${to}, with a short topic` : "to the recipients I name (ask me if I have not)";
|
|
5
5
|
const draft = replyTo ? `with reply_to "${replyTo}"${to ? ` and to: ${to}` : ""}` : recipients;
|
|
6
6
|
const direct = replyTo ? `reply with id "${replyTo}"${to ? ` and recipients: ${to}` : ""}` : `send_message ${recipients}`;
|
|
7
|
-
const inline = "attach text files as data_text and small binary files as data_base64
|
|
7
|
+
const inline = "attach text files as data_text and small binary files as data_base64 with their sha256 (base64 is best effort, so keep it for small files)";
|
|
8
8
|
if (routes.uploadUrl) {
|
|
9
9
|
return [
|
|
10
10
|
`1. Call create_draft ${draft}; ${inline}.`,
|
|
11
11
|
"2. For each larger file, PUT its raw bytes to the draft's upload URL with your host's authenticated HTTP tools, " +
|
|
12
12
|
"sending its sha256 in x-content-sha256, and check each response's size and sha256. If your host cannot send this " +
|
|
13
|
-
"connection's credentials with its own requests,
|
|
13
|
+
"connection's credentials with its own requests, " + (routes.presigned
|
|
14
|
+
? "get a single-use link for each file from get_attachment_upload_url and run the curl command it returns"
|
|
15
|
+
: "recreate the draft with the file as data_base64 instead") + "; never look for or copy credentials.",
|
|
14
16
|
"3. Call send_draft with the draft id and tell me what was sent. If a step fails, call delete_draft unless I want to keep the draft.",
|
|
15
17
|
];
|
|
16
18
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import type { Config } from "./config.js";
|
|
3
3
|
import type { CallerProvider } from "./context.js";
|
|
4
|
+
import type { PresignedLinks } from "./links.js";
|
|
4
5
|
export declare const SERVER_NAME = "fmsg";
|
|
5
6
|
export type CreateServerOptions = {
|
|
6
7
|
/** The caller's address when already known; makes the instructions name it. */
|
|
@@ -9,6 +10,8 @@ export type CreateServerOptions = {
|
|
|
9
10
|
owner?: string;
|
|
10
11
|
/** Aborted when the process begins shutting down, so wait_for_message returns "interrupted". */
|
|
11
12
|
shutdown?: AbortSignal;
|
|
13
|
+
/** Issues presigned download and upload links (HTTP with a public URL). */
|
|
14
|
+
links?: PresignedLinks;
|
|
12
15
|
};
|
|
13
16
|
/**
|
|
14
17
|
* Build an fmsg MCP server. Registration only — no I/O — so the same factory
|