@markmnl/fmsg-mcp 1.0.0 → 1.1.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/dist/http.js CHANGED
@@ -5,9 +5,12 @@ import { ApiKeyCallerProvider, FMSG_SCOPE } from "./auth.js";
5
5
  import { createFmsgMcpServer } from "./server.js";
6
6
  import { OAuthCallerProvider } from "./oauth/provider.js";
7
7
  import { invalidToken, insufficientScope, OAuthRequestError, oauthErrorResponse, oauthRequestState } from "./oauth/errors.js";
8
- import { MESSAGING_SCOPES, READ_SCOPE, requestScopes } from "./oauth/scopes.js";
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_PATH, UploadBodyError, decodedUploadBody, parseUploadPath, uploadEncoding } from "./upload.js";
11
+ import { AttachmentChecksumError } from "./client/client.js";
10
12
  import { FmsgHttpError } from "./client/errors.js";
13
+ import { attachmentType } from "./render.js";
11
14
  import { describeError } from "./errors.js";
12
15
  import { VERSION } from "./version.js";
13
16
  import { safeErrorMessage } from "./client/redact.js";
@@ -16,7 +19,10 @@ export const MCP_PATH = "/mcp";
16
19
  /** How long close() lets in-flight requests finish (waits end at once) before cutting them off. */
17
20
  export const SHUTDOWN_GRACE_MS = 5000;
18
21
  const CORS_METHODS = ["POST", "GET", "DELETE"];
19
- const CORS_HEADERS = ["authorization", "content-type", "accept", "mcp-protocol-version", "mcp-method", "mcp-name", "mcp-session-id", "last-event-id"];
22
+ const CORS_HEADERS = ["authorization", "content-type", "accept", "mcp-protocol-version", "mcp-method", "mcp-name", "mcp-session-id", "last-event-id",
23
+ "content-encoding", CHECKSUM_HEADER];
24
+ /** How long a request other than an attachment upload may take to arrive in full. Uploads use an idle budget instead. */
25
+ export const REQUEST_BODY_TIMEOUT_MS = 60_000;
20
26
  // Combined mode routes only fmsg API keys to API-key authentication; everything else is OAuth.
21
27
  const API_KEY_BEARER = /^Bearer fmsgk_/iu;
22
28
  /** Convert a Node request into a web-standard Request for the MCP handler. */
@@ -135,8 +141,15 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
135
141
  const abort = () => controller.abort();
136
142
  req.once("aborted", abort);
137
143
  res.once("close", abort);
144
+ const url = new URL(req.url ?? "/", "http://localhost");
145
+ const isUpload = url.pathname.startsWith(`${UPLOAD_PATH}/`);
146
+ // A progressing upload may take longer than any fixed budget; the upload stream has an idle timeout instead.
147
+ // Closing the connection afterwards stops Node from reading the rest of a refused upload's body.
148
+ const bodyTimer = isUpload ? undefined : setTimeout(() => { if (!req.complete)
149
+ req.destroy(); }, REQUEST_BODY_TIMEOUT_MS).unref();
150
+ if (isUpload)
151
+ res.setHeader("connection", "close");
138
152
  const done = (async () => {
139
- const url = new URL(req.url ?? "/", "http://localhost");
140
153
  const isDownload = url.pathname.startsWith(`${DOWNLOAD_PATH}/`);
141
154
  if (url.pathname === "/healthz") {
142
155
  res.writeHead(200, { "content-type": "application/json" });
@@ -156,7 +169,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
156
169
  authorization_servers: [config.oauth.issuerUrl], scopes_supported: MESSAGING_SCOPES,
157
170
  bearer_methods_supported: ["header"], resource_name: "fmsg messaging", }, { headers }));
158
171
  }
159
- if (url.pathname !== MCP_PATH && !isDownload) {
172
+ if (url.pathname !== MCP_PATH && !isDownload && !isUpload) {
160
173
  res.writeHead(404, { "content-type": "text/plain" });
161
174
  res.end("not found; the MCP endpoint is /mcp");
162
175
  return;
@@ -188,7 +201,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
188
201
  if (req.method === "OPTIONS") {
189
202
  const method = request.headers.get("access-control-request-method") ?? "";
190
203
  const headers = (request.headers.get("access-control-request-headers") ?? "").split(",").map((v) => v.trim().toLowerCase()).filter(Boolean);
191
- const methods = isDownload ? ["GET"] : CORS_METHODS;
204
+ const methods = isDownload ? ["GET"] : isUpload ? ["PUT"] : CORS_METHODS;
192
205
  if (!origin || !methods.includes(method) || headers.some((h) => !CORS_HEADERS.includes(h))) {
193
206
  return sendWebResponse(res, new Response("preflight not allowed", { status: 403 }));
194
207
  }
@@ -210,20 +223,61 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
210
223
  return sendWebResponse(res, new Response("invalid attachment download path", { status: 400 }));
211
224
  }
212
225
  }
213
- const serveDownload = async (provider, auth) => {
226
+ let upload;
227
+ if (isUpload) {
228
+ if (req.method !== "PUT")
229
+ return sendWebResponse(res, new Response("use PUT to upload attachments", { status: 405, headers: { allow: "PUT, OPTIONS" } }));
230
+ if (url.search)
231
+ return sendWebResponse(res, new Response("attachment URLs must not contain query parameters", { status: 400 }));
232
+ try {
233
+ upload = { ...parseUploadPath(url.pathname), contentType: "" };
234
+ }
235
+ catch {
236
+ return sendWebResponse(res, new Response("invalid attachment upload path; use /mcp/drafts/{id}/attachments/{filename}", { status: 400 }));
237
+ }
238
+ try {
239
+ upload.encoding = uploadEncoding(request.headers.get("content-encoding"));
240
+ }
241
+ catch (error) {
242
+ return sendWebResponse(res, new Response(error.message, { status: 415 }));
243
+ }
244
+ const sha256 = request.headers.get(CHECKSUM_HEADER)?.trim();
245
+ if (sha256 !== undefined) {
246
+ if (!/^[0-9A-Fa-f]{64}$/u.test(sha256))
247
+ return sendWebResponse(res, new Response(`${CHECKSUM_HEADER} must be 64 hexadecimal characters`, { status: 400 }));
248
+ upload.sha256 = sha256.toLowerCase();
249
+ }
250
+ // curl and similar tools label raw bodies as form data by default; that says nothing about the file.
251
+ const declared = request.headers.get("content-type")?.trim();
252
+ if (declared && !/^[\x20-\x7e]+$/u.test(declared))
253
+ return sendWebResponse(res, new Response("Content-Type must be printable ASCII", { status: 400 }));
254
+ upload.contentType = declared && !/^application\/x-www-form-urlencoded\b/iu.test(declared)
255
+ ? attachmentType(upload.filename, declared) : attachmentType(upload.filename);
256
+ }
257
+ const serveDownload = (provider, auth) => serveTransfer(provider, auth, READ_SCOPE, async (caller) => {
258
+ const { stream, contentType } = await caller.client.streamAttachment(download.id, download.filename, controller.signal);
259
+ let response;
260
+ try {
261
+ response = new Response(stream, { headers: downloadHeaders(download.filename, contentType) });
262
+ }
263
+ catch (error) {
264
+ await stream.cancel().catch(() => undefined);
265
+ throw error;
266
+ }
267
+ return sendWebResponse(res, response);
268
+ });
269
+ const serveUpload = (provider, auth) => serveTransfer(provider, auth, WRITE_SCOPE, async (caller) => {
270
+ const body = decodedUploadBody(request.body ?? new ReadableStream({ start: (c) => c.close() }), upload.encoding);
271
+ const uploaded = await caller.client.uploadAttachmentStream(upload.id, {
272
+ filename: upload.filename, stream: body, contentType: upload.contentType, ...(upload.sha256 ? { expectedSha256: upload.sha256 } : {}),
273
+ }, controller.signal);
274
+ return sendWebResponse(res, Response.json({ id: upload.id, ...uploaded, type: upload.contentType }, { status: 201 }));
275
+ });
276
+ const serveTransfer = async (provider, auth, scope, transfer) => {
214
277
  let caller;
215
278
  try {
216
279
  caller = await provider.forRequest(auth);
217
- const { stream, contentType } = await caller.client.streamAttachment(download.id, download.filename, controller.signal);
218
- let response;
219
- try {
220
- response = new Response(stream, { headers: downloadHeaders(download.filename, contentType) });
221
- }
222
- catch (error) {
223
- await stream.cancel().catch(() => undefined);
224
- throw error;
225
- }
226
- return await sendWebResponse(res, response);
280
+ return await transfer(caller);
227
281
  }
228
282
  catch (error) {
229
283
  const rejectedAuth = (error instanceof OAuthRequestError && error.status === 401) ||
@@ -242,10 +296,11 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
242
296
  if (rejectedAuth)
243
297
  return sendWebResponse(res, oauthErrorResponse(invalidToken(), metadataUrl));
244
298
  if (error instanceof FmsgHttpError && error.insufficientScope) {
245
- return sendWebResponse(res, oauthErrorResponse(new OAuthRequestError(403, "insufficient_scope", error.message, [READ_SCOPE]), metadataUrl));
299
+ return sendWebResponse(res, oauthErrorResponse(new OAuthRequestError(403, "insufficient_scope", error.message, [scope]), metadataUrl));
246
300
  }
247
301
  }
248
- const status = rejectedAuth ? 401 : error instanceof FmsgHttpError && error.status >= 400 && error.status <= 599 ? error.status : 502;
302
+ const status = rejectedAuth ? 401 : error instanceof AttachmentChecksumError ? 422 : error instanceof UploadBodyError ? 400
303
+ : error instanceof FmsgHttpError && error.status >= 400 && error.status <= 599 ? error.status : 502;
249
304
  const challenge = `Bearer error="invalid_token"${resource ? `, resource_metadata=${JSON.stringify(metadataUrl)}` : ""}`;
250
305
  return sendWebResponse(res, Response.json({ error: describeError(error) }, {
251
306
  status, headers: status === 401 ? { "www-authenticate": challenge } : {},
@@ -271,11 +326,13 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
271
326
  if (Array.isArray(parsedBody))
272
327
  return sendWebResponse(res, new Response("MCP batch requests are not supported", { status: 400 }));
273
328
  }
274
- const scopes = download ? [READ_SCOPE] : requestScopes(parsedBody);
329
+ const scopes = download ? [READ_SCOPE] : upload ? [WRITE_SCOPE] : requestScopes(parsedBody);
275
330
  if (scopes.some(scope => !authenticated.scopes.includes(scope)))
276
331
  throw insufficientScope(scopes);
277
332
  if (download)
278
333
  return await serveDownload(oauthProvider, authenticated);
334
+ if (upload)
335
+ return await serveUpload(oauthProvider, authenticated);
279
336
  // Discover/list operations only need the incoming JWT. Resolve an
280
337
  // exchanged credential before starting any protected tool response.
281
338
  if (scopes.length) {
@@ -314,6 +371,8 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
314
371
  return sendWebResponse(res, auth);
315
372
  if (download)
316
373
  return serveDownload(apiKeyProvider, auth);
374
+ if (upload)
375
+ return serveUpload(apiKeyProvider, auth);
317
376
  return sendWebResponse(res, await apiKeyHandler.fetch(request, { authInfo: auth }));
318
377
  })().catch((error) => {
319
378
  safeLog(`request failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -326,6 +385,7 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
326
385
  res.writeHead(500, { "content-type": "text/plain" });
327
386
  res.end("internal error");
328
387
  }).finally(() => {
388
+ clearTimeout(bodyTimer);
329
389
  release?.();
330
390
  active.delete(controller);
331
391
  req.off("aborted", abort);
@@ -333,8 +393,8 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
333
393
  });
334
394
  active.set(controller, { res, done });
335
395
  });
336
- // This bounds receiving the request body, not the duration of a wait response.
337
- server.requestTimeout = 60_000;
396
+ // Request bodies are bounded per request above (REQUEST_BODY_TIMEOUT_MS, or the upload idle budget).
397
+ server.requestTimeout = 0;
338
398
  server.headersTimeout = 60_000;
339
399
  server.keepAliveTimeout = 65_000;
340
400
  let closing;
@@ -1,7 +1,10 @@
1
+ import type { FileRoutes } from "./tools/attachments.js";
1
2
  export type InstructionsContext = {
2
3
  /** The address this server acts as, when already known (HTTP callers; stdio after a token exchange). */
3
4
  address?: string;
4
5
  /** FMSG_DEFAULT_DOMAIN: a domain, or CALLER_DOMAIN for the caller's own domain. */
5
6
  defaultDomain?: string;
7
+ /** Large-file routes this server offers; none when omitted. */
8
+ files?: FileRoutes;
6
9
  };
7
10
  export declare function buildInstructions(ctx?: InstructionsContext): string;
@@ -2,11 +2,19 @@
2
2
  * Server instructions: returned in the MCP `initialize` result and folded into
3
3
  * the model's system prompt by hosts that support them (some chat hosts do not, so
4
4
  * nothing a tool needs may live only here). Rules that span tools, each stated once:
5
- * which address this server acts as, the fmsg terms, the inbox routine, the
6
- * irreversible-send rule and untrusted content. Per-tool detail lives in the tool
5
+ * which address this server acts as, the fmsg terms, the inbox routine, how to
6
+ * send files, the irreversible-send rule and untrusted content. Per-tool detail lives in the tool
7
7
  * descriptions; situational guidance in results.
8
8
  */
9
9
  import { CALLER_DOMAIN, effectiveDefaultDomain } from "./address.js";
10
+ /** How to send files, naming only the routes this server offers. */
11
+ function filesParagraph(files) {
12
+ const large = [
13
+ ...(files?.uploadUrl ? ["create_draft, PUT each file to its upload URL, then send_draft"] : []),
14
+ ...(files?.uploadFolder ? ["give path, a file in the upload folder"] : []),
15
+ ];
16
+ return "Files: data_text for text, data_base64 for small files." + (large.length ? ` Large files: ${large.join("; or ")}.` : "");
17
+ }
10
18
  export function buildInstructions(ctx = {}) {
11
19
  const identity = ctx.address
12
20
  ? `you are acting as ${ctx.address}`
@@ -27,6 +35,7 @@ export function buildInstructions(ctx = {}) {
27
35
  "To check the inbox: list_messages with unread_only, get_thread on what matters, act or reply, then mark_read " +
28
36
  "what you have handled; reading does not mark a message read. There is no search: to find a thread by name, " +
29
37
  "find its first message by topic in list_messages, then get_thread on a later message for the conversation.",
38
+ filesParagraph(ctx.files),
30
39
  "Carry out the user's requested messaging task or authorized automation without repeatedly asking for " +
31
40
  "confirmation. Sending is immediate and sent messages cannot be edited or recalled. Ask the user only " +
32
41
  "when a decision is needed to resolve unclear intent, recipients or content.",
@@ -10,6 +10,7 @@ export const TOOL_SCOPES = {
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
12
  mark_read: [WRITE_SCOPE], add_recipients: [WRITE_SCOPE], react: [WRITE_SCOPE],
13
+ create_draft: MESSAGING_SCOPES, send_draft: MESSAGING_SCOPES, delete_draft: MESSAGING_SCOPES,
13
14
  };
14
15
  export function parseScopes(value) {
15
16
  if (typeof value !== "string")
package/dist/prompts.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/server";
2
- export declare function registerPrompts(server: McpServer): void;
2
+ import type { FileRoutes } from "./tools/attachments.js";
3
+ export declare function registerPrompts(server: McpServer, routes: FileRoutes): void;
package/dist/prompts.js CHANGED
@@ -1,5 +1,51 @@
1
1
  import * as z from "zod/v4";
2
- export function registerPrompts(server) {
2
+ /** Steps for sending files, using the best route this server offers. */
3
+ function sendFileSteps(routes, to, replyTo) {
4
+ const recipients = to ? `to ${to}, with a short topic` : "to the recipients I name (ask me if I have not)";
5
+ const draft = replyTo ? `with reply_to "${replyTo}"${to ? ` and to: ${to}` : ""}` : recipients;
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";
8
+ if (routes.uploadUrl) {
9
+ return [
10
+ `1. Call create_draft ${draft}; ${inline}.`,
11
+ "2. For each larger file, PUT its raw bytes to the draft's upload URL with your host's authenticated HTTP tools, " +
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.",
14
+ "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
+ ];
16
+ }
17
+ if (routes.uploadFolder) {
18
+ return [
19
+ `1. Call ${direct}; ${inline}.`,
20
+ "2. Give each larger file as path, relative to the upload folder, instead of its data. A file outside the folder must be " +
21
+ "copied into it first; if you cannot, ask me to.",
22
+ "3. Tell me what was sent.",
23
+ ];
24
+ }
25
+ return [
26
+ `1. Call ${direct}; ${inline}.`,
27
+ "2. This server has no upload route, so every file is written inline: tell me first if a file is too large for that to be practical.",
28
+ "3. Tell me what was sent.",
29
+ ];
30
+ }
31
+ export function registerPrompts(server, routes) {
32
+ server.registerPrompt("send_file", {
33
+ title: "Send files over fmsg",
34
+ description: "Send files to fmsg recipients or as a reply, using the most efficient way this server accepts them.",
35
+ argsSchema: z.object({
36
+ files: z.string().describe("the files to send: paths, or a description of them"),
37
+ to: z.string().optional().describe("recipients, separated by commas"),
38
+ reply_to: z.string().optional().describe("message id to reply to instead of starting a thread"),
39
+ message: z.string().optional().describe("what the message should say"),
40
+ }),
41
+ }, ({ files, to, reply_to, message }) => {
42
+ const text = [
43
+ `Send these files over fmsg: ${files}.${message ? ` The message should say: ${message}` : " Write a short message saying what they are."}`,
44
+ "Send the files as they are, without zipping or re-encoding them; fmsg compresses them in transit.",
45
+ ...sendFileSteps(routes, to, reply_to),
46
+ ].join("\n");
47
+ return { messages: [{ role: "user", content: { type: "text", text } }] };
48
+ });
3
49
  server.registerPrompt("chat", {
4
50
  title: "Chat over fmsg",
5
51
  description: "Wait for incoming fmsg messages and reply on the user's behalf, once or continuously within a thread.",
package/dist/server.js CHANGED
@@ -2,10 +2,12 @@ import { McpServer } from "@modelcontextprotocol/server";
2
2
  import { buildInstructions } from "./instructions.js";
3
3
  import { registerPrompts } from "./prompts.js";
4
4
  import { registerResources } from "./resources.js";
5
+ import { fileRoutes } from "./tools/attachments.js";
5
6
  import { registerIdentityTools } from "./tools/identity.js";
6
7
  import { registerListTools } from "./tools/list.js";
7
8
  import { registerReadTools } from "./tools/read.js";
8
9
  import { registerSendTools } from "./tools/send.js";
10
+ import { registerDraftTools } from "./tools/draft.js";
9
11
  import { registerWaitTools } from "./tools/wait.js";
10
12
  import { registerSaveTool } from "./tools/save.js";
11
13
  import { registerDownloadTool } from "./tools/download.js";
@@ -19,6 +21,7 @@ export function createFmsgMcpServer(provider, config, options = {}) {
19
21
  const instructions = buildInstructions({
20
22
  ...(options.address ? { address: options.address } : {}),
21
23
  ...(config.defaultDomain ? { defaultDomain: config.defaultDomain } : {}),
24
+ files: fileRoutes(config),
22
25
  });
23
26
  const server = new McpServer({ name: SERVER_NAME, title: "fmsg", version: VERSION }, { instructions });
24
27
  const deps = { provider, config, ...(options.shutdown ? { shutdown: options.shutdown } : {}) };
@@ -28,8 +31,9 @@ export function createFmsgMcpServer(provider, config, options = {}) {
28
31
  registerSaveTool(server, deps);
29
32
  registerDownloadTool(server, deps);
30
33
  registerSendTools(server, deps);
34
+ registerDraftTools(server, deps);
31
35
  registerWaitTools(server, deps);
32
36
  registerResources(server, deps);
33
- registerPrompts(server);
37
+ registerPrompts(server, fileRoutes(config));
34
38
  return server;
35
39
  }
@@ -0,0 +1,65 @@
1
+ import * as z from "zod/v4";
2
+ import type { FmsgClient } from "../client/client.js";
3
+ import type { Attachment, DraftInput, DraftResult } from "../client/types.js";
4
+ import type { Config } from "../config.js";
5
+ export declare const FILENAME: RegExp;
6
+ /** Ways this server can take a file without the model writing its bytes. */
7
+ export type FileRoutes = {
8
+ /** create_draft returns an HTTP upload URL (HTTP with a public MCP URL). */
9
+ uploadUrl: boolean;
10
+ /** Attachments may name a file in FMSG_MCP_UPLOAD_DIR (stdio). */
11
+ uploadFolder: boolean;
12
+ };
13
+ export declare function fileRoutes(config: Config): FileRoutes;
14
+ /** A hint for the model after it wrote a large file as base64 although this server offers a route that avoids it. */
15
+ export declare function base64Hint(items: AttachmentArgs[] | undefined, routes: FileRoutes): string | undefined;
16
+ /** One outbound attachment as a tool argument: a filename and exactly one source of bytes. */
17
+ export declare function attachmentInput(config: Config): z.ZodObject<{
18
+ content_type: z.ZodOptional<z.ZodString>;
19
+ sha256: z.ZodOptional<z.ZodString>;
20
+ path?: z.ZodOptional<z.ZodString> | undefined;
21
+ filename: z.ZodString;
22
+ data_base64: z.ZodOptional<z.ZodString>;
23
+ data_text: z.ZodOptional<z.ZodString>;
24
+ }, z.core.$strict>;
25
+ /** Parsed attachment arguments; `path` is a string whenever the schema registered it. */
26
+ export type AttachmentArgs = {
27
+ filename: string;
28
+ data_base64?: string;
29
+ data_text?: string;
30
+ path?: unknown;
31
+ content_type?: string;
32
+ sha256?: string;
33
+ };
34
+ /** A validated attachment ready to upload: bytes in memory, or a file to stream. */
35
+ export type PreparedAttachment = {
36
+ filename: string;
37
+ contentType: string;
38
+ sha256?: string;
39
+ } & ({
40
+ data: Uint8Array;
41
+ } | {
42
+ file: string;
43
+ });
44
+ /**
45
+ * Decode base64 strictly: only the standard alphabet and whitespace, `=` padding only at the end, a valid length, and
46
+ * no stray trailing bits. Node's decoder skips anything else silently, which would send a corrupted file.
47
+ */
48
+ export declare function decodeBase64Strict(filename: string, input: string): Buffer;
49
+ /** Validate every attachment before anything is created upstream, so a bad one never leaves a draft behind. */
50
+ export declare function prepareAttachments(items: AttachmentArgs[] | undefined, config: Config): Promise<PreparedAttachment[]>;
51
+ /** Upload prepared attachments into a draft; files stream from disk and are checked against their sha256. */
52
+ export declare function uploadPrepared(client: FmsgClient, draftId: string, prepared: PreparedAttachment[], signal: AbortSignal): Promise<Attachment[]>;
53
+ /** Create a draft and upload its attachments, deleting the draft if any upload fails. */
54
+ export declare function draftWithAttachments(client: FmsgClient, input: DraftInput, prepared: PreparedAttachment[], signal: AbortSignal): Promise<DraftResult & {
55
+ attachments: Attachment[];
56
+ }>;
57
+ /** Draft, attach and send in one step; the draft is deleted if any step fails. */
58
+ export declare function sendWithAttachments(client: FmsgClient, input: DraftInput, prepared: PreparedAttachment[], signal: AbortSignal): Promise<{
59
+ id: string;
60
+ time: number | null;
61
+ redactions: number;
62
+ redacted: string[];
63
+ topic: string;
64
+ attachments: Attachment[];
65
+ }>;
@@ -0,0 +1,157 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { realpath, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import * as z from "zod/v4";
7
+ import { downloadBaseUrl } from "../download.js";
8
+ import { attachmentType } from "../render.js";
9
+ export const FILENAME = /^[A-Za-z0-9._-]+$/u;
10
+ const SHA256 = /^[0-9A-Fa-f]{64}$/u;
11
+ export function fileRoutes(config) {
12
+ return { uploadUrl: Boolean(downloadBaseUrl(config)), uploadFolder: Boolean(config.uploadDir) };
13
+ }
14
+ /** 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
+ const BASE64_HINT_CHARS = 64 * 1024;
16
+ /** A hint for the model after it wrote a large file as base64 although this server offers a route that avoids it. */
17
+ export function base64Hint(items, routes) {
18
+ const large = (items ?? []).filter((a) => (a.data_base64?.length ?? 0) >= BASE64_HINT_CHARS).map((a) => a.filename);
19
+ const better = [
20
+ ...(routes.uploadUrl ? ["call create_draft, PUT them to its upload URL, then call send_draft"] : []),
21
+ ...(routes.uploadFolder ? ["give their path in the upload folder instead of data_base64"] : []),
22
+ ];
23
+ if (!large.length || !better.length)
24
+ return undefined;
25
+ return `${large.join(", ")} went inline as base64. Next time, send files this large without writing their bytes: ${better.join(", or ")}.`;
26
+ }
27
+ /** The attachment sources this server accepts; `path` only over stdio with an upload folder. */
28
+ function sources(config) {
29
+ return ["data_base64", "data_text", ...(config.uploadDir ? ["path"] : [])];
30
+ }
31
+ /** One outbound attachment as a tool argument: a filename and exactly one source of bytes. */
32
+ export function attachmentInput(config) {
33
+ const names = sources(config);
34
+ return z.strictObject({
35
+ 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`),
37
+ data_text: z.string().optional().describe("text file contents, sent as UTF-8 bytes; use instead of data_base64 for text"),
38
+ ...(config.uploadDir ? {
39
+ 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
+ } : {}),
41
+ 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"),
43
+ }).superRefine((a, ctx) => {
44
+ const given = names.filter((name) => a[name] !== undefined);
45
+ if (given.length !== 1)
46
+ ctx.addIssue({ code: "custom", message: `attachment ${a.filename}: give exactly one of ${names.join(", ")}` });
47
+ });
48
+ }
49
+ /**
50
+ * Decode base64 strictly: only the standard alphabet and whitespace, `=` padding only at the end, a valid length, and
51
+ * no stray trailing bits. Node's decoder skips anything else silently, which would send a corrupted file.
52
+ */
53
+ export function decodeBase64Strict(filename, input) {
54
+ const bad = /[^A-Za-z0-9+/=\s]/u.exec(input);
55
+ if (bad)
56
+ throw new Error(`attachment ${filename}: invalid base64 character ${JSON.stringify(bad[0])} at position ${bad.index}`);
57
+ const compact = input.replace(/\s+/gu, "");
58
+ const padding = compact.indexOf("=");
59
+ const unpadded = padding < 0 ? compact : compact.slice(0, padding);
60
+ if ((padding >= 0 && (!/^={1,2}$/u.test(compact.slice(padding)) || compact.length % 4 !== 0)) || unpadded.length % 4 === 1) {
61
+ throw new Error(`attachment ${filename}: invalid base64 length or padding; part of the data may be missing`);
62
+ }
63
+ const data = Buffer.from(compact, "base64");
64
+ if (data.toString("base64").replace(/=+$/u, "") !== unpadded)
65
+ throw new Error(`attachment ${filename}: invalid base64 ending; part of the data may be missing`);
66
+ return data;
67
+ }
68
+ /** Resolve a path inside the operator's upload folder; refuse anything that leaves it, including through symlinks. */
69
+ async function uploadFile(directory, filename, relative) {
70
+ if (path.isAbsolute(relative) || relative.includes("\0"))
71
+ throw new Error(`attachment ${filename}: path must be relative to the upload folder`);
72
+ const root = await realpath(directory);
73
+ let target;
74
+ try {
75
+ target = await realpath(path.join(root, relative));
76
+ }
77
+ catch (error) {
78
+ if (error.code === "ENOENT")
79
+ throw new Error(`attachment ${filename}: ${relative} was not found in the upload folder`);
80
+ throw error;
81
+ }
82
+ const inside = path.relative(root, target);
83
+ if (!inside || inside.startsWith("..") || path.isAbsolute(inside))
84
+ throw new Error(`attachment ${filename}: path must stay inside the upload folder`);
85
+ if (!(await stat(target)).isFile())
86
+ throw new Error(`attachment ${filename}: ${relative} is not a regular file`);
87
+ return target;
88
+ }
89
+ function checksum(filename, expected, data) {
90
+ if (!expected)
91
+ return;
92
+ const actual = createHash("sha256").update(data).digest("hex");
93
+ if (actual !== expected.toLowerCase())
94
+ throw new Error(`attachment ${filename}: sha256 mismatch (expected ${expected.toLowerCase()}, got ${actual}); nothing was sent`);
95
+ }
96
+ /** Validate every attachment before anything is created upstream, so a bad one never leaves a draft behind. */
97
+ export async function prepareAttachments(items, config) {
98
+ const prepared = [];
99
+ for (const a of items ?? []) {
100
+ const sha256 = a.sha256?.toLowerCase();
101
+ const base = { filename: a.filename, ...(sha256 ? { sha256 } : {}) };
102
+ if (typeof a.path === "string") {
103
+ if (!config.uploadDir)
104
+ throw new Error(`attachment ${a.filename}: path needs an upload folder (FMSG_MCP_UPLOAD_DIR, stdio only)`);
105
+ prepared.push({ ...base, contentType: a.content_type ?? attachmentType(a.filename), file: await uploadFile(config.uploadDir, a.filename, a.path) });
106
+ continue;
107
+ }
108
+ const text = a.data_text !== undefined;
109
+ const data = text ? Buffer.from(a.data_text, "utf8") : decodeBase64Strict(a.filename, a.data_base64 ?? "");
110
+ if (data.byteLength === 0)
111
+ throw new Error(`attachment ${a.filename} is empty`);
112
+ checksum(a.filename, sha256, data);
113
+ const inferred = attachmentType(a.filename);
114
+ const contentType = a.content_type ?? (text && inferred === "application/octet-stream" ? "text/plain; charset=utf-8" : inferred);
115
+ prepared.push({ ...base, contentType, data: new Uint8Array(data) });
116
+ }
117
+ return prepared;
118
+ }
119
+ /** Upload prepared attachments into a draft; files stream from disk and are checked against their sha256. */
120
+ export async function uploadPrepared(client, draftId, prepared, signal) {
121
+ const uploaded = [];
122
+ for (const a of prepared) {
123
+ if ("data" in a) {
124
+ uploaded.push(await client.uploadAttachment(draftId, { filename: a.filename, data: a.data, contentType: a.contentType }, signal));
125
+ }
126
+ else {
127
+ const stream = Readable.toWeb(createReadStream(a.file));
128
+ const { filename, size } = await client.uploadAttachmentStream(draftId, {
129
+ filename: a.filename, stream, contentType: a.contentType, ...(a.sha256 ? { expectedSha256: a.sha256 } : {}),
130
+ }, signal);
131
+ uploaded.push({ filename, size });
132
+ }
133
+ }
134
+ return uploaded;
135
+ }
136
+ /** Create a draft and upload its attachments, deleting the draft if any upload fails. */
137
+ export async function draftWithAttachments(client, input, prepared, signal) {
138
+ const draft = await client.createDraft({ ...input, signal });
139
+ try {
140
+ return { ...draft, attachments: await uploadPrepared(client, draft.id, prepared, signal) };
141
+ }
142
+ catch (error) {
143
+ await client.deleteMessage(draft.id).catch(() => undefined);
144
+ throw error;
145
+ }
146
+ }
147
+ /** Draft, attach and send in one step; the draft is deleted if any step fails. */
148
+ export async function sendWithAttachments(client, input, prepared, signal) {
149
+ const draft = await draftWithAttachments(client, input, prepared, signal);
150
+ try {
151
+ return { ...draft, ...(await client.sendDraft(draft.id, signal)) };
152
+ }
153
+ catch (error) {
154
+ await client.deleteMessage(draft.id).catch(() => undefined);
155
+ throw error;
156
+ }
157
+ }
@@ -0,0 +1,2 @@
1
+ import { type Register } from "./common.js";
2
+ export declare const registerDraftTools: Register;