@markmnl/fmsg-mcp 1.4.2 → 1.5.1
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 +5 -0
- package/README.md +23 -1
- package/dist/build-info.json +3 -3
- package/dist/config.d.ts +4 -0
- package/dist/config.js +3 -0
- package/dist/host-files.d.ts +66 -0
- package/dist/host-files.js +258 -0
- package/dist/instructions.js +2 -1
- package/dist/server.d.ts +3 -0
- package/dist/server.js +1 -0
- package/dist/tools/attachments.d.ts +14 -0
- package/dist/tools/attachments.js +28 -1
- package/dist/tools/common.d.ts +3 -0
- package/dist/tools/draft.js +9 -3
- package/dist/tools/send.d.ts +6 -0
- package/dist/tools/send.js +14 -5
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- `send_message`, `reply` and `create_draft` take `files`: files the user shared in the chat, given by
|
|
6
|
+
reference (ChatGPT's `openai/fileParams`, declared in each tool's `_meta`). The server downloads each
|
|
7
|
+
one, from public HTTPS addresses only, hashes it, attaches the exact bytes and reports its sha256 in
|
|
8
|
+
the result's `files`. ChatGPT can now send images and PDFs, which its sandbox could not upload.
|
|
9
|
+
New settings: `FMSG_MCP_FILE_HOSTS`, `FMSG_MCP_MAX_FILE_BYTES`.
|
|
5
10
|
- `react` is non-destructive again (`destructiveHint: false`), reversing 1.4.1: a reaction is one emoji
|
|
6
11
|
its sender changes or clears with another reaction, and it alters no one's messages, so hosts needn't
|
|
7
12
|
ask before each one.
|
package/README.md
CHANGED
|
@@ -214,6 +214,8 @@ attach resources; prompts `chat` and `reply` script the wait → reply loop and
|
|
|
214
214
|
| `FMSG_MCP_DOWNLOAD_DIR` | — | Enable `save_attachment` in stdio; folder for new files named from message ID and filename |
|
|
215
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 |
|
|
216
216
|
| `FMSG_MCP_WAIT_MAX_SECONDS` | `230` | Cap on one `wait_for_message` call |
|
|
217
|
+
| `FMSG_MCP_FILE_HOSTS` | any public host | Comma list of host names (or parent domains) that [files given by reference](#files-given-by-reference) may be downloaded from |
|
|
218
|
+
| `FMSG_MCP_MAX_FILE_BYTES` | `20971520` (20 MiB) | Size limit for one file given by reference |
|
|
217
219
|
| `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 |
|
|
218
220
|
| `FMSG_MCP_HOST` / `FMSG_MCP_PORT` | `127.0.0.1` / `8765` | HTTP bind address (or `--http host:port`) |
|
|
219
221
|
| `FMSG_MCP_ALLOWED_HOSTS` | loopback names | Comma-separated `Host` header allowlist; required for non-loopback binds |
|
|
@@ -268,6 +270,26 @@ with `x-content-sha256`), then `send_draft`; see
|
|
|
268
270
|
[attachment uploads](docs/http-deployment.md#attachment-uploads). Send files as they are: fmsg
|
|
269
271
|
compresses message bodies and attachments itself when it sends them.
|
|
270
272
|
|
|
273
|
+
#### Files given by reference
|
|
274
|
+
|
|
275
|
+
`send_message`, `reply` and `create_draft` also take `files`: files the user shared in the chat,
|
|
276
|
+
which the host fills in and the server downloads itself. This is ChatGPT's
|
|
277
|
+
[`openai/fileParams`](https://developers.openai.com/apps-sdk/reference): each of the three tools
|
|
278
|
+
declares `_meta["openai/fileParams"]: ["files"]`, and ChatGPT passes each file as
|
|
279
|
+
`{ download_url, file_id, mime_type?, file_name? }`. The bytes never pass through the model, and the
|
|
280
|
+
model needs no HTTP access of its own (ChatGPT's sandbox has none). Other hosts ignore the field.
|
|
281
|
+
|
|
282
|
+
- Only `https` URLs, with no credentials in them, and at most 3 redirects. Every hop must resolve to a
|
|
283
|
+
public address: loopback, private, link-local (including cloud metadata), CGNAT, multicast and
|
|
284
|
+
reserved addresses are refused, and the connection goes to the address that was checked.
|
|
285
|
+
`FMSG_MCP_FILE_HOSTS` narrows the allowed hosts further.
|
|
286
|
+
- Each file is limited to `FMSG_MCP_MAX_FILE_BYTES` and 30 seconds.
|
|
287
|
+
- The name is the host's `file_name` made safe (letters, digits, dot, underscore, hyphen; `file.<ext>`
|
|
288
|
+
from the type when there is none), renamed `name-2.ext` on a clash.
|
|
289
|
+
- The bytes are hashed as they download. The upload is checked against that sha256, and the result's
|
|
290
|
+
`files` lists each file's `file_id`, name, size and `sha256`.
|
|
291
|
+
- Every file is downloaded before a draft is created, so one that fails leaves nothing behind.
|
|
292
|
+
|
|
271
293
|
Hosts that cannot attach the connection's credentials to their own HTTP requests can still keep file
|
|
272
294
|
bytes out of the model. `get_attachment_upload_url` returns a single-use, 5-minute link per draft
|
|
273
295
|
file, bound to its size and sha256, with a ready-to-run `curl` command. `get_attachment_download_url`
|
|
@@ -280,7 +302,7 @@ routes the running server offers. A model that writes a large file as base64 alt
|
|
|
280
302
|
route exists gets a `hint` in the result naming it. The `send_file` prompt walks through the best
|
|
281
303
|
route. The upload URL needs a host that can send the MCP connection's credentials with its own
|
|
282
304
|
HTTP requests: scripts, SDK clients, and coding agents given an fmsg API key. Chat hosts usually
|
|
283
|
-
cannot
|
|
305
|
+
cannot; ChatGPT uses `files`, and for others `data_text` and `data_base64` remain the routes.
|
|
284
306
|
|
|
285
307
|
The Web API already transfers attachments as raw bytes. Base64 is used only when embedding binary
|
|
286
308
|
content in MCP's JSON results, as required by [MCP binary resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources#binary-content).
|
package/dist/build-info.json
CHANGED
package/dist/config.d.ts
CHANGED
|
@@ -39,6 +39,10 @@ export type Config = {
|
|
|
39
39
|
uploadDir?: string;
|
|
40
40
|
/** Hard cap on a single wait_for_message call. */
|
|
41
41
|
waitMaxSeconds: number;
|
|
42
|
+
/** Host names (or parent domains) files given by reference may be downloaded from; empty allows any public host. */
|
|
43
|
+
fileHosts?: string[];
|
|
44
|
+
/** Byte limit for one file given by reference. */
|
|
45
|
+
maxFileBytes?: number;
|
|
42
46
|
http: HttpConfig;
|
|
43
47
|
oauth?: OAuthConfig;
|
|
44
48
|
};
|
package/dist/config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_MAX_FILE_BYTES } from "./host-files.js";
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
3
|
import { CALLER_DOMAIN, normalizeFmsgAddress } from "./address.js";
|
|
3
4
|
import { normalizeApiUrl, normalizeOrigin } from "./client/url.js";
|
|
@@ -107,6 +108,8 @@ export function loadConfig(env, transport, overrides = {}, options = {}) {
|
|
|
107
108
|
...(transport === "stdio" && env.FMSG_MCP_DOWNLOAD_DIR?.trim() ? { downloadDir: env.FMSG_MCP_DOWNLOAD_DIR.trim() } : {}),
|
|
108
109
|
...(transport === "stdio" && env.FMSG_MCP_UPLOAD_DIR?.trim() ? { uploadDir: env.FMSG_MCP_UPLOAD_DIR.trim() } : {}),
|
|
109
110
|
waitMaxSeconds: intEnv(env, "FMSG_MCP_WAIT_MAX_SECONDS", DEFAULT_WAIT_MAX_SECONDS),
|
|
111
|
+
fileHosts: listEnv(env, "FMSG_MCP_FILE_HOSTS"),
|
|
112
|
+
maxFileBytes: intEnv(env, "FMSG_MCP_MAX_FILE_BYTES", DEFAULT_MAX_FILE_BYTES),
|
|
110
113
|
http: {
|
|
111
114
|
host,
|
|
112
115
|
port,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
/** The input field the host fills, named in each tool's `_meta["openai/fileParams"]`. */
|
|
3
|
+
export declare const HOST_FILES_FIELD = "files";
|
|
4
|
+
export declare const HOST_FILE_META: {
|
|
5
|
+
"openai/fileParams": string[];
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* OpenAI's file object: the four properties declared, download_url and file_id required, the others optional.
|
|
9
|
+
* Not strict, so an optional property the host adds later is accepted.
|
|
10
|
+
*/
|
|
11
|
+
export declare const hostFileSchema: z.ZodObject<{
|
|
12
|
+
download_url: z.ZodString;
|
|
13
|
+
file_id: z.ZodString;
|
|
14
|
+
mime_type: z.ZodOptional<z.ZodString>;
|
|
15
|
+
file_name: z.ZodOptional<z.ZodString>;
|
|
16
|
+
}, z.core.$strip>;
|
|
17
|
+
export declare const hostFilesInput: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
18
|
+
download_url: z.ZodString;
|
|
19
|
+
file_id: z.ZodString;
|
|
20
|
+
mime_type: z.ZodOptional<z.ZodString>;
|
|
21
|
+
file_name: z.ZodOptional<z.ZodString>;
|
|
22
|
+
}, z.core.$strip>>>;
|
|
23
|
+
export type HostFileArgs = {
|
|
24
|
+
download_url: string;
|
|
25
|
+
file_id: string;
|
|
26
|
+
mime_type?: string;
|
|
27
|
+
file_name?: string;
|
|
28
|
+
};
|
|
29
|
+
/** A downloaded host file, ready to attach. */
|
|
30
|
+
export type HostFile = {
|
|
31
|
+
fileId: string;
|
|
32
|
+
filename: string;
|
|
33
|
+
contentType: string;
|
|
34
|
+
data: Uint8Array;
|
|
35
|
+
sha256: string;
|
|
36
|
+
};
|
|
37
|
+
/** One HTTP response from a download hop. */
|
|
38
|
+
export type HostResponse = {
|
|
39
|
+
status: number;
|
|
40
|
+
headers: Record<string, string | undefined>;
|
|
41
|
+
body: AsyncIterable<Uint8Array>;
|
|
42
|
+
cancel: () => void;
|
|
43
|
+
};
|
|
44
|
+
/** Fetches one URL with no redirects followed; tests substitute it. */
|
|
45
|
+
export type HostTransport = (url: URL, signal: AbortSignal) => Promise<HostResponse>;
|
|
46
|
+
export type HostFileOptions = {
|
|
47
|
+
/** Byte limit per file. */
|
|
48
|
+
maxBytes: number;
|
|
49
|
+
/** Allowed host names (exact or a parent domain); empty allows any public host. */
|
|
50
|
+
allowedHosts: string[];
|
|
51
|
+
transport?: HostTransport;
|
|
52
|
+
/** Per-file time budget. */
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
};
|
|
55
|
+
export declare const DEFAULT_MAX_FILE_BYTES: number;
|
|
56
|
+
/** Whether an IP address is public (globally routable), so a download may connect to it. */
|
|
57
|
+
export declare function isPublicAddress(address: string): boolean;
|
|
58
|
+
/** The default transport: HTTPS to public addresses only, nothing followed. */
|
|
59
|
+
export declare const publicHttpsTransport: HostTransport;
|
|
60
|
+
/** A safe attachment name (letters, digits, dot, underscore, hyphen) from what the host gave. */
|
|
61
|
+
export declare function hostFilename(given: string | undefined, contentType: string | undefined): string;
|
|
62
|
+
/**
|
|
63
|
+
* Download every host file before anything is created upstream, so a bad one never leaves a draft behind.
|
|
64
|
+
* `taken` holds the names already used by the call's other attachments.
|
|
65
|
+
*/
|
|
66
|
+
export declare function prepareHostFiles(files: HostFileArgs[] | undefined, options: HostFileOptions, signal: AbortSignal, taken?: Set<string>): Promise<HostFile[]>;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files a host hands to a tool by reference (ChatGPT's `openai/fileParams`): the host fills a file object with a
|
|
3
|
+
* download URL and the server fetches the bytes, so they never pass through the model and the model needs no
|
|
4
|
+
* outbound HTTP of its own. The download only reaches public addresses: every hop is resolved, checked and connected
|
|
5
|
+
* to by the checked address, so a hostile URL can't reach loopback, the local network or a cloud metadata service.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { lookup as dnsLookup } from "node:dns";
|
|
9
|
+
import { request as httpsRequest } from "node:https";
|
|
10
|
+
import { BlockList, isIP } from "node:net";
|
|
11
|
+
import * as z from "zod/v4";
|
|
12
|
+
import { attachmentType } from "./render.js";
|
|
13
|
+
/** The input field the host fills, named in each tool's `_meta["openai/fileParams"]`. */
|
|
14
|
+
export const HOST_FILES_FIELD = "files";
|
|
15
|
+
export const HOST_FILE_META = { "openai/fileParams": [HOST_FILES_FIELD] };
|
|
16
|
+
/**
|
|
17
|
+
* OpenAI's file object: the four properties declared, download_url and file_id required, the others optional.
|
|
18
|
+
* Not strict, so an optional property the host adds later is accepted.
|
|
19
|
+
*/
|
|
20
|
+
export const hostFileSchema = z.object({
|
|
21
|
+
download_url: z.string().describe("filled in by the host"),
|
|
22
|
+
file_id: z.string().describe("filled in by the host"),
|
|
23
|
+
mime_type: z.string().optional().describe("filled in by the host"),
|
|
24
|
+
file_name: z.string().optional().describe("filled in by the host"),
|
|
25
|
+
}).describe("a file the user shared in this chat, filled in by the host");
|
|
26
|
+
export const hostFilesInput = z.array(hostFileSchema).optional().describe("files the user shared in this chat (images, PDFs and so on); the host fills these in and this server downloads them, " +
|
|
27
|
+
"so never write their bytes as base64");
|
|
28
|
+
export const DEFAULT_MAX_FILE_BYTES = 20 * 1024 * 1024;
|
|
29
|
+
const MAX_REDIRECTS = 3;
|
|
30
|
+
const FILENAME_MAX = 100;
|
|
31
|
+
// Addresses a download must never reach.
|
|
32
|
+
const blocked = new BlockList();
|
|
33
|
+
for (const [net, bits] of [
|
|
34
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12],
|
|
35
|
+
["192.0.0.0", 24], ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15],
|
|
36
|
+
["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
37
|
+
])
|
|
38
|
+
blocked.addSubnet(net, bits, "ipv4");
|
|
39
|
+
for (const [net, bits] of [
|
|
40
|
+
["::", 128], ["::1", 128], ["100::", 64], ["2001::", 32], ["2001:db8::", 32],
|
|
41
|
+
["fc00::", 7], ["fe80::", 10], ["ff00::", 8],
|
|
42
|
+
])
|
|
43
|
+
blocked.addSubnet(net, bits, "ipv6");
|
|
44
|
+
/** Whether an IP address is public (globally routable), so a download may connect to it. */
|
|
45
|
+
export function isPublicAddress(address) {
|
|
46
|
+
const family = isIP(address);
|
|
47
|
+
if (!family)
|
|
48
|
+
return false;
|
|
49
|
+
if (family === 4)
|
|
50
|
+
return !blocked.check(address, "ipv4");
|
|
51
|
+
// IPv4-mapped (::ffff:a.b.c.d or ::ffff:7f00:1) and NAT64 (64:ff9b::/96) addresses are judged by their IPv4 address.
|
|
52
|
+
const embedded = embeddedIpv4(address);
|
|
53
|
+
if (embedded)
|
|
54
|
+
return !blocked.check(embedded, "ipv4");
|
|
55
|
+
return !blocked.check(address, "ipv6");
|
|
56
|
+
}
|
|
57
|
+
/** The IPv4 address inside an IPv4-mapped or NAT64 IPv6 address, if it is one. */
|
|
58
|
+
function embeddedIpv4(address) {
|
|
59
|
+
const lower = address.toLowerCase();
|
|
60
|
+
const prefix = ["::ffff:", "64:ff9b::"].find((p) => lower.startsWith(p));
|
|
61
|
+
if (!prefix)
|
|
62
|
+
return undefined;
|
|
63
|
+
const rest = lower.slice(prefix.length);
|
|
64
|
+
if (isIP(rest) === 4)
|
|
65
|
+
return rest;
|
|
66
|
+
const m = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/u.exec(rest);
|
|
67
|
+
if (!m)
|
|
68
|
+
return undefined;
|
|
69
|
+
const hi = parseInt(m[1], 16), lo = parseInt(m[2], 16);
|
|
70
|
+
return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
71
|
+
}
|
|
72
|
+
class RefusedError extends Error {
|
|
73
|
+
}
|
|
74
|
+
/** A DNS lookup for https.request that only ever yields public addresses: the connection uses what was checked. */
|
|
75
|
+
function publicLookup(hostname, options, callback) {
|
|
76
|
+
dnsLookup(hostname, { all: true }, (err, addresses) => {
|
|
77
|
+
if (err)
|
|
78
|
+
return callback(err, "", 0);
|
|
79
|
+
const ok = addresses.filter((a) => isPublicAddress(a.address));
|
|
80
|
+
if (!ok.length)
|
|
81
|
+
return callback(Object.assign(new RefusedError("address not public"), { code: "EREFUSED_PRIVATE" }), "", 0);
|
|
82
|
+
if (options.all)
|
|
83
|
+
return callback(null, ok);
|
|
84
|
+
callback(null, ok[0].address, ok[0].family);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** The default transport: HTTPS to public addresses only, nothing followed. */
|
|
88
|
+
export const publicHttpsTransport = (url, signal) => new Promise((resolve, reject) => {
|
|
89
|
+
if (isIP(url.hostname.replace(/^\[|\]$/gu, "")) && !isPublicAddress(url.hostname.replace(/^\[|\]$/gu, ""))) {
|
|
90
|
+
reject(new RefusedError("address not public"));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const req = httpsRequest(url, { method: "GET", lookup: publicLookup, signal, headers: { accept: "*/*" } }, (res) => {
|
|
94
|
+
resolve({
|
|
95
|
+
status: res.statusCode ?? 0,
|
|
96
|
+
headers: Object.fromEntries(Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : v])),
|
|
97
|
+
body: res,
|
|
98
|
+
cancel: () => res.destroy(),
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
req.on("error", (error) => reject(error.code === "EREFUSED_PRIVATE" ? new RefusedError("address not public") : error));
|
|
102
|
+
req.end();
|
|
103
|
+
});
|
|
104
|
+
function hostAllowed(host, allowed) {
|
|
105
|
+
if (!allowed.length)
|
|
106
|
+
return true;
|
|
107
|
+
const h = host.toLowerCase();
|
|
108
|
+
return allowed.some((a) => { const d = a.toLowerCase().replace(/^\./u, ""); return h === d || h.endsWith(`.${d}`); });
|
|
109
|
+
}
|
|
110
|
+
function checkUrl(raw, allowed) {
|
|
111
|
+
let url;
|
|
112
|
+
try {
|
|
113
|
+
url = new URL(raw);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new RefusedError("not a valid URL");
|
|
117
|
+
}
|
|
118
|
+
if (url.protocol !== "https:")
|
|
119
|
+
throw new RefusedError("only https download URLs are accepted");
|
|
120
|
+
if (url.username || url.password)
|
|
121
|
+
throw new RefusedError("credentials in the URL are not accepted");
|
|
122
|
+
if (!hostAllowed(url.hostname, allowed))
|
|
123
|
+
throw new RefusedError(`host ${url.hostname} is not an allowed file host`);
|
|
124
|
+
return url;
|
|
125
|
+
}
|
|
126
|
+
const EXTENSIONS = {
|
|
127
|
+
"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", "image/heic": "heic", "image/svg+xml": "svg",
|
|
128
|
+
"application/pdf": "pdf", "text/plain": "txt", "text/markdown": "md", "text/csv": "csv", "application/json": "json",
|
|
129
|
+
"application/zip": "zip", "audio/mpeg": "mp3", "audio/mp4": "m4a", "video/mp4": "mp4", "video/quicktime": "mov",
|
|
130
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
|
131
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
132
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
|
133
|
+
};
|
|
134
|
+
/** A safe attachment name (letters, digits, dot, underscore, hyphen) from what the host gave. */
|
|
135
|
+
export function hostFilename(given, contentType) {
|
|
136
|
+
const base = (given ?? "").split(/[\\/]/u).pop() ?? "";
|
|
137
|
+
let name = base.normalize("NFKD").replace(/[^A-Za-z0-9._-]+/gu, "_").replace(/_+/gu, "_").replace(/^[._]+/u, "");
|
|
138
|
+
if (name.length > FILENAME_MAX) {
|
|
139
|
+
const dot = name.lastIndexOf(".");
|
|
140
|
+
const ext = dot > 0 && name.length - dot <= 10 ? name.slice(dot) : "";
|
|
141
|
+
name = name.slice(0, FILENAME_MAX - ext.length) + ext;
|
|
142
|
+
}
|
|
143
|
+
if (!name || name === "_") {
|
|
144
|
+
const ext = EXTENSIONS[(contentType ?? "").split(";")[0].trim().toLowerCase()];
|
|
145
|
+
name = ext ? `file.${ext}` : "file";
|
|
146
|
+
}
|
|
147
|
+
return name;
|
|
148
|
+
}
|
|
149
|
+
function uniqueName(name, taken) {
|
|
150
|
+
if (!taken.has(name.toLowerCase())) {
|
|
151
|
+
taken.add(name.toLowerCase());
|
|
152
|
+
return name;
|
|
153
|
+
}
|
|
154
|
+
const dot = name.lastIndexOf(".");
|
|
155
|
+
const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
|
|
156
|
+
for (let i = 2;; i++) {
|
|
157
|
+
const candidate = `${stem}-${i}${ext}`;
|
|
158
|
+
if (!taken.has(candidate.toLowerCase())) {
|
|
159
|
+
taken.add(candidate.toLowerCase());
|
|
160
|
+
return candidate;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function sizeText(bytes) {
|
|
165
|
+
return bytes >= 1024 * 1024 ? `${Math.round(bytes / 1024 / 1024)} MiB` : `${Math.round(bytes / 1024)} KiB`;
|
|
166
|
+
}
|
|
167
|
+
/** Download one host file: public HTTPS, redirects checked, size-limited, hashed. */
|
|
168
|
+
async function download(file, label, options, outer) {
|
|
169
|
+
const transport = options.transport ?? publicHttpsTransport;
|
|
170
|
+
const signal = AbortSignal.any([outer, AbortSignal.timeout(options.timeoutMs ?? 30_000)]);
|
|
171
|
+
let url;
|
|
172
|
+
try {
|
|
173
|
+
url = checkUrl(file.download_url, options.allowedHosts);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
throw new Error(`file ${label}: download refused (${error.message})`);
|
|
177
|
+
}
|
|
178
|
+
for (let hop = 0;; hop++) {
|
|
179
|
+
let res;
|
|
180
|
+
try {
|
|
181
|
+
res = await transport(url, signal);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error instanceof RefusedError)
|
|
185
|
+
throw new Error(`file ${label}: download refused (${error.message})`);
|
|
186
|
+
if (signal.aborted)
|
|
187
|
+
throw new Error(`file ${label}: download timed out or was cancelled`);
|
|
188
|
+
throw new Error(`file ${label}: download failed (${error.message})`);
|
|
189
|
+
}
|
|
190
|
+
if (res.status >= 300 && res.status < 400 && res.headers.location) {
|
|
191
|
+
res.cancel();
|
|
192
|
+
if (hop >= MAX_REDIRECTS)
|
|
193
|
+
throw new Error(`file ${label}: download refused (too many redirects)`);
|
|
194
|
+
try {
|
|
195
|
+
url = checkUrl(new URL(res.headers.location, url).toString(), options.allowedHosts);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
throw new Error(`file ${label}: download refused (${error.message})`);
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (res.status !== 200) {
|
|
203
|
+
res.cancel();
|
|
204
|
+
throw new Error(`file ${label}: download failed: HTTP ${res.status}`);
|
|
205
|
+
}
|
|
206
|
+
const declared = Number(res.headers["content-length"]);
|
|
207
|
+
if (Number.isFinite(declared) && declared > options.maxBytes) {
|
|
208
|
+
res.cancel();
|
|
209
|
+
throw new Error(`file ${label} is larger than ${sizeText(options.maxBytes)}`);
|
|
210
|
+
}
|
|
211
|
+
const hash = createHash("sha256");
|
|
212
|
+
const chunks = [];
|
|
213
|
+
let total = 0;
|
|
214
|
+
try {
|
|
215
|
+
for await (const chunk of res.body) {
|
|
216
|
+
total += chunk.byteLength;
|
|
217
|
+
if (total > options.maxBytes) {
|
|
218
|
+
res.cancel();
|
|
219
|
+
throw new Error(`file ${label} is larger than ${sizeText(options.maxBytes)}`);
|
|
220
|
+
}
|
|
221
|
+
hash.update(chunk);
|
|
222
|
+
chunks.push(chunk);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error.message.startsWith(`file ${label}`))
|
|
227
|
+
throw error;
|
|
228
|
+
throw new Error(`file ${label}: download failed (${signal.aborted ? "timed out or was cancelled" : error.message})`);
|
|
229
|
+
}
|
|
230
|
+
if (total === 0)
|
|
231
|
+
throw new Error(`file ${label} is empty`);
|
|
232
|
+
const data = new Uint8Array(total);
|
|
233
|
+
let offset = 0;
|
|
234
|
+
for (const c of chunks) {
|
|
235
|
+
data.set(c, offset);
|
|
236
|
+
offset += c.byteLength;
|
|
237
|
+
}
|
|
238
|
+
const contentType = res.headers["content-type"]?.split(",")[0]?.trim();
|
|
239
|
+
return { data, sha256: hash.digest("hex"), ...(contentType ? { contentType } : {}) };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Download every host file before anything is created upstream, so a bad one never leaves a draft behind.
|
|
244
|
+
* `taken` holds the names already used by the call's other attachments.
|
|
245
|
+
*/
|
|
246
|
+
export async function prepareHostFiles(files, options, signal, taken = new Set()) {
|
|
247
|
+
const out = [];
|
|
248
|
+
for (const f of files ?? []) {
|
|
249
|
+
if (!f.download_url || !f.file_id)
|
|
250
|
+
throw new Error("each file needs download_url and file_id (the host fills them in)");
|
|
251
|
+
const label = hostFilename(f.file_name, f.mime_type);
|
|
252
|
+
const got = await download(f, label, options, signal);
|
|
253
|
+
const contentType = f.mime_type?.trim() || got.contentType || attachmentType(label);
|
|
254
|
+
const filename = uniqueName(f.file_name ? label : hostFilename(undefined, contentType), taken);
|
|
255
|
+
out.push({ fileId: f.file_id, filename, contentType, data: got.data, sha256: got.sha256 });
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
package/dist/instructions.js
CHANGED
|
@@ -14,7 +14,8 @@ function filesParagraph(files) {
|
|
|
14
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 (best effort; always add sha256)." + (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
|
+
" Files the user shared in this chat go in files, which the host fills in; this server downloads them, so never write their bytes.";
|
|
18
19
|
}
|
|
19
20
|
export function buildInstructions(ctx = {}) {
|
|
20
21
|
const identity = ctx.address
|
package/dist/server.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { HostTransport } from "./host-files.js";
|
|
1
2
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
3
|
import type { Config } from "./config.js";
|
|
3
4
|
import type { CallerProvider } from "./context.js";
|
|
@@ -20,6 +21,8 @@ export type CreateServerOptions = {
|
|
|
20
21
|
* (stdio).
|
|
21
22
|
*/
|
|
22
23
|
indicators?: IndicatorRegistry;
|
|
24
|
+
/** Fetches files given by reference (tests substitute it). */
|
|
25
|
+
hostTransport?: HostTransport;
|
|
23
26
|
};
|
|
24
27
|
/**
|
|
25
28
|
* Build an fmsg MCP server. Registration only — no I/O — so the same factory
|
package/dist/server.js
CHANGED
|
@@ -41,6 +41,7 @@ export function createFmsgMcpServer(provider, config, options = {}) {
|
|
|
41
41
|
const deps = {
|
|
42
42
|
provider, config, ...(includeOwner ? { includeOwner } : {}), ...(options.shutdown ? { shutdown: options.shutdown } : {}),
|
|
43
43
|
...(options.links ? { links: options.links } : {}), indicators,
|
|
44
|
+
...(options.hostTransport ? { hostTransport: options.hostTransport } : {}),
|
|
44
45
|
};
|
|
45
46
|
registerIdentityTools(server, deps);
|
|
46
47
|
registerListTools(server, deps);
|
|
@@ -2,6 +2,7 @@ import * as z from "zod/v4";
|
|
|
2
2
|
import type { FmsgClient } from "../client/client.js";
|
|
3
3
|
import type { Attachment, DraftInput, DraftResult } from "../client/types.js";
|
|
4
4
|
import type { Config } from "../config.js";
|
|
5
|
+
import { type HostFile, type HostFileArgs, type HostTransport } from "../host-files.js";
|
|
5
6
|
export declare const FILENAME: RegExp;
|
|
6
7
|
/** Ways this server can take a file without the model writing its bytes. */
|
|
7
8
|
export type FileRoutes = {
|
|
@@ -40,6 +41,7 @@ export type PreparedAttachment = {
|
|
|
40
41
|
filename: string;
|
|
41
42
|
contentType: string;
|
|
42
43
|
sha256?: string;
|
|
44
|
+
verify?: boolean;
|
|
43
45
|
} & ({
|
|
44
46
|
data: Uint8Array;
|
|
45
47
|
} | {
|
|
@@ -68,3 +70,15 @@ export declare function sendWithAttachments(client: FmsgClient, input: DraftInpu
|
|
|
68
70
|
topic: string;
|
|
69
71
|
attachments: Attachment[];
|
|
70
72
|
}>;
|
|
73
|
+
/** What a send or draft tool attaches: its inline attachments, then the files the host gave by reference. */
|
|
74
|
+
export declare function prepareAllAttachments(items: AttachmentArgs[] | undefined, files: HostFileArgs[] | undefined, config: Config, transport: HostTransport | undefined, signal: AbortSignal): Promise<{
|
|
75
|
+
prepared: PreparedAttachment[];
|
|
76
|
+
hostFiles: HostFile[];
|
|
77
|
+
}>;
|
|
78
|
+
/** The files given by reference as they were attached, for the tool result. */
|
|
79
|
+
export declare function hostFilesResult(files: HostFile[]): Array<{
|
|
80
|
+
file_id: string;
|
|
81
|
+
filename: string;
|
|
82
|
+
size: number;
|
|
83
|
+
sha256: string;
|
|
84
|
+
}>;
|
|
@@ -6,6 +6,7 @@ import { Readable } from "node:stream";
|
|
|
6
6
|
import * as z from "zod/v4";
|
|
7
7
|
import { downloadBaseUrl } from "../download.js";
|
|
8
8
|
import { attachmentType } from "../render.js";
|
|
9
|
+
import { DEFAULT_MAX_FILE_BYTES, prepareHostFiles } from "../host-files.js";
|
|
9
10
|
export const FILENAME = /^[A-Za-z0-9._-]+$/u;
|
|
10
11
|
const SHA256 = /^[0-9A-Fa-f]{64}$/u;
|
|
11
12
|
export function fileRoutes(config, presigned = false) {
|
|
@@ -139,9 +140,17 @@ export async function prepareAttachments(items, config) {
|
|
|
139
140
|
export async function uploadPrepared(client, draftId, prepared, signal) {
|
|
140
141
|
const uploaded = [];
|
|
141
142
|
for (const a of prepared) {
|
|
142
|
-
if ("data" in a) {
|
|
143
|
+
if ("data" in a && !(a.verify && a.sha256)) {
|
|
143
144
|
uploaded.push(await client.uploadAttachment(draftId, { filename: a.filename, data: a.data, contentType: a.contentType }, signal));
|
|
144
145
|
}
|
|
146
|
+
else if ("data" in a) {
|
|
147
|
+
// A downloaded file: the host checks the bytes it stores against the hash taken while downloading.
|
|
148
|
+
const stream = Readable.toWeb(Readable.from([Buffer.from(a.data)]));
|
|
149
|
+
const { filename, size } = await client.uploadAttachmentStream(draftId, {
|
|
150
|
+
filename: a.filename, stream, contentType: a.contentType, expectedSha256: a.sha256,
|
|
151
|
+
}, signal);
|
|
152
|
+
uploaded.push({ filename, size });
|
|
153
|
+
}
|
|
145
154
|
else {
|
|
146
155
|
const stream = Readable.toWeb(createReadStream(a.file));
|
|
147
156
|
const { filename, size } = await client.uploadAttachmentStream(draftId, {
|
|
@@ -174,3 +183,21 @@ export async function sendWithAttachments(client, input, prepared, signal) {
|
|
|
174
183
|
throw error;
|
|
175
184
|
}
|
|
176
185
|
}
|
|
186
|
+
/** What a send or draft tool attaches: its inline attachments, then the files the host gave by reference. */
|
|
187
|
+
export async function prepareAllAttachments(items, files, config, transport, signal) {
|
|
188
|
+
const prepared = await prepareAttachments(items, config);
|
|
189
|
+
const taken = new Set(prepared.map((a) => a.filename.toLowerCase()));
|
|
190
|
+
const hostFiles = await prepareHostFiles(files, {
|
|
191
|
+
maxBytes: config.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
|
|
192
|
+
allowedHosts: config.fileHosts ?? [],
|
|
193
|
+
...(transport ? { transport } : {}),
|
|
194
|
+
}, signal, taken);
|
|
195
|
+
return {
|
|
196
|
+
prepared: [...prepared, ...hostFiles.map((f) => ({ filename: f.filename, contentType: f.contentType, data: f.data, sha256: f.sha256, verify: true }))],
|
|
197
|
+
hostFiles,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** The files given by reference as they were attached, for the tool result. */
|
|
201
|
+
export function hostFilesResult(files) {
|
|
202
|
+
return files.map((f) => ({ file_id: f.fileId, filename: f.filename, size: f.data.byteLength, sha256: f.sha256 }));
|
|
203
|
+
}
|
package/dist/tools/common.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { HostTransport } from "../host-files.js";
|
|
1
2
|
import type { CallToolResult, McpServer, ServerContext, ToolAnnotations } from "@modelcontextprotocol/server";
|
|
2
3
|
import * as z from "zod/v4";
|
|
3
4
|
import type { AddressResolver } from "../address.js";
|
|
@@ -17,6 +18,8 @@ export type ToolDeps = {
|
|
|
17
18
|
links?: PresignedLinks;
|
|
18
19
|
/** FMSG-006 indicators the caller sets with the indicate tool, kept up until replied, handled or cleared. */
|
|
19
20
|
indicators: IndicatorRegistry;
|
|
21
|
+
/** Fetches files given by reference; the public-HTTPS transport when omitted. */
|
|
22
|
+
hostTransport?: HostTransport;
|
|
20
23
|
};
|
|
21
24
|
export declare const READ_ONLY: ToolAnnotations;
|
|
22
25
|
export declare const SENDS: ToolAnnotations;
|
package/dist/tools/draft.js
CHANGED
|
@@ -4,7 +4,8 @@ import { toolError } from "../errors.js";
|
|
|
4
4
|
import { withOwner } from "../owner.js";
|
|
5
5
|
import { addressList, headerValue, isoTime } from "../render.js";
|
|
6
6
|
import { CHECKSUM_HEADER, UPLOAD_ENCODINGS, draftUploadUrlTemplate } from "../upload.js";
|
|
7
|
-
import { attachmentInput, base64Hint, draftWithAttachments, fileRoutes,
|
|
7
|
+
import { attachmentInput, base64Hint, draftWithAttachments, fileRoutes, hostFilesResult, prepareAllAttachments } from "./attachments.js";
|
|
8
|
+
import { HOST_FILE_META, hostFilesInput } from "../host-files.js";
|
|
8
9
|
import { SENDS, attachmentItem, idSchema, ok, openEnum, outputObject, resolverFor, withCaller } from "./common.js";
|
|
9
10
|
import { parentReference, replyParent } from "./parent.js";
|
|
10
11
|
import { threadRoot } from "./read.js";
|
|
@@ -30,6 +31,7 @@ export const registerDraftTools = (server, deps) => {
|
|
|
30
31
|
allow_no_reply: z.boolean().default(false).describe("draft a reply even though the parent asked for no replies"),
|
|
31
32
|
...includeOwner,
|
|
32
33
|
attachments: z.array(attachmentInput(deps.config)).optional().describe("files to attach now"),
|
|
34
|
+
files: hostFilesInput,
|
|
33
35
|
}),
|
|
34
36
|
outputSchema: outputObject({
|
|
35
37
|
draft_id: z.string(),
|
|
@@ -37,6 +39,8 @@ export const registerDraftTools = (server, deps) => {
|
|
|
37
39
|
topic: z.string(),
|
|
38
40
|
parent_id: z.string().nullable(),
|
|
39
41
|
attachments: z.array(attachmentItem).describe("sorted by filename, the same order in every tool"),
|
|
42
|
+
files: z.array(outputObject({ file_id: z.string().optional(), filename: z.string().optional(), size: z.number().optional(), sha256: z.string().optional() })).optional()
|
|
43
|
+
.describe("the files given by reference, as attached: the name used, size and hex sha256 of the bytes"),
|
|
40
44
|
redactions: z.number().describe("secrets replaced with placeholders in the draft"),
|
|
41
45
|
redacted: z.array(z.string()).describe("the kinds of secret replaced; empty when none"),
|
|
42
46
|
resolved: resolvedOutput,
|
|
@@ -52,7 +56,8 @@ export const registerDraftTools = (server, deps) => {
|
|
|
52
56
|
hint: hintOutput,
|
|
53
57
|
}),
|
|
54
58
|
annotations: { ...SENDS, destructiveHint: false, openWorldHint: false },
|
|
55
|
-
|
|
59
|
+
_meta: HOST_FILE_META,
|
|
60
|
+
}, async ({ to, topic, reply_to, body, type, important, no_reply, allow_no_reply, include_owner, attachments, files }, ctx) => {
|
|
56
61
|
if (reply_to && topic)
|
|
57
62
|
return toolError("a reply has no topic: pass topic only for a new message");
|
|
58
63
|
if (!reply_to && !to?.length)
|
|
@@ -75,7 +80,7 @@ export const registerDraftTools = (server, deps) => {
|
|
|
75
80
|
({ to: recipients, resolved } = resolveRecipients(to, resolverFor(deps, caller)));
|
|
76
81
|
}
|
|
77
82
|
recipients = withOwner(caller, recipients, include_owner);
|
|
78
|
-
const outbound = await
|
|
83
|
+
const { prepared: outbound, hostFiles } = await prepareAllAttachments(attachments, files, deps.config, deps.hostTransport, signal);
|
|
79
84
|
const hint = base64Hint(attachments, routes);
|
|
80
85
|
const draft = await draftWithAttachments(caller.client, {
|
|
81
86
|
to: recipients, body, type, important, noReply: no_reply, ...(parentRef ?? { topic: topic ?? "" }),
|
|
@@ -96,6 +101,7 @@ export const registerDraftTools = (server, deps) => {
|
|
|
96
101
|
topic: draft.topic,
|
|
97
102
|
parent_id: pid ?? null,
|
|
98
103
|
attachments: sentAttachments(draft.attachments, outbound),
|
|
104
|
+
...(hostFiles.length ? { files: hostFilesResult(hostFiles) } : {}),
|
|
99
105
|
redactions: draft.redactions,
|
|
100
106
|
redacted: draft.redacted,
|
|
101
107
|
...(resolved.length ? { resolved } : {}),
|
package/dist/tools/send.d.ts
CHANGED
|
@@ -69,6 +69,12 @@ export declare const sentOutput: z.ZodObject<{
|
|
|
69
69
|
size: z.ZodNumber;
|
|
70
70
|
type: z.ZodOptional<z.ZodString>;
|
|
71
71
|
}, z.core.$loose>>;
|
|
72
|
+
files: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
73
|
+
file_id: z.ZodOptional<z.ZodString>;
|
|
74
|
+
filename: z.ZodOptional<z.ZodString>;
|
|
75
|
+
size: z.ZodOptional<z.ZodNumber>;
|
|
76
|
+
sha256: z.ZodOptional<z.ZodString>;
|
|
77
|
+
}, z.core.$loose>>>;
|
|
72
78
|
redactions: z.ZodNumber;
|
|
73
79
|
redacted: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
74
80
|
resolved: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
package/dist/tools/send.js
CHANGED
|
@@ -3,7 +3,8 @@ import { resolveAddress, sameAddress } from "../address.js";
|
|
|
3
3
|
import { toolError } from "../errors.js";
|
|
4
4
|
import { withOwner } from "../owner.js";
|
|
5
5
|
import { addressList, addressText, attachmentType, headerValue, isoTime, participantsOf, sortAttachments } from "../render.js";
|
|
6
|
-
import { attachmentInput, base64Hint, fileRoutes,
|
|
6
|
+
import { attachmentInput, base64Hint, fileRoutes, hostFilesResult, prepareAllAttachments, sendWithAttachments } from "./attachments.js";
|
|
7
|
+
import { HOST_FILE_META, hostFilesInput } from "../host-files.js";
|
|
7
8
|
import { SENDS, attachmentItem, idSchema, ok, outputObject, resolverFor, withCaller } from "./common.js";
|
|
8
9
|
import { parentReference, replyParent } from "./parent.js";
|
|
9
10
|
import { WORKING_EMOJI } from "../indicators.js";
|
|
@@ -88,6 +89,8 @@ export const sentOutput = outputObject({
|
|
|
88
89
|
topic: z.string(),
|
|
89
90
|
parent_id: z.string().nullable(),
|
|
90
91
|
attachments: z.array(attachmentItem).describe("sorted by filename, the same order in every tool"),
|
|
92
|
+
files: z.array(outputObject({ file_id: z.string().optional(), filename: z.string().optional(), size: z.number().optional(), sha256: z.string().optional() })).optional()
|
|
93
|
+
.describe("the files given by reference, as attached: the name used, size and hex sha256 of the bytes"),
|
|
91
94
|
redactions: z.number().describe("secrets replaced with placeholders before sending"),
|
|
92
95
|
redacted: z.array(z.string()).optional().describe("the kinds of secret replaced, such as github_token; empty when none"),
|
|
93
96
|
resolved: resolvedOutput,
|
|
@@ -125,13 +128,15 @@ export const registerSendTools = (server, deps) => {
|
|
|
125
128
|
no_reply: z.boolean().default(false).describe(NO_REPLY),
|
|
126
129
|
...includeOwner,
|
|
127
130
|
attachments,
|
|
131
|
+
files: hostFilesInput,
|
|
128
132
|
}),
|
|
129
133
|
outputSchema: sentOutput,
|
|
130
134
|
annotations: SENDS,
|
|
131
|
-
|
|
135
|
+
_meta: HOST_FILE_META,
|
|
136
|
+
}, async ({ to, topic, body, type, important, no_reply, include_owner, attachments, files }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
132
137
|
const { to: resolvedTo, resolved } = resolveRecipients(to, resolverFor(deps, caller));
|
|
133
138
|
const recipients = withOwner(caller, resolvedTo, include_owner);
|
|
134
|
-
const outbound = await
|
|
139
|
+
const { prepared: outbound, hostFiles } = await prepareAllAttachments(attachments, files, deps.config, deps.hostTransport, signal);
|
|
135
140
|
const hint = base64Hint(attachments, routes);
|
|
136
141
|
const sent = await sendWithAttachments(caller.client, { to: recipients, topic, body, type, important, noReply: no_reply }, outbound, signal);
|
|
137
142
|
const structured = {
|
|
@@ -142,6 +147,7 @@ export const registerSendTools = (server, deps) => {
|
|
|
142
147
|
topic: sent.topic,
|
|
143
148
|
parent_id: null,
|
|
144
149
|
attachments: sentAttachments(sent.attachments, outbound),
|
|
150
|
+
...(hostFiles.length ? { files: hostFilesResult(hostFiles) } : {}),
|
|
145
151
|
redactions: sent.redactions,
|
|
146
152
|
redacted: sent.redacted,
|
|
147
153
|
...(resolved.length ? { resolved } : {}),
|
|
@@ -171,10 +177,12 @@ export const registerSendTools = (server, deps) => {
|
|
|
171
177
|
allow_no_reply: z.boolean().default(false).describe("reply even though the parent asked for no replies"),
|
|
172
178
|
...includeOwner,
|
|
173
179
|
attachments,
|
|
180
|
+
files: hostFilesInput,
|
|
174
181
|
}),
|
|
175
182
|
outputSchema: sentOutput,
|
|
176
183
|
annotations: SENDS,
|
|
177
|
-
|
|
184
|
+
_meta: HOST_FILE_META,
|
|
185
|
+
}, async ({ id, body, recipients, type, important, no_reply, allow_no_reply, include_owner, attachments, files }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
178
186
|
const parent = await replyParent(caller, id, signal);
|
|
179
187
|
if ("content" in parent)
|
|
180
188
|
return parent;
|
|
@@ -183,7 +191,7 @@ export const registerSendTools = (server, deps) => {
|
|
|
183
191
|
return target;
|
|
184
192
|
const { resolved } = target;
|
|
185
193
|
const to = withOwner(caller, target.to, include_owner);
|
|
186
|
-
const outbound = await
|
|
194
|
+
const { prepared: outbound, hostFiles } = await prepareAllAttachments(attachments, files, deps.config, deps.hostTransport, signal);
|
|
187
195
|
const hint = base64Hint(attachments, routes);
|
|
188
196
|
const sent = await sendWithAttachments(caller.client, { to, ...parentReference(caller, parent), body, type, important, noReply: no_reply }, outbound, signal);
|
|
189
197
|
// The reply is already sent: a failed root lookup only leaves the topic unknown.
|
|
@@ -197,6 +205,7 @@ export const registerSendTools = (server, deps) => {
|
|
|
197
205
|
topic: "",
|
|
198
206
|
parent_id: parent.id,
|
|
199
207
|
attachments: sentAttachments(sent.attachments, outbound),
|
|
208
|
+
...(hostFiles.length ? { files: hostFilesResult(hostFiles) } : {}),
|
|
200
209
|
redactions: sent.redactions,
|
|
201
210
|
redacted: sent.redacted,
|
|
202
211
|
...(resolved.length ? { resolved } : {}),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markmnl/fmsg-mcp",
|
|
3
3
|
"mcpName": "io.github.markmnl/fmsg-mcp",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.5.1",
|
|
5
5
|
"description": "MCP server for fmsg: send and receive federated messages from any AI agent via a deployed fmsg Web API",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
package/server.json
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
"url": "https://github.com/markmnl/fmsg-mcp",
|
|
9
9
|
"source": "github"
|
|
10
10
|
},
|
|
11
|
-
"version": "1.
|
|
11
|
+
"version": "1.5.1",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
16
16
|
"identifier": "@markmnl/fmsg-mcp",
|
|
17
|
-
"version": "1.
|
|
17
|
+
"version": "1.5.1",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
},
|