@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.
@@ -0,0 +1,186 @@
1
+ import { FmsgHttpError } from "./client/errors.js";
2
+ /** The emoji indicate shows by default: working on a reply. */
3
+ export const WORKING_EMOJI = "⏳";
4
+ /** A 4xx other than 401 (renewed by the client), 408 or 429 will not succeed on a retry; neither will a closed client. */
5
+ function permanent(error) {
6
+ if (error instanceof FmsgHttpError)
7
+ return error.status >= 400 && error.status < 500 && ![401, 408, 429].includes(error.status);
8
+ return error instanceof Error && error.name === "AbortError";
9
+ }
10
+ function describe(error) {
11
+ return error instanceof Error ? error.message : String(error);
12
+ }
13
+ /** The indicators one caller shows. Sends for one message are serialized so a clear never overtakes a refresh. */
14
+ class CallerIndicators {
15
+ client;
16
+ settings;
17
+ onIdle;
18
+ entries = new Map();
19
+ tails = new Map();
20
+ constructor(client, settings, onIdle) {
21
+ this.client = client;
22
+ this.settings = settings;
23
+ this.onIdle = onIdle;
24
+ }
25
+ get idle() { return this.entries.size === 0 && this.tails.size === 0; }
26
+ ids() { return [...this.entries.keys()]; }
27
+ has(id) { return this.entries.has(id); }
28
+ enqueue(id, emoji, ttl) {
29
+ const previous = this.tails.get(id) ?? Promise.resolve();
30
+ const sent = previous.then(() => this.client.indicate(id, emoji, ttl));
31
+ const settled = sent.then(() => undefined, () => undefined);
32
+ this.tails.set(id, settled);
33
+ void settled.then(() => {
34
+ if (this.tails.get(id) === settled)
35
+ this.tails.delete(id);
36
+ if (this.idle)
37
+ this.onIdle();
38
+ });
39
+ return sent;
40
+ }
41
+ /** Send in the background, dropping the indicator when the host will never take it. */
42
+ sendQuietly(entry, emoji, ttl) {
43
+ this.enqueue(entry.id, emoji, ttl).catch((error) => {
44
+ this.settings.log(`indicator on ${entry.id} failed: ${describe(error)}`);
45
+ if (permanent(error) && this.entries.get(entry.id) === entry)
46
+ this.stop(entry.id, false);
47
+ });
48
+ }
49
+ schedule(entry) {
50
+ const remaining = this.settings.maxMs - (Date.now() - entry.startedAt);
51
+ entry.timer = setTimeout(() => this.tick(entry), Math.max(0, Math.min(entry.refreshMs, remaining)));
52
+ entry.timer.unref?.();
53
+ }
54
+ tick(entry) {
55
+ if (this.entries.get(entry.id) !== entry)
56
+ return;
57
+ if (Date.now() - entry.startedAt >= this.settings.maxMs) {
58
+ this.stop(entry.id);
59
+ return;
60
+ }
61
+ this.sendQuietly(entry, entry.emoji, entry.ttl);
62
+ this.schedule(entry);
63
+ }
64
+ add(id, emoji, ttl, rootId) {
65
+ this.stop(id, false);
66
+ while (this.entries.size >= this.settings.maxActive) {
67
+ const oldest = this.entries.keys().next().value;
68
+ if (oldest === undefined)
69
+ break;
70
+ this.stop(oldest);
71
+ }
72
+ const refreshMs = Math.max(1000, Math.min(this.settings.refreshMs, Math.floor((ttl * 1000) / 2)));
73
+ const entry = { id, emoji, ttl, refreshMs, rootId, startedAt: Date.now() };
74
+ this.entries.set(id, entry);
75
+ this.schedule(entry);
76
+ return entry;
77
+ }
78
+ /**
79
+ * Show `emoji` now, failing if the host refuses it, then keep it up. The send is queued before the old indicator stops
80
+ * so this caller never looks idle (and is dropped from the registry) while replacing one.
81
+ */
82
+ async set(id, emoji, ttl, rootId) {
83
+ rootId ??= this.entries.get(id)?.rootId ?? null;
84
+ const sent = this.enqueue(id, emoji, ttl);
85
+ this.stop(id, false);
86
+ await sent;
87
+ this.add(id, emoji, ttl, rootId);
88
+ }
89
+ /** Clear now, failing if the host refuses it. */
90
+ async clear(id) {
91
+ const sent = this.enqueue(id, "", 0);
92
+ this.stop(id, false);
93
+ await sent;
94
+ }
95
+ /** Stop refreshing; with `clear`, also clear the indicator in the background. */
96
+ stop(id, clear = true) {
97
+ const entry = this.entries.get(id);
98
+ if (!entry)
99
+ return;
100
+ clearTimeout(entry.timer);
101
+ this.entries.delete(id);
102
+ if (clear) {
103
+ this.enqueue(id, "", 0).catch((error) => this.settings.log(`clearing indicator on ${id} failed: ${describe(error)}`));
104
+ }
105
+ else if (this.idle)
106
+ this.onIdle();
107
+ }
108
+ stopThread(rootId) {
109
+ for (const entry of [...this.entries.values()])
110
+ if (entry.rootId === rootId || entry.id === rootId)
111
+ this.stop(entry.id);
112
+ }
113
+ stopAll(clear) {
114
+ for (const id of [...this.entries.keys()])
115
+ this.stop(id, clear);
116
+ }
117
+ }
118
+ export class IndicatorRegistry {
119
+ callers = new Map();
120
+ settings;
121
+ closed = false;
122
+ constructor(options = {}) {
123
+ this.settings = {
124
+ refreshMs: options.refreshMs ?? 30_000,
125
+ maxMs: options.maxMs ?? 5 * 60_000,
126
+ maxActive: options.maxActive ?? 10,
127
+ log: options.log ?? (() => undefined),
128
+ };
129
+ }
130
+ /** The caller's indicators, if any, switched to its current client (a renewed OAuth token brings a new one). */
131
+ existing(caller) {
132
+ const indicators = this.callers.get(caller.address.toLowerCase());
133
+ if (indicators)
134
+ indicators.client = caller.client;
135
+ return indicators;
136
+ }
137
+ of(caller) {
138
+ const key = caller.address.toLowerCase();
139
+ let indicators = this.existing(caller);
140
+ if (!indicators) {
141
+ const created = new CallerIndicators(caller.client, this.settings, () => {
142
+ if (this.callers.get(key) === created && created.idle)
143
+ this.callers.delete(key);
144
+ });
145
+ indicators = created;
146
+ this.callers.set(key, created);
147
+ }
148
+ return indicators;
149
+ }
150
+ /** After a reply was sent to `parentId`: stop what is shown on it and anywhere in its thread. */
151
+ replied(caller, parentId, rootId) {
152
+ const indicators = this.existing(caller);
153
+ if (!indicators)
154
+ return;
155
+ indicators.stop(parentId);
156
+ if (rootId)
157
+ indicators.stopThread(rootId);
158
+ }
159
+ /** After a reaction or mark_read: the caller has handled these messages. */
160
+ handled(caller, ids) {
161
+ const indicators = this.existing(caller);
162
+ for (const id of ids)
163
+ indicators?.stop(id);
164
+ }
165
+ /**
166
+ * The indicate tool: show `emoji` and keep it up, or clear with an empty one. Throws the host's refusal. `rootId` is
167
+ * the message's thread root, when known, so a reply anywhere in that thread stops it.
168
+ */
169
+ async indicate(caller, id, emoji, ttl, rootId = null) {
170
+ if (!emoji)
171
+ return this.of(caller).clear(id);
172
+ if (this.closed)
173
+ return caller.client.indicate(id, emoji, ttl);
174
+ return this.of(caller).set(id, emoji, ttl, rootId);
175
+ }
176
+ /** Message ids with an indicator kept up for this caller. */
177
+ active(caller) {
178
+ return this.callers.get(caller.address.toLowerCase())?.ids() ?? [];
179
+ }
180
+ /** Stop every indicator, clearing them in the background. Idempotent. */
181
+ close() {
182
+ this.closed = true;
183
+ for (const indicators of this.callers.values())
184
+ indicators.stopAll(true);
185
+ }
186
+ }
@@ -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 ? ["create_draft, PUT each file to its upload URL, then send_draft"] : []),
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
@@ -37,7 +37,9 @@ export function buildInstructions(ctx = {}) {
37
37
  "added to a message after it was sent; they can read it and its attachments.",
38
38
  "To check the inbox: list_messages with unread_only, get_thread on what matters, act or reply, then mark_read " +
39
39
  "what you have handled; reading does not mark a message read. There is no search: to find a thread by name, " +
40
- "find its first message by topic in list_messages, then get_thread on a later message for the conversation.",
40
+ "find its first message by topic in list_messages, then get_thread on a later message for the conversation. " +
41
+ "indicate can show the other participants a status such as ⏳ while you work on a reply (cleared when you reply, " +
42
+ "react or mark it read, or after 5 minutes); an empty emoji clears it.",
41
43
  filesParagraph(ctx.files),
42
44
  "Carry out the user's requested messaging task or authorized automation without repeatedly asking for " +
43
45
  "confirmation. Sending is immediate and sent messages cannot be edited or recalled. Ask the user only " +
@@ -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
+ }
@@ -9,8 +9,9 @@ export const TOOL_SCOPES = {
9
9
  get_thread: [READ_SCOPE], delivery_status: [READ_SCOPE], download_attachment: [READ_SCOPE],
10
10
  save_attachment: [READ_SCOPE], get_attachment_download_url: [READ_SCOPE], wait_for_message: [READ_SCOPE],
11
11
  send_message: [WRITE_SCOPE], reply: MESSAGING_SCOPES,
12
- mark_read: [WRITE_SCOPE], add_recipients: [WRITE_SCOPE], react: [WRITE_SCOPE],
12
+ mark_read: [WRITE_SCOPE], add_recipients: [WRITE_SCOPE], react: [WRITE_SCOPE], indicate: [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, each with its sha256 when you can compute it";
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, recreate the draft with the file as data_base64 instead; never look for or copy credentials.",
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/public.d.ts CHANGED
@@ -7,5 +7,6 @@ export { ApiKeyCallerProvider } from "./auth.js";
7
7
  export { waitForMessage, type WaitOptions, type WaitResult } from "./wait.js";
8
8
  export { assembleThread, renderThread, type AssembledThread } from "./thread.js";
9
9
  export { resolveAddress, resolveAddresses, normalizeFmsgAddress } from "./address.js";
10
+ export { IndicatorRegistry, type IndicatorOptions } from "./indicators.js";
10
11
  export { VERSION } from "./version.js";
11
12
  export * from "./client/index.js";
package/dist/public.js CHANGED
@@ -7,5 +7,6 @@ export { ApiKeyCallerProvider } from "./auth.js";
7
7
  export { waitForMessage } from "./wait.js";
8
8
  export { assembleThread, renderThread } from "./thread.js";
9
9
  export { resolveAddress, resolveAddresses, normalizeFmsgAddress } from "./address.js";
10
+ export { IndicatorRegistry } from "./indicators.js";
10
11
  export { VERSION } from "./version.js";
11
12
  export * from "./client/index.js";
package/dist/server.d.ts CHANGED
@@ -1,6 +1,8 @@
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";
5
+ import { IndicatorRegistry } from "./indicators.js";
4
6
  export declare const SERVER_NAME = "fmsg";
5
7
  export type CreateServerOptions = {
6
8
  /** The caller's address when already known; makes the instructions name it. */
@@ -9,6 +11,15 @@ export type CreateServerOptions = {
9
11
  owner?: string;
10
12
  /** Aborted when the process begins shutting down, so wait_for_message returns "interrupted". */
11
13
  shutdown?: AbortSignal;
14
+ /** Issues presigned download and upload links (HTTP with a public URL). */
15
+ links?: PresignedLinks;
16
+ /**
17
+ * Indicators set with the indicate tool and kept up on the caller's behalf (FMSG-006). A factory that builds one
18
+ * server per HTTP request must pass a shared registry and close it on shutdown. Without one, the server owns its own
19
+ * and clears every indicator when it closes or `shutdown` aborts, which suits a server that lives for a whole session
20
+ * (stdio).
21
+ */
22
+ indicators?: IndicatorRegistry;
12
23
  };
13
24
  /**
14
25
  * Build an fmsg MCP server. Registration only — no I/O — so the same factory
package/dist/server.js CHANGED
@@ -13,6 +13,7 @@ import { registerWaitTools } from "./tools/wait.js";
13
13
  import { registerSaveTool } from "./tools/save.js";
14
14
  import { registerDownloadTool } from "./tools/download.js";
15
15
  import { VERSION } from "./version.js";
16
+ import { IndicatorRegistry } from "./indicators.js";
16
17
  export const SERVER_NAME = "fmsg";
17
18
  /**
18
19
  * Build an fmsg MCP server. Registration only — no I/O — so the same factory
@@ -23,12 +24,23 @@ export function createFmsgMcpServer(provider, config, options = {}) {
23
24
  ...(options.address ? { address: options.address } : {}),
24
25
  ...(options.owner ? { owner: options.owner } : {}),
25
26
  ...(config.defaultDomain ? { defaultDomain: config.defaultDomain } : {}),
26
- files: fileRoutes(config),
27
+ files: fileRoutes(config, Boolean(options.links)),
27
28
  });
28
29
  const server = new McpServer({ name: SERVER_NAME, title: "fmsg", version: VERSION }, { instructions });
29
30
  const includeOwner = includeOwnerSchema(options.address, options.owner);
31
+ let indicators = options.indicators;
32
+ if (!indicators) {
33
+ const owned = new IndicatorRegistry();
34
+ indicators = owned;
35
+ const previous = server.server.onclose;
36
+ server.server.onclose = () => { owned.close(); previous?.(); };
37
+ options.shutdown?.addEventListener("abort", () => owned.close(), { once: true });
38
+ if (options.shutdown?.aborted)
39
+ owned.close();
40
+ }
30
41
  const deps = {
31
42
  provider, config, ...(includeOwner ? { includeOwner } : {}), ...(options.shutdown ? { shutdown: options.shutdown } : {}),
43
+ ...(options.links ? { links: options.links } : {}), indicators,
32
44
  };
33
45
  registerIdentityTools(server, deps);
34
46
  registerListTools(server, deps);
@@ -39,6 +51,6 @@ export function createFmsgMcpServer(provider, config, options = {}) {
39
51
  registerDraftTools(server, deps);
40
52
  registerWaitTools(server, deps);
41
53
  registerResources(server, deps);
42
- registerPrompts(server, fileRoutes(config));
54
+ registerPrompts(server, fileRoutes(config, Boolean(options.links)));
43
55
  return server;
44
56
  }
@@ -9,12 +9,16 @@ export type FileRoutes = {
9
9
  uploadUrl: boolean;
10
10
  /** Attachments may name a file in FMSG_MCP_UPLOAD_DIR (stdio). */
11
11
  uploadFolder: boolean;
12
+ /** get_attachment_upload_url issues single-use upload links that need no credentials (HTTP). */
13
+ presigned: boolean;
12
14
  };
13
- export declare function fileRoutes(config: Config): FileRoutes;
15
+ export declare function fileRoutes(config: Config, presigned?: boolean): FileRoutes;
16
+ /** The routes for files too large for base64, in words for a model; empty when there are none. */
17
+ export declare function largeFileRoutes(routes: FileRoutes): string[];
14
18
  /** A hint for the model after it wrote a large file as base64 although this server offers a route that avoids it. */
15
19
  export declare function base64Hint(items: AttachmentArgs[] | undefined, routes: FileRoutes): string | undefined;
16
20
  /** One outbound attachment as a tool argument: a filename and exactly one source of bytes. */
17
- export declare function attachmentInput(config: Config): z.ZodObject<{
21
+ export declare function attachmentInput(config: Config, routes?: FileRoutes): z.ZodObject<{
18
22
  content_type: z.ZodOptional<z.ZodString>;
19
23
  sha256: z.ZodOptional<z.ZodString>;
20
24
  path?: z.ZodOptional<z.ZodString> | undefined;
@@ -8,8 +8,16 @@ import { downloadBaseUrl } from "../download.js";
8
8
  import { attachmentType } from "../render.js";
9
9
  export const FILENAME = /^[A-Za-z0-9._-]+$/u;
10
10
  const SHA256 = /^[0-9A-Fa-f]{64}$/u;
11
- export function fileRoutes(config) {
12
- return { uploadUrl: Boolean(downloadBaseUrl(config)), uploadFolder: Boolean(config.uploadDir) };
11
+ export function fileRoutes(config, presigned = false) {
12
+ const uploadUrl = Boolean(downloadBaseUrl(config));
13
+ return { uploadUrl, uploadFolder: Boolean(config.uploadDir), presigned: uploadUrl && presigned };
14
+ }
15
+ /** The routes for files too large for base64, in words for a model; empty when there are none. */
16
+ export function largeFileRoutes(routes) {
17
+ return [
18
+ ...(routes.uploadFolder ? ["path, a file in the upload folder"] : []),
19
+ ...(routes.uploadUrl ? [`create_draft's upload URL${routes.presigned ? " (or a single-use link from get_attachment_upload_url)" : ""}`] : []),
20
+ ];
13
21
  }
14
22
  /** Inline base64 this long (about 48 KB of file) earns a hint about a better route next time. A hint only, never a limit. */
15
23
  const BASE64_HINT_CHARS = 64 * 1024;
@@ -17,7 +25,7 @@ const BASE64_HINT_CHARS = 64 * 1024;
17
25
  export function base64Hint(items, routes) {
18
26
  const large = (items ?? []).filter((a) => (a.data_base64?.length ?? 0) >= BASE64_HINT_CHARS).map((a) => a.filename);
19
27
  const better = [
20
- ...(routes.uploadUrl ? ["call create_draft, PUT them to its upload URL, then call send_draft"] : []),
28
+ ...(routes.uploadUrl ? [`call create_draft, PUT them to its upload URL${routes.presigned ? " or to single-use links from get_attachment_upload_url" : ""}, then call send_draft`] : []),
21
29
  ...(routes.uploadFolder ? ["give their path in the upload folder instead of data_base64"] : []),
22
30
  ];
23
31
  if (!large.length || !better.length)
@@ -29,17 +37,20 @@ function sources(config) {
29
37
  return ["data_base64", "data_text", ...(config.uploadDir ? ["path"] : [])];
30
38
  }
31
39
  /** One outbound attachment as a tool argument: a filename and exactly one source of bytes. */
32
- export function attachmentInput(config) {
40
+ export function attachmentInput(config, routes = fileRoutes(config)) {
33
41
  const names = sources(config);
42
+ const large = largeFileRoutes(routes);
34
43
  return z.strictObject({
35
44
  filename: z.string().regex(FILENAME, "letters, digits, dot, underscore, hyphen only").describe("file name"),
36
- data_base64: z.string().optional().describe(`base64 file contents, for small files${config.uploadDir ? "; give large files as path" : downloadBaseUrl(config) ? "; send large files with create_draft's upload URL" : ""}. The host sets the size limit`),
45
+ data_base64: z.string().optional().describe("base64 file contents. You write the base64, so it is best effort: fine for " +
46
+ `small files (checked exact up to 16 KB). Always give sha256 with it${large.length ? `. For anything larger use ${large.join(", or ")}` : ""}. ` +
47
+ "The host sets the size limit"),
37
48
  data_text: z.string().optional().describe("text file contents, sent as UTF-8 bytes; use instead of data_base64 for text"),
38
49
  ...(config.uploadDir ? {
39
50
  path: z.string().min(1).optional().describe("file in the configured upload folder, relative to it; the server reads it so its bytes skip model context"),
40
51
  } : {}),
41
52
  content_type: z.string().optional().describe("inferred from the filename when omitted"),
42
- sha256: z.string().regex(SHA256, "64 hexadecimal characters").optional().describe("hex sha256 of the file; the attachment is refused, and nothing sent, if the bytes differ. Give it when you can compute it"),
53
+ sha256: z.string().regex(SHA256, "64 hexadecimal characters").optional().describe("hex sha256 of the file; the attachment is refused, and nothing sent, if the bytes differ. Always give it with data_base64"),
43
54
  }).superRefine((a, ctx) => {
44
55
  const given = names.filter((name) => a[name] !== undefined);
45
56
  if (given.length !== 1)
@@ -3,6 +3,8 @@ import * as z from "zod/v4";
3
3
  import type { AddressResolver } from "../address.js";
4
4
  import type { FmsgMessage } from "../client/types.js";
5
5
  import type { Config } from "../config.js";
6
+ import type { IndicatorRegistry } from "../indicators.js";
7
+ import type { PresignedLinks } from "../links.js";
6
8
  import { type Caller, type CallerProvider } from "../context.js";
7
9
  export type ToolDeps = {
8
10
  provider: CallerProvider;
@@ -11,6 +13,10 @@ export type ToolDeps = {
11
13
  includeOwner?: z.ZodDefault<z.ZodBoolean>;
12
14
  /** Aborted when the server begins shutting down; long waits then return early. */
13
15
  shutdown?: AbortSignal;
16
+ /** Presigned links, when this server has a public URL. */
17
+ links?: PresignedLinks;
18
+ /** FMSG-006 indicators the caller sets with the indicate tool, kept up until replied, handled or cleared. */
19
+ indicators: IndicatorRegistry;
14
20
  };
15
21
  export declare const READ_ONLY: ToolAnnotations;
16
22
  export declare const SENDS: ToolAnnotations;