@markmnl/fmsg-mcp 1.0.0 → 1.1.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 +35 -0
- package/README.md +35 -4
- package/dist/client/client.d.ts +38 -7
- package/dist/client/client.js +185 -30
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/types.d.ts +21 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +117 -23
- package/dist/instructions.d.ts +3 -0
- package/dist/instructions.js +11 -2
- package/dist/oauth/errors.d.ts +1 -0
- package/dist/oauth/errors.js +1 -0
- package/dist/oauth/scopes.js +1 -0
- package/dist/prompts.d.ts +2 -1
- package/dist/prompts.js +47 -1
- package/dist/server.js +5 -1
- package/dist/tools/attachments.d.ts +66 -0
- package/dist/tools/attachments.js +165 -0
- package/dist/tools/draft.d.ts +2 -0
- package/dist/tools/draft.js +151 -0
- package/dist/tools/read.js +6 -2
- package/dist/tools/send.d.ts +85 -1
- package/dist/tools/send.js +69 -60
- package/dist/upload.d.ts +28 -0
- package/dist/upload.js +95 -0
- package/docs/http-deployment.md +36 -3
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { downloadBaseUrl } from "../download.js";
|
|
3
|
+
import { toolError } from "../errors.js";
|
|
4
|
+
import { addressList, headerValue, isoTime } from "../render.js";
|
|
5
|
+
import { CHECKSUM_HEADER, UPLOAD_ENCODINGS, draftUploadUrlTemplate } from "../upload.js";
|
|
6
|
+
import { attachmentInput, base64Hint, draftWithAttachments, fileRoutes, prepareAttachments } from "./attachments.js";
|
|
7
|
+
import { SENDS, attachmentItem, idSchema, ok, openEnum, outputObject, resolverFor, withCaller } from "./common.js";
|
|
8
|
+
import { rootTopic } from "./read.js";
|
|
9
|
+
import { IMMUTABLE, IMPORTANT, NO_REPLY, UPLOAD_STEPS, hintOutput, redactionNote, replyRecipients, resolveRecipients, resolvedNote, resolvedOutput, sendWarnings, sentAttachments, sentOutput, withHint, } from "./send.js";
|
|
10
|
+
export const registerDraftTools = (server, deps) => {
|
|
11
|
+
const baseUrl = downloadBaseUrl(deps.config);
|
|
12
|
+
const routes = fileRoutes(deps.config);
|
|
13
|
+
server.registerTool("create_draft", {
|
|
14
|
+
title: "Create fmsg draft",
|
|
15
|
+
description: "Create an unsent draft: a new message, or a reply when reply_to is given (reply rules and reply-all apply). " +
|
|
16
|
+
"Nothing is sent until send_draft; delete_draft discards it. The draft is visible in the user's other fmsg clients. " +
|
|
17
|
+
(routes.uploadUrl ? `Use it for large files: ${UPLOAD_STEPS}. ` : "") +
|
|
18
|
+
"Secrets in the body and topic are redacted.",
|
|
19
|
+
inputSchema: z.strictObject({
|
|
20
|
+
to: z.array(z.string()).min(1).optional().describe("recipient addresses (@user@domain) or short names; for a reply, overrides reply-all"),
|
|
21
|
+
topic: z.string().max(256).optional().describe("thread topic for a new message; not allowed with reply_to"),
|
|
22
|
+
reply_to: idSchema.optional().describe("message this draft replies to"),
|
|
23
|
+
body: z.string().describe("message body; Markdown unless type says otherwise"),
|
|
24
|
+
type: z.string().default("text/markdown; charset=utf-8").describe("body media type"),
|
|
25
|
+
important: z.boolean().default(false).describe(IMPORTANT),
|
|
26
|
+
no_reply: z.boolean().default(false).describe(NO_REPLY),
|
|
27
|
+
allow_no_reply: z.boolean().default(false).describe("draft a reply even though the parent asked for no replies"),
|
|
28
|
+
attachments: z.array(attachmentInput(deps.config)).optional().describe("files to attach now"),
|
|
29
|
+
}),
|
|
30
|
+
outputSchema: outputObject({
|
|
31
|
+
draft_id: z.string(),
|
|
32
|
+
to: z.array(z.string()),
|
|
33
|
+
topic: z.string(),
|
|
34
|
+
parent_id: z.string().nullable(),
|
|
35
|
+
attachments: z.array(attachmentItem).describe("sorted by filename, the same order in every tool"),
|
|
36
|
+
redactions: z.number().describe("secrets replaced with placeholders in the draft"),
|
|
37
|
+
redacted: z.array(z.string()).describe("the kinds of secret replaced; empty when none"),
|
|
38
|
+
resolved: resolvedOutput,
|
|
39
|
+
warnings: z.array(z.string()).describe("things to tell the user, such as redacted secrets or resolved short names"),
|
|
40
|
+
upload: outputObject({
|
|
41
|
+
url_template: z.string().describe("replace {filename} with the attachment's file name (letters, digits, dot, underscore, hyphen)"),
|
|
42
|
+
method: openEnum(["PUT"]),
|
|
43
|
+
authentication: openEnum(["bearer"], "bearer: send this MCP connection's Authorization header"),
|
|
44
|
+
checksum_header: z.string().describe("optional request header carrying the file's hex sha256; a mismatch is refused and the file not kept"),
|
|
45
|
+
accepts_encoding: z.array(z.string()).describe("Content-Encoding values accepted; the server decodes them before storing"),
|
|
46
|
+
}).optional().describe("where to upload more attachments; present when this server has a public URL"),
|
|
47
|
+
next: z.string(),
|
|
48
|
+
hint: hintOutput,
|
|
49
|
+
}),
|
|
50
|
+
annotations: { ...SENDS, destructiveHint: false },
|
|
51
|
+
}, async ({ to, topic, reply_to, body, type, important, no_reply, allow_no_reply, attachments }, ctx) => {
|
|
52
|
+
if (reply_to && topic)
|
|
53
|
+
return toolError("a reply has no topic: pass topic only for a new message");
|
|
54
|
+
if (!reply_to && !to?.length)
|
|
55
|
+
return toolError("pass to: the recipients (or reply_to to draft a reply)");
|
|
56
|
+
return withCaller(deps, ctx, async (caller, signal) => {
|
|
57
|
+
let recipients;
|
|
58
|
+
let resolved;
|
|
59
|
+
let pid;
|
|
60
|
+
if (reply_to) {
|
|
61
|
+
const parent = await caller.client.getMessage(reply_to, signal);
|
|
62
|
+
const target = replyRecipients(deps, caller, parent, to, allow_no_reply);
|
|
63
|
+
if ("content" in target)
|
|
64
|
+
return target;
|
|
65
|
+
({ to: recipients, resolved } = target);
|
|
66
|
+
pid = parent.id;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
({ to: recipients, resolved } = resolveRecipients(to, resolverFor(deps, caller)));
|
|
70
|
+
}
|
|
71
|
+
const outbound = await prepareAttachments(attachments, deps.config);
|
|
72
|
+
const hint = base64Hint(attachments, routes);
|
|
73
|
+
const draft = await draftWithAttachments(caller.client, {
|
|
74
|
+
to: recipients, body, type, important, noReply: no_reply, ...(pid ? { pid } : { topic: topic ?? "" }),
|
|
75
|
+
}, outbound, signal);
|
|
76
|
+
const upload = baseUrl ? {
|
|
77
|
+
url_template: draftUploadUrlTemplate(baseUrl, draft.id),
|
|
78
|
+
method: "PUT",
|
|
79
|
+
authentication: "bearer",
|
|
80
|
+
checksum_header: CHECKSUM_HEADER,
|
|
81
|
+
accepts_encoding: [...UPLOAD_ENCODINGS],
|
|
82
|
+
} : undefined;
|
|
83
|
+
const next = (upload ? `Upload files with PUT to ${upload.url_template} using this MCP connection's Authorization header, then call send_draft. ` : "Call send_draft to send it. ") +
|
|
84
|
+
"Call delete_draft to discard it.";
|
|
85
|
+
const structured = {
|
|
86
|
+
draft_id: draft.id,
|
|
87
|
+
to: recipients,
|
|
88
|
+
topic: draft.topic,
|
|
89
|
+
parent_id: pid ?? null,
|
|
90
|
+
attachments: sentAttachments(draft.attachments, outbound),
|
|
91
|
+
redactions: draft.redactions,
|
|
92
|
+
redacted: draft.redacted,
|
|
93
|
+
...(resolved.length ? { resolved } : {}),
|
|
94
|
+
warnings: sendWarnings(draft.redactions, draft.redacted, resolved),
|
|
95
|
+
...(upload ? { upload } : {}),
|
|
96
|
+
next,
|
|
97
|
+
...(hint ? { hint } : {}),
|
|
98
|
+
};
|
|
99
|
+
const what = pid ? `reply to message ${pid}` : `"${headerValue(draft.topic)}"`;
|
|
100
|
+
const text = `Created draft ${draft.id} (${what}) for ${addressList(recipients)}; not sent` +
|
|
101
|
+
(structured.attachments.length ? `, with ${structured.attachments.map((a) => a.filename).join(", ")}` : "") +
|
|
102
|
+
redactionNote(draft.redactions, draft.redacted) + resolvedNote(resolved) + `\n\n${next}`;
|
|
103
|
+
return ok(withHint(text, hint), structured);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
server.registerTool("send_draft", {
|
|
107
|
+
title: "Send fmsg draft",
|
|
108
|
+
description: `Send a draft made with create_draft, with its attachments, immediately. ${IMMUTABLE} ` +
|
|
109
|
+
"If the host refuses it, its reason is returned verbatim and the message stays a draft.",
|
|
110
|
+
inputSchema: z.strictObject({ id: idSchema.describe("the draft to send") }),
|
|
111
|
+
outputSchema: sentOutput,
|
|
112
|
+
annotations: SENDS,
|
|
113
|
+
}, async ({ id }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
114
|
+
const draft = await caller.client.getMessage(id, signal);
|
|
115
|
+
if (draft.time !== null && draft.time !== undefined)
|
|
116
|
+
return toolError(`message ${id} was already sent; send_draft sends only drafts`);
|
|
117
|
+
const sent = await caller.client.sendDraft(draft.id, signal);
|
|
118
|
+
const topic = draft.pid ? "" : (draft.topic ?? "");
|
|
119
|
+
const structured = {
|
|
120
|
+
id: sent.id,
|
|
121
|
+
time: isoTime(sent.time),
|
|
122
|
+
from: caller.address,
|
|
123
|
+
to: draft.to,
|
|
124
|
+
topic,
|
|
125
|
+
parent_id: draft.pid ?? null,
|
|
126
|
+
attachments: sentAttachments(draft.attachments ?? []),
|
|
127
|
+
redactions: 0,
|
|
128
|
+
redacted: [],
|
|
129
|
+
warnings: [],
|
|
130
|
+
// The message is already sent: a failed topic lookup only leaves the topic unknown.
|
|
131
|
+
thread_topic: draft.pid ? await rootTopic(caller.client, { ...draft, id: sent.id }, signal).catch(() => null) : topic,
|
|
132
|
+
};
|
|
133
|
+
const where = draft.pid ? `reply to message ${draft.pid}` : `"${headerValue(topic)}"`;
|
|
134
|
+
const text = `Sent draft ${sent.id} (${where}) to ${addressList(draft.to)} at ${structured.time ?? "?"}` +
|
|
135
|
+
(structured.attachments.length ? ` with ${structured.attachments.map((a) => a.filename).join(", ")}` : "") + ".";
|
|
136
|
+
return ok(text, structured);
|
|
137
|
+
}));
|
|
138
|
+
server.registerTool("delete_draft", {
|
|
139
|
+
title: "Delete fmsg draft",
|
|
140
|
+
description: "Delete an unsent draft and its attachments. Sent messages cannot be deleted.",
|
|
141
|
+
inputSchema: z.strictObject({ id: idSchema.describe("the draft to delete") }),
|
|
142
|
+
outputSchema: outputObject({ id: z.string(), deleted: z.boolean() }),
|
|
143
|
+
annotations: { ...SENDS, idempotentHint: true },
|
|
144
|
+
}, async ({ id }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
145
|
+
const draft = await caller.client.getMessage(id, signal);
|
|
146
|
+
if (draft.time !== null && draft.time !== undefined)
|
|
147
|
+
return toolError(`message ${id} was already sent and cannot be deleted`);
|
|
148
|
+
await caller.client.deleteMessage(draft.id, signal);
|
|
149
|
+
return ok(`Deleted draft ${draft.id}.`, { id: draft.id, deleted: true });
|
|
150
|
+
}));
|
|
151
|
+
};
|
package/dist/tools/read.js
CHANGED
|
@@ -202,6 +202,7 @@ export const registerReadTools = (server, deps) => {
|
|
|
202
202
|
size: z.number(),
|
|
203
203
|
content_type: z.string().describe("media type; the same value as type"),
|
|
204
204
|
type: z.string().optional().describe("media type, named as on message attachment lists"),
|
|
205
|
+
text: z.string().optional().describe("the file's contents, for text attachments"),
|
|
205
206
|
...untrustedNotice,
|
|
206
207
|
}),
|
|
207
208
|
annotations: READ_ONLY,
|
|
@@ -223,8 +224,11 @@ export const registerReadTools = (server, deps) => {
|
|
|
223
224
|
const type = contentType ?? "application/octet-stream";
|
|
224
225
|
const base = { id, filename, size: data.byteLength, content_type: type, type, ...UNTRUSTED };
|
|
225
226
|
const metadata = `${filename} (${data.byteLength} bytes, ${type}) from message ${id}`;
|
|
226
|
-
if (type.toLowerCase().startsWith("text/"))
|
|
227
|
-
|
|
227
|
+
if (type.toLowerCase().startsWith("text/")) {
|
|
228
|
+
// Some hosts show models only structured content, so the text is there too.
|
|
229
|
+
const text = Buffer.from(data).toString("utf8");
|
|
230
|
+
return ok(messageData(`${metadata}\n\n${text}`), { ...base, text });
|
|
231
|
+
}
|
|
228
232
|
const b64 = Buffer.from(data).toString("base64");
|
|
229
233
|
const uri = `fmsg://message/${id}/attachment/${encodeURIComponent(filename)}`;
|
|
230
234
|
const result = {
|
package/dist/tools/send.d.ts
CHANGED
|
@@ -1,2 +1,86 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { CallToolResult } from "@modelcontextprotocol/server";
|
|
2
|
+
import * as z from "zod/v4";
|
|
3
|
+
import { type AddressResolver } from "../address.js";
|
|
4
|
+
import type { FmsgMessage } from "../client/types.js";
|
|
5
|
+
import type { Config } from "../config.js";
|
|
6
|
+
import type { Caller } from "../context.js";
|
|
7
|
+
import { type PreparedAttachment } from "./attachments.js";
|
|
8
|
+
import { type Register, type ToolDeps } from "./common.js";
|
|
9
|
+
export declare const IMMUTABLE = "fmsg messages are immutable: once sent they cannot be edited or recalled. Send within the user's requested task or authorized automation.";
|
|
10
|
+
export declare const IMPORTANT = "mark as important for recipients";
|
|
11
|
+
export declare const NO_REPLY = "ask recipients (and their agents) not to reply";
|
|
12
|
+
/** The upload-URL route, stated in full wherever a model may read about it. */
|
|
13
|
+
export declare const UPLOAD_STEPS: string;
|
|
14
|
+
/** How to send files without the model writing their bytes, naming only what this server registered. */
|
|
15
|
+
export declare function attachmentGuidance(config: Config): string;
|
|
16
|
+
/**
|
|
17
|
+
* Resolve recipients, dropping duplicates, and report each name that was not already a full address, so the model
|
|
18
|
+
* can confirm a guessed address with the user.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveRecipients(names: string[], resolver: AddressResolver): {
|
|
21
|
+
to: string[];
|
|
22
|
+
resolved: Array<{
|
|
23
|
+
input: string;
|
|
24
|
+
address: string;
|
|
25
|
+
}>;
|
|
26
|
+
};
|
|
27
|
+
export declare const resolvedOutput: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
28
|
+
input: z.ZodOptional<z.ZodString>;
|
|
29
|
+
address: z.ZodOptional<z.ZodString>;
|
|
30
|
+
}, z.core.$loose>>>;
|
|
31
|
+
/** Warnings for the model to pass on: what was redacted, and short names that were expanded. */
|
|
32
|
+
export declare function sendWarnings(redactions: number, kinds: string[], resolved: Array<{
|
|
33
|
+
input: string;
|
|
34
|
+
address: string;
|
|
35
|
+
}>): string[];
|
|
36
|
+
/** Sent attachments with the type each was given, or the one its filename implies, in the order every tool lists them. */
|
|
37
|
+
export declare function sentAttachments(sent: Array<{
|
|
38
|
+
filename: string;
|
|
39
|
+
size: number;
|
|
40
|
+
}>, outbound?: Array<Pick<PreparedAttachment, "contentType">>): {
|
|
41
|
+
type: string;
|
|
42
|
+
filename: string;
|
|
43
|
+
size: number;
|
|
44
|
+
}[];
|
|
45
|
+
/**
|
|
46
|
+
* Who a reply goes to: the given recipients, or everyone on the parent except the caller. Returns an error result
|
|
47
|
+
* when the parent is terminal, asked for no replies (unless allowed) or has nobody else to reply to.
|
|
48
|
+
*/
|
|
49
|
+
export declare function replyRecipients(deps: ToolDeps, caller: Caller, parent: FmsgMessage, recipients: string[] | undefined, allowNoReply: boolean): CallToolResult | {
|
|
50
|
+
to: string[];
|
|
51
|
+
resolved: Array<{
|
|
52
|
+
input: string;
|
|
53
|
+
address: string;
|
|
54
|
+
}>;
|
|
55
|
+
};
|
|
56
|
+
export declare const hintOutput: z.ZodOptional<z.ZodString>;
|
|
57
|
+
export declare const sentOutput: z.ZodObject<{
|
|
58
|
+
id: z.ZodString;
|
|
59
|
+
time: z.ZodNullable<z.ZodString>;
|
|
60
|
+
from: z.ZodString;
|
|
61
|
+
to: z.ZodArray<z.ZodString>;
|
|
62
|
+
topic: z.ZodString;
|
|
63
|
+
parent_id: z.ZodNullable<z.ZodString>;
|
|
64
|
+
attachments: z.ZodArray<z.ZodObject<{
|
|
65
|
+
filename: z.ZodString;
|
|
66
|
+
size: z.ZodNumber;
|
|
67
|
+
type: z.ZodOptional<z.ZodString>;
|
|
68
|
+
}, z.core.$loose>>;
|
|
69
|
+
redactions: z.ZodNumber;
|
|
70
|
+
redacted: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
71
|
+
resolved: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
72
|
+
input: z.ZodOptional<z.ZodString>;
|
|
73
|
+
address: z.ZodOptional<z.ZodString>;
|
|
74
|
+
}, z.core.$loose>>>;
|
|
75
|
+
warnings: z.ZodArray<z.ZodString>;
|
|
76
|
+
thread_topic: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
77
|
+
hint: z.ZodOptional<z.ZodString>;
|
|
78
|
+
}, z.core.$loose>;
|
|
79
|
+
/** The result text with a hint for the model appended, when there is one. */
|
|
80
|
+
export declare function withHint(text: string, hint: string | undefined): string;
|
|
81
|
+
export declare function redactionNote(count: number, kinds: string[]): string;
|
|
82
|
+
export declare function resolvedNote(resolved: Array<{
|
|
83
|
+
input: string;
|
|
84
|
+
address: string;
|
|
85
|
+
}>): string;
|
|
2
86
|
export declare const registerSendTools: Register;
|
package/dist/tools/send.js
CHANGED
|
@@ -2,21 +2,31 @@ import * as z from "zod/v4";
|
|
|
2
2
|
import { resolveAddress, sameAddress } from "../address.js";
|
|
3
3
|
import { toolError } from "../errors.js";
|
|
4
4
|
import { addressList, addressText, attachmentType, headerValue, isoTime, participantsOf, sortAttachments } from "../render.js";
|
|
5
|
+
import { attachmentInput, base64Hint, fileRoutes, prepareAttachments, sendWithAttachments } from "./attachments.js";
|
|
5
6
|
import { SENDS, attachmentItem, idSchema, ok, outputObject, resolverFor, withCaller } from "./common.js";
|
|
6
7
|
import { rootTopic } from "./read.js";
|
|
7
|
-
const IMMUTABLE = "fmsg messages are immutable: once sent they cannot be edited or recalled. Send within the user's requested task or authorized automation.";
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
export const IMMUTABLE = "fmsg messages are immutable: once sent they cannot be edited or recalled. Send within the user's requested task or authorized automation.";
|
|
9
|
+
export const IMPORTANT = "mark as important for recipients";
|
|
10
|
+
export const NO_REPLY = "ask recipients (and their agents) not to reply";
|
|
11
|
+
/** The upload-URL route, stated in full wherever a model may read about it. */
|
|
12
|
+
export const UPLOAD_STEPS = "PUT each file's raw bytes to the draft's upload URL with this connection's Authorization header, then call " +
|
|
13
|
+
"send_draft. If your host cannot send this connection's credentials, use data_base64; never look for or copy credentials";
|
|
14
|
+
/** How to send files without the model writing their bytes, naming only what this server registered. */
|
|
15
|
+
export function attachmentGuidance(config) {
|
|
16
|
+
const routes = fileRoutes(config);
|
|
17
|
+
const large = [
|
|
18
|
+
...(routes.uploadUrl ? [`use create_draft instead: ${UPLOAD_STEPS}`] : []),
|
|
19
|
+
...(routes.uploadFolder ? ["give path, a file in the upload folder"] : []),
|
|
20
|
+
];
|
|
21
|
+
return "Files: data_text for text, data_base64 for small files; add sha256 when you can. " +
|
|
22
|
+
(large.length ? `For large files, ${large.join(". Or ")}. ` : "") +
|
|
23
|
+
"Send files as they are; fmsg compresses them.";
|
|
24
|
+
}
|
|
15
25
|
/**
|
|
16
26
|
* Resolve recipients, dropping duplicates, and report each name that was not already a full address, so the model
|
|
17
27
|
* can confirm a guessed address with the user.
|
|
18
28
|
*/
|
|
19
|
-
function resolveRecipients(names, resolver) {
|
|
29
|
+
export function resolveRecipients(names, resolver) {
|
|
20
30
|
const to = [];
|
|
21
31
|
const resolved = [];
|
|
22
32
|
for (const name of names) {
|
|
@@ -28,9 +38,9 @@ function resolveRecipients(names, resolver) {
|
|
|
28
38
|
}
|
|
29
39
|
return { to, resolved };
|
|
30
40
|
}
|
|
31
|
-
const resolvedOutput = z.array(outputObject({ input: z.string().optional(), address: z.string().optional() })).optional().describe("short names and how they resolved, when any were given; confirm with the user if an address looks wrong");
|
|
41
|
+
export const resolvedOutput = z.array(outputObject({ input: z.string().optional(), address: z.string().optional() })).optional().describe("short names and how they resolved, when any were given; confirm with the user if an address looks wrong");
|
|
32
42
|
/** Warnings for the model to pass on: what was redacted, and short names that were expanded. */
|
|
33
|
-
function sendWarnings(redactions, kinds, resolved) {
|
|
43
|
+
export function sendWarnings(redactions, kinds, resolved) {
|
|
34
44
|
const warnings = [];
|
|
35
45
|
if (redactions)
|
|
36
46
|
warnings.push(`Tell the user ${redactions} secret(s) (${kinds.join(", ")}) were replaced with placeholders before sending.`);
|
|
@@ -39,18 +49,29 @@ function sendWarnings(redactions, kinds, resolved) {
|
|
|
39
49
|
return warnings;
|
|
40
50
|
}
|
|
41
51
|
/** Sent attachments with the type each was given, or the one its filename implies, in the order every tool lists them. */
|
|
42
|
-
function sentAttachments(sent, outbound) {
|
|
52
|
+
export function sentAttachments(sent, outbound = []) {
|
|
43
53
|
return sortAttachments(sent.map((a, i) => ({ ...a, type: attachmentType(a.filename, outbound[i]?.contentType) })));
|
|
44
54
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Who a reply goes to: the given recipients, or everyone on the parent except the caller. Returns an error result
|
|
57
|
+
* when the parent is terminal, asked for no replies (unless allowed) or has nobody else to reply to.
|
|
58
|
+
*/
|
|
59
|
+
export function replyRecipients(deps, caller, parent, recipients, allowNoReply) {
|
|
60
|
+
const id = parent.id;
|
|
61
|
+
if (parent.terminal)
|
|
62
|
+
return toolError(`message ${id} is terminal and cannot be replied to`);
|
|
63
|
+
if (parent.no_reply && !allowNoReply) {
|
|
64
|
+
return toolError(`message ${id} is marked no-reply; pass allow_no_reply: true only if the user explicitly wants to reply anyway`);
|
|
65
|
+
}
|
|
66
|
+
const result = recipients?.length
|
|
67
|
+
? resolveRecipients(recipients, resolverFor(deps, caller))
|
|
68
|
+
: { to: participantsOf(parent).filter((a) => !sameAddress(a, caller.address)), resolved: [] };
|
|
69
|
+
if (result.to.length === 0)
|
|
70
|
+
return toolError(`message ${id} has no other participants to reply to; pass recipients`);
|
|
71
|
+
return result;
|
|
52
72
|
}
|
|
53
|
-
const
|
|
73
|
+
export const hintOutput = z.string().optional().describe("for you, not the user: a better way to do this next time");
|
|
74
|
+
export const sentOutput = outputObject({
|
|
54
75
|
id: z.string(),
|
|
55
76
|
time: z.string().nullable(),
|
|
56
77
|
from: z.string(),
|
|
@@ -63,20 +84,28 @@ const sentOutput = outputObject({
|
|
|
63
84
|
resolved: resolvedOutput,
|
|
64
85
|
warnings: z.array(z.string()).describe("things to tell the user, such as redacted secrets or resolved short names"),
|
|
65
86
|
thread_topic: z.string().nullable().optional().describe("the thread's topic: the topic sent for a new message; for a reply, the thread root's topic, or null when the root is not visible"),
|
|
87
|
+
hint: hintOutput,
|
|
66
88
|
});
|
|
67
|
-
|
|
89
|
+
/** The result text with a hint for the model appended, when there is one. */
|
|
90
|
+
export function withHint(text, hint) {
|
|
91
|
+
return hint ? `${text}\n\n${hint}` : text;
|
|
92
|
+
}
|
|
93
|
+
export function redactionNote(count, kinds) {
|
|
68
94
|
return count ? `. ${count} secret(s) were redacted before sending (${kinds.join(", ")}).` : ".";
|
|
69
95
|
}
|
|
70
|
-
function resolvedNote(resolved) {
|
|
96
|
+
export function resolvedNote(resolved) {
|
|
71
97
|
return resolved.length ? ` Short names resolved: ${resolved.map((r) => `${r.input} → ${addressText(r.address)}`).join(", ")}.` : "";
|
|
72
98
|
}
|
|
73
99
|
export const registerSendTools = (server, deps) => {
|
|
100
|
+
const attachments = z.array(attachmentInput(deps.config)).optional().describe("files");
|
|
101
|
+
const guidance = attachmentGuidance(deps.config);
|
|
102
|
+
const routes = fileRoutes(deps.config);
|
|
74
103
|
server.registerTool("send_message", {
|
|
75
104
|
title: "Send new fmsg message",
|
|
76
105
|
description: `Send a new message immediately, starting a new thread. ${IMMUTABLE} ` +
|
|
77
106
|
"Recipients may be full @user@domain addresses or resolvable short names. The body is Markdown by default. " +
|
|
78
107
|
"Secrets are redacted. If the host rejects the message its reason is returned verbatim. " +
|
|
79
|
-
|
|
108
|
+
`To continue an existing conversation use reply instead. ${guidance}`,
|
|
80
109
|
inputSchema: z.strictObject({
|
|
81
110
|
to: z.array(z.string()).min(1).describe("recipient addresses (@user@domain) or short names"),
|
|
82
111
|
topic: z.string().max(256).describe("thread topic (subject) — immutable once sent"),
|
|
@@ -84,23 +113,15 @@ export const registerSendTools = (server, deps) => {
|
|
|
84
113
|
type: z.string().default("text/markdown; charset=utf-8").describe("body media type"),
|
|
85
114
|
important: z.boolean().default(false).describe(IMPORTANT),
|
|
86
115
|
no_reply: z.boolean().default(false).describe(NO_REPLY),
|
|
87
|
-
attachments
|
|
116
|
+
attachments,
|
|
88
117
|
}),
|
|
89
118
|
outputSchema: sentOutput,
|
|
90
119
|
annotations: SENDS,
|
|
91
120
|
}, async ({ to, topic, body, type, important, no_reply, attachments }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
92
121
|
const { to: recipients, resolved } = resolveRecipients(to, resolverFor(deps, caller));
|
|
93
|
-
const outbound =
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
topic,
|
|
97
|
-
body,
|
|
98
|
-
type,
|
|
99
|
-
important,
|
|
100
|
-
noReply: no_reply,
|
|
101
|
-
attachments: outbound,
|
|
102
|
-
signal,
|
|
103
|
-
});
|
|
122
|
+
const outbound = await prepareAttachments(attachments, deps.config);
|
|
123
|
+
const hint = base64Hint(attachments, routes);
|
|
124
|
+
const sent = await sendWithAttachments(caller.client, { to: recipients, topic, body, type, important, noReply: no_reply }, outbound, signal);
|
|
104
125
|
const structured = {
|
|
105
126
|
id: sent.id,
|
|
106
127
|
time: isoTime(sent.time),
|
|
@@ -114,18 +135,19 @@ export const registerSendTools = (server, deps) => {
|
|
|
114
135
|
...(resolved.length ? { resolved } : {}),
|
|
115
136
|
warnings: sendWarnings(sent.redactions, sent.redacted, resolved),
|
|
116
137
|
thread_topic: sent.topic,
|
|
138
|
+
...(hint ? { hint } : {}),
|
|
117
139
|
};
|
|
118
140
|
const text = `Sent message ${sent.id} "${sent.topic}" to ${addressList(recipients)} at ${structured.time ?? "?"}` +
|
|
119
141
|
(structured.attachments.length ? ` with ${structured.attachments.map((a) => a.filename).join(", ")}` : "") +
|
|
120
142
|
redactionNote(structured.redactions, structured.redacted) + resolvedNote(resolved);
|
|
121
|
-
return ok(text, structured);
|
|
143
|
+
return ok(withHint(text, hint), structured);
|
|
122
144
|
}));
|
|
123
145
|
server.registerTool("reply", {
|
|
124
146
|
title: "Reply in fmsg thread",
|
|
125
147
|
description: `Send an immediate reply to a message (linking it into that thread). ${IMMUTABLE} ` +
|
|
126
148
|
"By default the reply goes to everyone on the parent message — its sender, recipients and anyone added later — " +
|
|
127
149
|
"except you; pass recipients to narrow or widen that. Fails if the parent is terminal (no replies possible); " +
|
|
128
|
-
|
|
150
|
+
`a parent marked no-reply is refused unless allow_no_reply is true. Secrets are redacted. ${guidance}`,
|
|
129
151
|
inputSchema: z.strictObject({
|
|
130
152
|
id: idSchema.describe("message to reply to"),
|
|
131
153
|
body: z.string().describe("reply body; Markdown unless type says otherwise"),
|
|
@@ -134,33 +156,19 @@ export const registerSendTools = (server, deps) => {
|
|
|
134
156
|
important: z.boolean().default(false).describe(IMPORTANT),
|
|
135
157
|
no_reply: z.boolean().default(false).describe(NO_REPLY),
|
|
136
158
|
allow_no_reply: z.boolean().default(false).describe("reply even though the parent asked for no replies"),
|
|
137
|
-
attachments
|
|
159
|
+
attachments,
|
|
138
160
|
}),
|
|
139
161
|
outputSchema: sentOutput,
|
|
140
162
|
annotations: SENDS,
|
|
141
163
|
}, async ({ id, body, recipients, type, important, no_reply, allow_no_reply, attachments }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
142
164
|
const parent = await caller.client.getMessage(id, signal);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
: { to: participantsOf(parent).filter((a) => !sameAddress(a, caller.address)), resolved: [] };
|
|
151
|
-
if (to.length === 0)
|
|
152
|
-
return toolError(`message ${id} has no other participants to reply to; pass recipients`);
|
|
153
|
-
const outbound = decodeAttachments(attachments);
|
|
154
|
-
const sent = await caller.client.send({
|
|
155
|
-
to,
|
|
156
|
-
pid: parent.id,
|
|
157
|
-
body,
|
|
158
|
-
type,
|
|
159
|
-
important,
|
|
160
|
-
noReply: no_reply,
|
|
161
|
-
attachments: outbound,
|
|
162
|
-
signal,
|
|
163
|
-
});
|
|
165
|
+
const target = replyRecipients(deps, caller, parent, recipients, allow_no_reply);
|
|
166
|
+
if ("content" in target)
|
|
167
|
+
return target;
|
|
168
|
+
const { to, resolved } = target;
|
|
169
|
+
const outbound = await prepareAttachments(attachments, deps.config);
|
|
170
|
+
const hint = base64Hint(attachments, routes);
|
|
171
|
+
const sent = await sendWithAttachments(caller.client, { to, pid: parent.id, body, type, important, noReply: no_reply }, outbound, signal);
|
|
164
172
|
const structured = {
|
|
165
173
|
id: sent.id,
|
|
166
174
|
time: isoTime(sent.time),
|
|
@@ -175,11 +183,12 @@ export const registerSendTools = (server, deps) => {
|
|
|
175
183
|
warnings: sendWarnings(sent.redactions, sent.redacted, resolved),
|
|
176
184
|
// The reply is already sent: a failed topic lookup only leaves the topic unknown.
|
|
177
185
|
thread_topic: await rootTopic(caller.client, parent, signal).catch(() => null),
|
|
186
|
+
...(hint ? { hint } : {}),
|
|
178
187
|
};
|
|
179
188
|
const topic = structured.thread_topic ? ` in "${headerValue(structured.thread_topic)}"` : "";
|
|
180
189
|
const text = `Sent reply ${sent.id} to message ${parent.id}${topic} for ${addressList(to)} at ${structured.time ?? "?"}` +
|
|
181
190
|
redactionNote(structured.redactions, structured.redacted) + resolvedNote(resolved);
|
|
182
|
-
return ok(text, structured);
|
|
191
|
+
return ok(withHint(text, hint), structured);
|
|
183
192
|
}));
|
|
184
193
|
server.registerTool("add_recipients", {
|
|
185
194
|
title: "Add fmsg recipients",
|
package/dist/upload.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare const UPLOAD_PATH = "/mcp/drafts";
|
|
2
|
+
/** Content-Encoding values accepted on uploads; decoded before the bytes reach fmsg. */
|
|
3
|
+
export declare const UPLOAD_ENCODINGS: readonly ["gzip", "deflate"];
|
|
4
|
+
export declare const CHECKSUM_HEADER = "x-content-sha256";
|
|
5
|
+
/** The PUT target for one draft's attachments, with `{filename}` left for the host to fill in. */
|
|
6
|
+
export declare function draftUploadUrlTemplate(baseUrl: string, id: string): string;
|
|
7
|
+
export declare function parseUploadPath(pathname: string): {
|
|
8
|
+
id: string;
|
|
9
|
+
filename: string;
|
|
10
|
+
};
|
|
11
|
+
/** The upload request's own body was unusable (such as invalid compressed data); answered with 400. */
|
|
12
|
+
export declare class UploadBodyError extends Error {
|
|
13
|
+
constructor(message: string);
|
|
14
|
+
}
|
|
15
|
+
/** How long draining a failed upload's remaining body may wait for more data before the connection is closed. */
|
|
16
|
+
export declare const UPLOAD_DRAIN_IDLE_MS = 10000;
|
|
17
|
+
/**
|
|
18
|
+
* The upload body as a stream whose cancellation leaves the request open, plus `drain()`, which discards what the
|
|
19
|
+
* upload did not read so the answer can follow. drain() resolves false if the client goes idle first.
|
|
20
|
+
*/
|
|
21
|
+
export declare function drainableBody(body: ReadableStream<Uint8Array>, idleMs: number): {
|
|
22
|
+
stream: ReadableStream<Uint8Array>;
|
|
23
|
+
drain: () => Promise<boolean>;
|
|
24
|
+
};
|
|
25
|
+
/** Decode a Content-Encoding while streaming, reporting corrupt data as UploadBodyError. */
|
|
26
|
+
export declare function decodedUploadBody(body: ReadableStream<Uint8Array>, encoding: string | undefined): ReadableStream<Uint8Array>;
|
|
27
|
+
/** The upload's Content-Encoding, lowercased; "identity" or none means undecoded. Throws for an unsupported one. */
|
|
28
|
+
export declare function uploadEncoding(header: string | null): string | undefined;
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { normalizeMessageId } from "./client/message-id.js";
|
|
2
|
+
import { FILENAME } from "./tools/attachments.js";
|
|
3
|
+
export const UPLOAD_PATH = "/mcp/drafts";
|
|
4
|
+
/** Content-Encoding values accepted on uploads; decoded before the bytes reach fmsg. */
|
|
5
|
+
export const UPLOAD_ENCODINGS = ["gzip", "deflate"];
|
|
6
|
+
export const CHECKSUM_HEADER = "x-content-sha256";
|
|
7
|
+
/** The PUT target for one draft's attachments, with `{filename}` left for the host to fill in. */
|
|
8
|
+
export function draftUploadUrlTemplate(baseUrl, id) {
|
|
9
|
+
return `${baseUrl.replace(/\/$/u, "")}/drafts/${normalizeMessageId(id)}/attachments/{filename}`;
|
|
10
|
+
}
|
|
11
|
+
export function parseUploadPath(pathname) {
|
|
12
|
+
const parts = pathname.slice(UPLOAD_PATH.length + 1).split("/");
|
|
13
|
+
if (parts.length !== 3 || parts[1] !== "attachments")
|
|
14
|
+
throw new Error("invalid attachment upload path");
|
|
15
|
+
const filename = decodeURIComponent(parts[2]);
|
|
16
|
+
if (!FILENAME.test(filename) || /^\.+$/u.test(filename))
|
|
17
|
+
throw new Error("invalid attachment upload filename");
|
|
18
|
+
return { id: normalizeMessageId(parts[0]), filename };
|
|
19
|
+
}
|
|
20
|
+
/** The upload request's own body was unusable (such as invalid compressed data); answered with 400. */
|
|
21
|
+
export class UploadBodyError extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "UploadBodyError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** How long draining a failed upload's remaining body may wait for more data before the connection is closed. */
|
|
28
|
+
export const UPLOAD_DRAIN_IDLE_MS = 10_000;
|
|
29
|
+
/**
|
|
30
|
+
* The upload body as a stream whose cancellation leaves the request open, plus `drain()`, which discards what the
|
|
31
|
+
* upload did not read so the answer can follow. drain() resolves false if the client goes idle first.
|
|
32
|
+
*/
|
|
33
|
+
export function drainableBody(body, idleMs) {
|
|
34
|
+
const reader = body.getReader();
|
|
35
|
+
let stopped = false;
|
|
36
|
+
const stream = new ReadableStream({
|
|
37
|
+
async pull(controller) {
|
|
38
|
+
const chunk = await reader.read();
|
|
39
|
+
if (stopped)
|
|
40
|
+
return;
|
|
41
|
+
if (chunk.done)
|
|
42
|
+
controller.close();
|
|
43
|
+
else
|
|
44
|
+
controller.enqueue(chunk.value);
|
|
45
|
+
},
|
|
46
|
+
cancel: () => { stopped = true; },
|
|
47
|
+
});
|
|
48
|
+
const drain = async () => {
|
|
49
|
+
stopped = true;
|
|
50
|
+
for (;;) {
|
|
51
|
+
let timer;
|
|
52
|
+
const idle = new Promise((resolve) => { timer = setTimeout(() => resolve("idle"), idleMs); });
|
|
53
|
+
const next = await Promise.race([reader.read().catch(() => ({ done: true })), idle]);
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
if (next === "idle")
|
|
56
|
+
return false;
|
|
57
|
+
if (next.done)
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
return { stream, drain };
|
|
62
|
+
}
|
|
63
|
+
/** Decode a Content-Encoding while streaming, reporting corrupt data as UploadBodyError. */
|
|
64
|
+
export function decodedUploadBody(body, encoding) {
|
|
65
|
+
if (!encoding)
|
|
66
|
+
return body;
|
|
67
|
+
const reader = body.pipeThrough(new DecompressionStream(encoding)).getReader();
|
|
68
|
+
return new ReadableStream({
|
|
69
|
+
async pull(controller) {
|
|
70
|
+
let chunk;
|
|
71
|
+
try {
|
|
72
|
+
chunk = await reader.read();
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const code = error?.code;
|
|
76
|
+
const detail = code === "Z_BUF_ERROR" ? "the data ends early" : error instanceof Error ? error.message || code : String(error);
|
|
77
|
+
throw new UploadBodyError(`the ${encoding} request body is invalid or truncated${detail ? ` (${detail})` : ""}`);
|
|
78
|
+
}
|
|
79
|
+
if (chunk.done)
|
|
80
|
+
controller.close();
|
|
81
|
+
else
|
|
82
|
+
controller.enqueue(chunk.value);
|
|
83
|
+
},
|
|
84
|
+
cancel: (reason) => reader.cancel(reason),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** The upload's Content-Encoding, lowercased; "identity" or none means undecoded. Throws for an unsupported one. */
|
|
88
|
+
export function uploadEncoding(header) {
|
|
89
|
+
const value = header?.trim().toLowerCase();
|
|
90
|
+
if (!value || value === "identity")
|
|
91
|
+
return undefined;
|
|
92
|
+
if (!UPLOAD_ENCODINGS.includes(value))
|
|
93
|
+
throw new Error(`unsupported Content-Encoding ${JSON.stringify(header)}; use ${UPLOAD_ENCODINGS.join(" or ")}, or none`);
|
|
94
|
+
return value;
|
|
95
|
+
}
|