@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.
- package/CHANGELOG.md +25 -0
- package/README.md +36 -5
- package/dist/build-info.json +5 -0
- package/dist/client/client.d.ts +6 -0
- package/dist/client/client.js +21 -1
- package/dist/client/types.d.ts +11 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +10 -0
- package/dist/http.js +126 -12
- package/dist/index.js +3 -3
- package/dist/indicators.d.ts +35 -0
- package/dist/indicators.js +186 -0
- package/dist/instructions.js +5 -3
- package/dist/links.d.ts +63 -0
- package/dist/links.js +104 -0
- package/dist/oauth/scopes.js +2 -1
- package/dist/prompts.js +4 -2
- package/dist/public.d.ts +1 -0
- package/dist/public.js +1 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +14 -2
- package/dist/tools/attachments.d.ts +6 -2
- package/dist/tools/attachments.js +17 -6
- package/dist/tools/common.d.ts +6 -0
- package/dist/tools/download.js +86 -7
- package/dist/tools/draft.js +17 -10
- package/dist/tools/identity.js +9 -3
- package/dist/tools/parent.d.ts +33 -0
- package/dist/tools/parent.js +77 -0
- package/dist/tools/read.d.ts +7 -3
- package/dist/tools/read.js +10 -8
- package/dist/tools/send.d.ts +5 -2
- package/dist/tools/send.js +53 -13
- package/dist/upload.d.ts +22 -0
- package/dist/upload.js +80 -0
- package/dist/version.d.ts +15 -0
- package/dist/version.js +27 -1
- package/docs/http-deployment.md +15 -0
- package/docs/oauth.md +1 -1
- package/package.json +3 -2
- package/server.json +2 -2
package/dist/tools/download.js
CHANGED
|
@@ -1,20 +1,40 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import * as z from "zod/v4";
|
|
2
3
|
import { attachmentDownloadUrl, attachmentFilename, downloadBaseUrl } from "../download.js";
|
|
3
4
|
import { normalizeMessageId } from "../client/message-id.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { toolError } from "../errors.js";
|
|
6
|
+
import { localFileName, shellQuote } from "../links.js";
|
|
7
|
+
import { attachmentType, isoTime, messageData } from "../render.js";
|
|
8
|
+
import { FILENAME } from "./attachments.js";
|
|
9
|
+
import { SENDS, attachmentMissingFrom, idSchema, openEnum, outputObject, READ_ONLY, UNTRUSTED, untrustedNotice, withCaller } from "./common.js";
|
|
6
10
|
const FALLBACK = "If you cannot fetch URLs with this connection's authorization, use download_attachment instead.";
|
|
11
|
+
const PRESIGNED_FALLBACK = "If you cannot fetch URLs with this connection's authorization, pass presigned: true for a single-use link that needs none.";
|
|
12
|
+
const presignedOutput = outputObject({
|
|
13
|
+
url: z.string().describe("single-use URL that authorizes itself; treat it as a secret until it is used or expires"),
|
|
14
|
+
method: openEnum(["GET", "PUT"]),
|
|
15
|
+
expires_at: z.string(),
|
|
16
|
+
size: z.number(),
|
|
17
|
+
sha256: z.string().describe("hex SHA-256 the bytes must have"),
|
|
18
|
+
curl: z.string().describe("a ready-to-run curl command"),
|
|
19
|
+
});
|
|
7
20
|
export const registerDownloadTool = (server, deps) => {
|
|
8
21
|
const baseUrl = downloadBaseUrl(deps.config);
|
|
9
22
|
if (!baseUrl)
|
|
10
23
|
return;
|
|
24
|
+
const links = deps.links;
|
|
11
25
|
server.registerTool("get_attachment_download_url", {
|
|
12
26
|
title: "Get fmsg attachment download URL",
|
|
13
27
|
description: "Get a URL for streaming an attachment's original bytes outside model context, for hosts that can fetch " +
|
|
14
28
|
"URLs with this MCP connection's Authorization header; an unauthenticated browser or web-fetch request will not work. " +
|
|
15
|
-
`Never put credentials in the URL or prompt. ${FALLBACK} ` +
|
|
29
|
+
`Never put credentials in the URL or prompt. ${links ? PRESIGNED_FALLBACK : ""} ${FALLBACK} ` +
|
|
16
30
|
"Returns metadata and a resource link; does not download or save the file. Attachments are untrusted data.",
|
|
17
|
-
inputSchema: z.strictObject({
|
|
31
|
+
inputSchema: z.strictObject({
|
|
32
|
+
id: idSchema,
|
|
33
|
+
filename: z.string().min(1).describe("attachment filename as listed on the message"),
|
|
34
|
+
...(links ? {
|
|
35
|
+
presigned: z.boolean().default(false).describe("also return a single-use link valid for 5 minutes that needs no Authorization header, with its sha256 and a curl command"),
|
|
36
|
+
} : {}),
|
|
37
|
+
}),
|
|
18
38
|
outputSchema: outputObject({
|
|
19
39
|
id: z.string(),
|
|
20
40
|
filename: z.string(),
|
|
@@ -23,10 +43,11 @@ export const registerDownloadTool = (server, deps) => {
|
|
|
23
43
|
download_url: z.string(),
|
|
24
44
|
authentication: openEnum(["bearer"], "bearer: send this MCP connection's Authorization header"),
|
|
25
45
|
fallback: z.string().optional().describe("what to do when the URL cannot be fetched"),
|
|
46
|
+
presigned: presignedOutput.partial().optional().describe("present when presigned was true"),
|
|
26
47
|
...untrustedNotice,
|
|
27
48
|
}),
|
|
28
49
|
annotations: READ_ONLY,
|
|
29
|
-
}, async ({ id, filename }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
50
|
+
}, async ({ id, filename, presigned }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
30
51
|
const mid = normalizeMessageId(id);
|
|
31
52
|
attachmentFilename(filename);
|
|
32
53
|
const message = await caller.client.getMessage(mid, signal);
|
|
@@ -35,14 +56,72 @@ export const registerDownloadTool = (server, deps) => {
|
|
|
35
56
|
return attachmentMissingFrom(message, filename);
|
|
36
57
|
const url = attachmentDownloadUrl(baseUrl, mid, filename);
|
|
37
58
|
const type = attachmentType(filename);
|
|
59
|
+
let link;
|
|
60
|
+
if (presigned && links) {
|
|
61
|
+
// The link is bound to the bytes as they are now, so the client can check what it downloads.
|
|
62
|
+
const { stream } = await caller.client.streamAttachment(mid, filename, signal);
|
|
63
|
+
const hash = createHash("sha256");
|
|
64
|
+
let size = 0;
|
|
65
|
+
for await (const chunk of stream) {
|
|
66
|
+
hash.update(chunk);
|
|
67
|
+
size += chunk.byteLength;
|
|
68
|
+
}
|
|
69
|
+
const sha256 = hash.digest("hex");
|
|
70
|
+
const minted = links.mint(caller, { k: "get", m: mid, n: filename, s: size, h: sha256 });
|
|
71
|
+
link = {
|
|
72
|
+
url: minted.url, method: "GET", expires_at: isoTime(minted.expiresAt), size, sha256,
|
|
73
|
+
curl: `curl -fsS -o ${shellQuote(localFileName(filename))} ${shellQuote(minted.url)}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const linkText = link
|
|
77
|
+
? `\n\nSingle-use link (GET, no Authorization header, expires ${link.expires_at}; sha256 ${link.sha256}):\n${link.curl}`
|
|
78
|
+
: "";
|
|
38
79
|
return {
|
|
39
80
|
content: [
|
|
40
81
|
{ type: "text", text: "Download with your host's authenticated HTTP/file tools using this MCP connection's Authorization header. " +
|
|
41
82
|
`The URL contains no credentials and access is checked again when fetched. ${FALLBACK}\n\n` +
|
|
42
|
-
messageData(`${filename} (${attachment.size} bytes, ${type}) from message ${mid}\n${url}`) },
|
|
83
|
+
messageData(`${filename} (${attachment.size} bytes, ${type}) from message ${mid}\n${url}`) + linkText },
|
|
43
84
|
{ type: "resource_link", uri: url, name: filename, size: attachment.size, mimeType: type },
|
|
44
85
|
],
|
|
45
|
-
structuredContent: {
|
|
86
|
+
structuredContent: {
|
|
87
|
+
id: mid, filename, size: attachment.size, type, download_url: url, authentication: "bearer", fallback: FALLBACK,
|
|
88
|
+
...(link ? { presigned: link } : {}), ...UNTRUSTED,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}));
|
|
92
|
+
if (!links)
|
|
93
|
+
return;
|
|
94
|
+
server.registerTool("get_attachment_upload_url", {
|
|
95
|
+
title: "Get fmsg draft upload URL",
|
|
96
|
+
description: "Get a single-use URL, valid for 5 minutes, that PUTs one file into a draft from create_draft without " +
|
|
97
|
+
"this connection's Authorization header, for hosts that cannot send it. The URL accepts only a file of exactly the " +
|
|
98
|
+
"given size and sha256; then call send_draft. Returns a ready-to-run curl command. The URL authorizes itself: keep it " +
|
|
99
|
+
"out of messages and share it with nobody.",
|
|
100
|
+
inputSchema: z.strictObject({
|
|
101
|
+
id: idSchema.describe("the draft"),
|
|
102
|
+
filename: z.string().regex(FILENAME, "letters, digits, dot, underscore, hyphen only").describe("attachment file name"),
|
|
103
|
+
size: z.number().int().nonnegative().describe("file size in bytes"),
|
|
104
|
+
sha256: z.string().regex(/^[0-9A-Fa-f]{64}$/u, "64 hexadecimal characters").describe("hex SHA-256 of the file"),
|
|
105
|
+
content_type: z.string().regex(/^[\x20-\x7e]+$/u).optional().describe("inferred from the filename when omitted"),
|
|
106
|
+
}),
|
|
107
|
+
outputSchema: outputObject({ draft_id: z.string(), filename: z.string(), ...presignedOutput.shape }),
|
|
108
|
+
annotations: { ...SENDS, destructiveHint: false },
|
|
109
|
+
}, async ({ id, filename, size, sha256, content_type }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
110
|
+
const draft = await caller.client.getMessage(id, signal);
|
|
111
|
+
if (draft.time !== null && draft.time !== undefined)
|
|
112
|
+
return toolError(`message ${draft.id} was already sent; files can be added only to drafts`);
|
|
113
|
+
if (draft.from.toLowerCase() !== caller.address.toLowerCase())
|
|
114
|
+
return toolError(`message ${draft.id} is not your draft`);
|
|
115
|
+
if ((draft.attachments ?? []).some((a) => a.filename === filename))
|
|
116
|
+
return toolError(`draft ${draft.id} already has an attachment named ${filename}`);
|
|
117
|
+
const hex = sha256.toLowerCase();
|
|
118
|
+
const minted = links.mint(caller, { k: "put", m: draft.id, n: filename, s: size, h: hex, t: content_type ?? attachmentType(filename) });
|
|
119
|
+
const curl = `curl -fsS -X PUT --data-binary @${shellQuote(filename)} ${shellQuote(minted.url)}`;
|
|
120
|
+
const structured = { draft_id: draft.id, filename, url: minted.url, method: "PUT", expires_at: isoTime(minted.expiresAt), size, sha256: hex, curl };
|
|
121
|
+
return {
|
|
122
|
+
content: [{ type: "text", text: `Upload ${filename} (${size} bytes, sha256 ${hex}) into draft ${draft.id} before ${structured.expires_at}, ` +
|
|
123
|
+
`then call send_draft. The link takes one upload of exactly these bytes and needs no Authorization header:\n${curl}` }],
|
|
124
|
+
structuredContent: structured,
|
|
46
125
|
};
|
|
47
126
|
}));
|
|
48
127
|
};
|
package/dist/tools/draft.js
CHANGED
|
@@ -6,17 +6,18 @@ import { addressList, headerValue, isoTime } from "../render.js";
|
|
|
6
6
|
import { CHECKSUM_HEADER, UPLOAD_ENCODINGS, draftUploadUrlTemplate } from "../upload.js";
|
|
7
7
|
import { attachmentInput, base64Hint, draftWithAttachments, fileRoutes, prepareAttachments } from "./attachments.js";
|
|
8
8
|
import { SENDS, attachmentItem, idSchema, ok, openEnum, outputObject, resolverFor, withCaller } from "./common.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { parentReference, replyParent } from "./parent.js";
|
|
10
|
+
import { threadRoot } from "./read.js";
|
|
11
|
+
import { IMMUTABLE, IMPORTANT, NO_REPLY, hintOutput, uploadSteps, redactionNote, replyRecipients, resolveRecipients, resolvedNote, resolvedOutput, sendWarnings, sentAttachments, sentOutput, withHint, } from "./send.js";
|
|
11
12
|
export const registerDraftTools = (server, deps) => {
|
|
12
13
|
const baseUrl = downloadBaseUrl(deps.config);
|
|
13
|
-
const routes = fileRoutes(deps.config);
|
|
14
|
+
const routes = fileRoutes(deps.config, Boolean(deps.links));
|
|
14
15
|
const includeOwner = deps.includeOwner ? { include_owner: deps.includeOwner } : {};
|
|
15
16
|
server.registerTool("create_draft", {
|
|
16
17
|
title: "Create fmsg draft",
|
|
17
18
|
description: "Create an unsent draft: a new message, or a reply when reply_to is given (reply rules and reply-all apply). " +
|
|
18
19
|
"Nothing is sent until send_draft; delete_draft discards it. The draft is visible in the user's other fmsg clients. " +
|
|
19
|
-
(routes.uploadUrl ? `Use it for large files: ${
|
|
20
|
+
(routes.uploadUrl ? `Use it for large files: ${uploadSteps(routes)}. ` : "") +
|
|
20
21
|
"Secrets in the body and topic are redacted.",
|
|
21
22
|
inputSchema: z.strictObject({
|
|
22
23
|
to: z.array(z.string()).min(1).optional().describe("recipient addresses (@user@domain) or short names; for a reply, overrides reply-all"),
|
|
@@ -59,14 +60,16 @@ export const registerDraftTools = (server, deps) => {
|
|
|
59
60
|
return withCaller(deps, ctx, async (caller, signal) => {
|
|
60
61
|
let recipients;
|
|
61
62
|
let resolved;
|
|
62
|
-
let
|
|
63
|
+
let parentRef;
|
|
63
64
|
if (reply_to) {
|
|
64
|
-
const parent = await caller
|
|
65
|
+
const parent = await replyParent(caller, reply_to, signal);
|
|
66
|
+
if ("content" in parent)
|
|
67
|
+
return parent;
|
|
65
68
|
const target = replyRecipients(deps, caller, parent, to, allow_no_reply);
|
|
66
69
|
if ("content" in target)
|
|
67
70
|
return target;
|
|
68
71
|
({ to: recipients, resolved } = target);
|
|
69
|
-
|
|
72
|
+
parentRef = parentReference(caller, parent);
|
|
70
73
|
}
|
|
71
74
|
else {
|
|
72
75
|
({ to: recipients, resolved } = resolveRecipients(to, resolverFor(deps, caller)));
|
|
@@ -75,8 +78,9 @@ export const registerDraftTools = (server, deps) => {
|
|
|
75
78
|
const outbound = await prepareAttachments(attachments, deps.config);
|
|
76
79
|
const hint = base64Hint(attachments, routes);
|
|
77
80
|
const draft = await draftWithAttachments(caller.client, {
|
|
78
|
-
to: recipients, body, type, important, noReply: no_reply, ...(
|
|
81
|
+
to: recipients, body, type, important, noReply: no_reply, ...(parentRef ?? { topic: topic ?? "" }),
|
|
79
82
|
}, outbound, signal);
|
|
83
|
+
const pid = parentRef?.pid;
|
|
80
84
|
const upload = baseUrl ? {
|
|
81
85
|
url_template: draftUploadUrlTemplate(baseUrl, draft.id),
|
|
82
86
|
method: "PUT",
|
|
@@ -120,6 +124,10 @@ export const registerDraftTools = (server, deps) => {
|
|
|
120
124
|
return toolError(`message ${id} was already sent; send_draft sends only drafts`);
|
|
121
125
|
const sent = await caller.client.sendDraft(draft.id, signal);
|
|
122
126
|
const topic = draft.pid ? "" : (draft.topic ?? "");
|
|
127
|
+
// The message is already sent: a failed root lookup only leaves the topic unknown.
|
|
128
|
+
const root = draft.pid ? await threadRoot(caller.client, { ...draft, id: sent.id }, signal).catch(() => ({ id: null, topic: null })) : null;
|
|
129
|
+
if (draft.pid)
|
|
130
|
+
deps.indicators.replied(caller, draft.pid, root?.id ?? null);
|
|
123
131
|
const structured = {
|
|
124
132
|
id: sent.id,
|
|
125
133
|
time: isoTime(sent.time),
|
|
@@ -131,8 +139,7 @@ export const registerDraftTools = (server, deps) => {
|
|
|
131
139
|
redactions: 0,
|
|
132
140
|
redacted: [],
|
|
133
141
|
warnings: [],
|
|
134
|
-
|
|
135
|
-
thread_topic: draft.pid ? await rootTopic(caller.client, { ...draft, id: sent.id }, signal).catch(() => null) : topic,
|
|
142
|
+
thread_topic: root ? root.topic : topic,
|
|
136
143
|
};
|
|
137
144
|
const where = draft.pid ? `reply to message ${draft.pid}` : `"${headerValue(topic)}"`;
|
|
138
145
|
const text = `Sent draft ${sent.id} (${where}) to ${addressList(draft.to)} at ${structured.time ?? "?"}` +
|
package/dist/tools/identity.js
CHANGED
|
@@ -3,14 +3,14 @@ import { CALLER_DOMAIN, effectiveDefaultDomain, resolveAddress } from "../addres
|
|
|
3
3
|
import { addressText, isoTime } from "../render.js";
|
|
4
4
|
import { READ_ONLY, ok, openEnum, outputObject, resolverFor, withCaller } from "./common.js";
|
|
5
5
|
import { toolError } from "../errors.js";
|
|
6
|
-
import { VERSION } from "../version.js";
|
|
6
|
+
import { BUILD, VERSION, VERSION_LABEL } from "../version.js";
|
|
7
7
|
/** Structured output keeps the transport ids published since 0.2.5; only the text uses the friendlier label. */
|
|
8
8
|
const TRANSPORT_LABELS = { stdio: "stdio", http: "Streamable HTTP" };
|
|
9
9
|
export const registerIdentityTools = (server, deps) => {
|
|
10
10
|
server.registerTool("whoami", {
|
|
11
11
|
title: "Show fmsg identity",
|
|
12
12
|
description: "Report the fmsg address this server acts as, its owner if it is a sub-address, the fmsg Web API URL and how " +
|
|
13
|
-
"short names resolve
|
|
13
|
+
"short names resolve, and this server's version and build. Call this first if unsure who you are sending as.",
|
|
14
14
|
outputSchema: outputObject({
|
|
15
15
|
address: z.string(),
|
|
16
16
|
owner: z.string().optional().describe("owner of this sub-address, included as a recipient of messages you send unless include_owner is false; absent for an owner's own address"),
|
|
@@ -19,6 +19,9 @@ export const registerIdentityTools = (server, deps) => {
|
|
|
19
19
|
transport: openEnum(["stdio", "http"], "http is Streamable HTTP"),
|
|
20
20
|
default_domain: z.string().nullable(),
|
|
21
21
|
directory_names: z.array(z.string()).describe("short names in the operator-configured directory; empty when there are none"),
|
|
22
|
+
server_version: z.string().optional().describe("this fmsg-mcp server's release version"),
|
|
23
|
+
server_commit: z.string().optional().describe("the git commit this server was built from, when the build recorded it"),
|
|
24
|
+
server_build: z.string().optional().describe("git describe of the build, such as v1.2.3-4-g80e0418, when recorded"),
|
|
22
25
|
}),
|
|
23
26
|
annotations: { ...READ_ONLY, openWorldHint: false },
|
|
24
27
|
}, async (ctx) => withCaller(deps, ctx, async (caller) => {
|
|
@@ -34,12 +37,15 @@ export const registerIdentityTools = (server, deps) => {
|
|
|
34
37
|
transport: deps.config.transport,
|
|
35
38
|
default_domain: defaultDomain ?? null,
|
|
36
39
|
directory_names: directoryNames,
|
|
40
|
+
server_version: VERSION,
|
|
41
|
+
...(BUILD.commit ? { server_commit: BUILD.commit } : {}),
|
|
42
|
+
...(BUILD.describe ? { server_build: BUILD.describe } : {}),
|
|
37
43
|
};
|
|
38
44
|
const lines = [
|
|
39
45
|
deps.config.apiPublicUrl
|
|
40
46
|
? `You are ${addressText(caller.address)} on ${deps.config.apiPublicUrl}, connected over ${transport}.`
|
|
41
47
|
: `You are ${addressText(caller.address)}, connected over ${transport}.`,
|
|
42
|
-
`Access is renewed automatically. Server version ${
|
|
48
|
+
`Access is renewed automatically. Server version ${VERSION_LABEL}.`,
|
|
43
49
|
];
|
|
44
50
|
if (caller.owner)
|
|
45
51
|
lines.push(`Sub-address of ${addressText(caller.owner)}, who is a recipient of messages you send unless include_owner is false.`);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parent of a reply. A recipient added to a message after it was sent (add-to) is a participant of that
|
|
3
|
+
* message only through the batch that added them, so the Web API takes their reply only with that batch's hash
|
|
4
|
+
* as the parent. They are not a participant of the rest of the thread and cannot reply there.
|
|
5
|
+
*/
|
|
6
|
+
import type { CallToolResult } from "@modelcontextprotocol/server";
|
|
7
|
+
import type { FmsgMessage } from "../client/types.js";
|
|
8
|
+
import type { Caller } from "../context.js";
|
|
9
|
+
/** How a draft names its parent: the parent's id, plus the hash to reply through when the caller was added later. */
|
|
10
|
+
export type ParentReference = {
|
|
11
|
+
pid: string;
|
|
12
|
+
parentSha256?: string;
|
|
13
|
+
};
|
|
14
|
+
/** Whether the caller was added to `message` after it was sent, rather than being its sender or a recipient. */
|
|
15
|
+
export declare function addedOnly(caller: Pick<Caller, "address">, message: FmsgMessage): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The parent reference for a reply from `caller` to `parent`. An added recipient replies through the hash of the
|
|
18
|
+
* batch that added them. A host that gives no batch hash predates the rule, so the id alone is sent there.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parentReference(caller: Pick<Caller, "address">, parent: FmsgMessage): ParentReference;
|
|
21
|
+
/**
|
|
22
|
+
* The message the caller was added to in the thread above `target`, when `target` is an earlier message there that
|
|
23
|
+
* the caller cannot reply to. Searches the caller's recent inbox for messages it was added to whose thread contains
|
|
24
|
+
* `target`. Returns undefined when none is found.
|
|
25
|
+
*/
|
|
26
|
+
export declare function addedMessageBelow(caller: Caller, target: string, signal: AbortSignal): Promise<string | undefined>;
|
|
27
|
+
/** The error for a reply to a message in a thread the caller can reply to only lower down, at `added`. */
|
|
28
|
+
export declare function notParticipantError(target: string, added: string): CallToolResult;
|
|
29
|
+
/**
|
|
30
|
+
* Fetch the message a reply goes to. When it cannot be read because the caller is not a participant of it, but the
|
|
31
|
+
* caller was added to a later message in its thread, return an error naming that message.
|
|
32
|
+
*/
|
|
33
|
+
export declare function replyParent(caller: Caller, id: string, signal: AbortSignal): Promise<FmsgMessage | CallToolResult>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { sameAddress } from "../address.js";
|
|
2
|
+
import { FmsgHttpError } from "../client/client.js";
|
|
3
|
+
import { toolError } from "../errors.js";
|
|
4
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/iu;
|
|
5
|
+
/** Whether the caller was added to `message` after it was sent, rather than being its sender or a recipient. */
|
|
6
|
+
export function addedOnly(caller, message) {
|
|
7
|
+
if (sameAddress(message.from, caller.address) || message.to.some((a) => sameAddress(a, caller.address)))
|
|
8
|
+
return false;
|
|
9
|
+
return (message.add_to ?? []).some((b) => (b.to ?? []).some((a) => sameAddress(a, caller.address)));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The parent reference for a reply from `caller` to `parent`. An added recipient replies through the hash of the
|
|
13
|
+
* batch that added them. A host that gives no batch hash predates the rule, so the id alone is sent there.
|
|
14
|
+
*/
|
|
15
|
+
export function parentReference(caller, parent) {
|
|
16
|
+
if (!addedOnly(caller, parent))
|
|
17
|
+
return { pid: parent.id };
|
|
18
|
+
const batch = (parent.add_to ?? []).find((b) => (b.to ?? []).some((a) => sameAddress(a, caller.address)));
|
|
19
|
+
const hash = batch?.sha256;
|
|
20
|
+
return typeof hash === "string" && SHA256_HEX.test(hash) ? { pid: parent.id, parentSha256: hash.toLowerCase() } : { pid: parent.id };
|
|
21
|
+
}
|
|
22
|
+
/** Inbox pages searched for the message the caller was added to; only on the error path. */
|
|
23
|
+
const SEARCH_PAGES = 5;
|
|
24
|
+
const PAGE_SIZE = 100;
|
|
25
|
+
/** Threads fetched while searching, so a large inbox cannot turn one failed reply into many requests. */
|
|
26
|
+
const SEARCH_THREADS = 20;
|
|
27
|
+
/**
|
|
28
|
+
* The message the caller was added to in the thread above `target`, when `target` is an earlier message there that
|
|
29
|
+
* the caller cannot reply to. Searches the caller's recent inbox for messages it was added to whose thread contains
|
|
30
|
+
* `target`. Returns undefined when none is found.
|
|
31
|
+
*/
|
|
32
|
+
export async function addedMessageBelow(caller, target, signal) {
|
|
33
|
+
const candidates = [];
|
|
34
|
+
for (let page = 0; page < SEARCH_PAGES; page++) {
|
|
35
|
+
const items = await caller.client.listInbox(PAGE_SIZE, page * PAGE_SIZE, signal);
|
|
36
|
+
candidates.push(...items.filter((m) => m.id !== target && addedOnly(caller, m)));
|
|
37
|
+
if (items.length < PAGE_SIZE)
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
const direct = candidates.find((m) => m.pid === target);
|
|
41
|
+
if (direct)
|
|
42
|
+
return direct.id;
|
|
43
|
+
for (const m of candidates.filter((c) => c.pid).slice(0, SEARCH_THREADS)) {
|
|
44
|
+
try {
|
|
45
|
+
const thread = await caller.client.getThreadMessages(m.id, signal);
|
|
46
|
+
if (thread.messages.some((x) => x.id === target))
|
|
47
|
+
return m.id;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (!(error instanceof FmsgHttpError) || error.status === 401)
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
/** The error for a reply to a message in a thread the caller can reply to only lower down, at `added`. */
|
|
57
|
+
export function notParticipantError(target, added) {
|
|
58
|
+
return toolError(`You cannot reply to message ${target}: you are not a participant of it. You were added to message ${added} ` +
|
|
59
|
+
`in the same thread after it was sent, so you can reply to message ${added} instead (pass id ${added}).`);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Fetch the message a reply goes to. When it cannot be read because the caller is not a participant of it, but the
|
|
63
|
+
* caller was added to a later message in its thread, return an error naming that message.
|
|
64
|
+
*/
|
|
65
|
+
export async function replyParent(caller, id, signal) {
|
|
66
|
+
try {
|
|
67
|
+
return await caller.client.getMessage(id, signal);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (!(error instanceof FmsgHttpError) || ![403, 404].includes(error.status))
|
|
71
|
+
throw error;
|
|
72
|
+
const added = await addedMessageBelow(caller, id, signal).catch(() => undefined);
|
|
73
|
+
if (added)
|
|
74
|
+
return notParticipantError(id, added);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
package/dist/tools/read.d.ts
CHANGED
|
@@ -2,8 +2,12 @@ import type { FmsgClient } from "../client/client.js";
|
|
|
2
2
|
import type { FmsgMessage } from "../client/types.js";
|
|
3
3
|
import { type Register } from "./common.js";
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* A message's thread root and its topic (fmsg topics live only on the root): one thread lookup, made only for replies.
|
|
6
|
+
* The topic is null when the root is not visible, and both are null when the lookup fails; a failure here never
|
|
7
|
+
* fails the read itself.
|
|
7
8
|
*/
|
|
8
|
-
export declare function
|
|
9
|
+
export declare function threadRoot(client: FmsgClient, message: FmsgMessage, signal: AbortSignal): Promise<{
|
|
10
|
+
id: string | null;
|
|
11
|
+
topic: string | null;
|
|
12
|
+
}>;
|
|
9
13
|
export declare const registerReadTools: Register;
|
package/dist/tools/read.js
CHANGED
|
@@ -28,20 +28,21 @@ const assembledMessage = outputObject({
|
|
|
28
28
|
attachments: z.array(attachmentItem).describe("sorted by filename, the same order in every tool"),
|
|
29
29
|
});
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* A message's thread root and its topic (fmsg topics live only on the root): one thread lookup, made only for replies.
|
|
32
|
+
* The topic is null when the root is not visible, and both are null when the lookup fails; a failure here never
|
|
33
|
+
* fails the read itself.
|
|
33
34
|
*/
|
|
34
|
-
export async function
|
|
35
|
+
export async function threadRoot(client, message, signal) {
|
|
35
36
|
if (!message.pid)
|
|
36
|
-
return message.topic ?? "";
|
|
37
|
+
return { id: message.id, topic: message.topic ?? "" };
|
|
37
38
|
try {
|
|
38
39
|
const thread = await client.getThreadMessages(message.id, signal);
|
|
39
40
|
const root = thread.messages[0];
|
|
40
|
-
return root?.visible && root.id === thread.root_id ? (root.topic ?? "") : null;
|
|
41
|
+
return { id: thread.root_id, topic: root?.visible && root.id === thread.root_id ? (root.topic ?? "") : null };
|
|
41
42
|
}
|
|
42
43
|
catch (error) {
|
|
43
44
|
if (error instanceof FmsgHttpError && error.status !== 401 && !error.insufficientScope)
|
|
44
|
-
return null;
|
|
45
|
+
return { id: null, topic: null };
|
|
45
46
|
throw error;
|
|
46
47
|
}
|
|
47
48
|
}
|
|
@@ -78,7 +79,7 @@ export const registerReadTools = (server, deps) => {
|
|
|
78
79
|
const message = await caller.client.getMessage(id, signal);
|
|
79
80
|
const text = await caller.client.getText(message, signal);
|
|
80
81
|
const t = text === null ? null : truncateUtf8(text, max_body_bytes);
|
|
81
|
-
const threadTopic = await
|
|
82
|
+
const { topic: threadTopic } = await threadRoot(caller.client, message, signal);
|
|
82
83
|
const structured = {
|
|
83
84
|
message: toItem(message, caller.address),
|
|
84
85
|
body: t?.text ?? null,
|
|
@@ -159,7 +160,7 @@ export const registerReadTools = (server, deps) => {
|
|
|
159
160
|
server.registerTool("mark_read", {
|
|
160
161
|
title: "Mark fmsg messages read",
|
|
161
162
|
description: "Mark received messages as read, including reaction messages listed with include_reactions. " +
|
|
162
|
-
"Reading a message with get_message does not mark it read.",
|
|
163
|
+
"Reading a message with get_message does not mark it read. Marking a message read clears an indicator you set on it.",
|
|
163
164
|
inputSchema: z.strictObject({ ids: z.array(idSchema).min(1).max(100).describe("ids of the messages to mark read (up to 100)") }),
|
|
164
165
|
outputSchema: outputObject({
|
|
165
166
|
marked: z.array(outputObject({ id: z.string(), time_read: z.string().nullable() })),
|
|
@@ -167,6 +168,7 @@ export const registerReadTools = (server, deps) => {
|
|
|
167
168
|
}),
|
|
168
169
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
169
170
|
}, async ({ ids }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
171
|
+
deps.indicators.handled(caller, ids);
|
|
170
172
|
const marked = [];
|
|
171
173
|
const failed = [];
|
|
172
174
|
for (const id of ids) {
|
package/dist/tools/send.d.ts
CHANGED
|
@@ -4,15 +4,18 @@ import { type AddressResolver } from "../address.js";
|
|
|
4
4
|
import type { FmsgMessage } from "../client/types.js";
|
|
5
5
|
import type { Config } from "../config.js";
|
|
6
6
|
import type { Caller } from "../context.js";
|
|
7
|
-
import { type PreparedAttachment } from "./attachments.js";
|
|
7
|
+
import { type FileRoutes, type PreparedAttachment } from "./attachments.js";
|
|
8
8
|
import { type Register, type ToolDeps } from "./common.js";
|
|
9
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
10
|
export declare const IMPORTANT = "mark as important for recipients";
|
|
11
11
|
export declare const NO_REPLY = "ask recipients (and their agents) not to reply";
|
|
12
12
|
/** The upload-URL route, stated in full wherever a model may read about it. */
|
|
13
13
|
export declare const UPLOAD_STEPS: string;
|
|
14
|
+
/** UPLOAD_STEPS where get_attachment_upload_url is registered. */
|
|
15
|
+
export declare const UPLOAD_STEPS_PRESIGNED: string;
|
|
16
|
+
export declare function uploadSteps(routes: FileRoutes): string;
|
|
14
17
|
/** How to send files without the model writing their bytes, naming only what this server registered. */
|
|
15
|
-
export declare function attachmentGuidance(config: Config): string;
|
|
18
|
+
export declare function attachmentGuidance(config: Config, routes?: FileRoutes): string;
|
|
16
19
|
/**
|
|
17
20
|
* Resolve recipients, dropping duplicates, and report each name that was not already a full address, so the model
|
|
18
21
|
* can confirm a guessed address with the user.
|
package/dist/tools/send.js
CHANGED
|
@@ -5,21 +5,29 @@ import { withOwner } from "../owner.js";
|
|
|
5
5
|
import { addressList, addressText, attachmentType, headerValue, isoTime, participantsOf, sortAttachments } from "../render.js";
|
|
6
6
|
import { attachmentInput, base64Hint, fileRoutes, prepareAttachments, sendWithAttachments } from "./attachments.js";
|
|
7
7
|
import { SENDS, attachmentItem, idSchema, ok, outputObject, resolverFor, withCaller } from "./common.js";
|
|
8
|
-
import {
|
|
8
|
+
import { parentReference, replyParent } from "./parent.js";
|
|
9
|
+
import { WORKING_EMOJI } from "../indicators.js";
|
|
10
|
+
import { threadRoot } from "./read.js";
|
|
9
11
|
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.";
|
|
10
12
|
export const IMPORTANT = "mark as important for recipients";
|
|
11
13
|
export const NO_REPLY = "ask recipients (and their agents) not to reply";
|
|
12
14
|
/** The upload-URL route, stated in full wherever a model may read about it. */
|
|
13
15
|
export const UPLOAD_STEPS = "PUT each file's raw bytes to the draft's upload URL with this connection's Authorization header, then call " +
|
|
14
16
|
"send_draft. If your host cannot send this connection's credentials, use data_base64; never look for or copy credentials";
|
|
17
|
+
/** UPLOAD_STEPS where get_attachment_upload_url is registered. */
|
|
18
|
+
export const UPLOAD_STEPS_PRESIGNED = "PUT each file's raw bytes to the draft's upload URL with this connection's Authorization header, then call " +
|
|
19
|
+
"send_draft. If your host cannot send this connection's credentials, get a single-use link per file from get_attachment_upload_url " +
|
|
20
|
+
"instead; never look for or copy credentials";
|
|
21
|
+
export function uploadSteps(routes) {
|
|
22
|
+
return routes.presigned ? UPLOAD_STEPS_PRESIGNED : UPLOAD_STEPS;
|
|
23
|
+
}
|
|
15
24
|
/** How to send files without the model writing their bytes, naming only what this server registered. */
|
|
16
|
-
export function attachmentGuidance(config) {
|
|
17
|
-
const routes = fileRoutes(config);
|
|
25
|
+
export function attachmentGuidance(config, routes = fileRoutes(config)) {
|
|
18
26
|
const large = [
|
|
19
|
-
...(routes.uploadUrl ? [`use create_draft instead: ${
|
|
27
|
+
...(routes.uploadUrl ? [`use create_draft instead: ${uploadSteps(routes)}`] : []),
|
|
20
28
|
...(routes.uploadFolder ? ["give path, a file in the upload folder"] : []),
|
|
21
29
|
];
|
|
22
|
-
return "Files: data_text for text
|
|
30
|
+
return "Files: data_text for text; data_base64 for small files, best effort, always with sha256. " +
|
|
23
31
|
(large.length ? `For large files, ${large.join(". Or ")}. ` : "") +
|
|
24
32
|
"Send files as they are; fmsg compresses them.";
|
|
25
33
|
}
|
|
@@ -98,10 +106,10 @@ export function resolvedNote(resolved) {
|
|
|
98
106
|
return resolved.length ? ` Short names resolved: ${resolved.map((r) => `${r.input} → ${addressText(r.address)}`).join(", ")}.` : "";
|
|
99
107
|
}
|
|
100
108
|
export const registerSendTools = (server, deps) => {
|
|
101
|
-
const
|
|
102
|
-
const
|
|
109
|
+
const routes = fileRoutes(deps.config, Boolean(deps.links));
|
|
110
|
+
const attachments = z.array(attachmentInput(deps.config, routes)).optional().describe("files");
|
|
111
|
+
const guidance = attachmentGuidance(deps.config, routes);
|
|
103
112
|
const includeOwner = deps.includeOwner ? { include_owner: deps.includeOwner } : {};
|
|
104
|
-
const routes = fileRoutes(deps.config);
|
|
105
113
|
server.registerTool("send_message", {
|
|
106
114
|
title: "Send new fmsg message",
|
|
107
115
|
description: `Send a new message immediately, starting a new thread. ${IMMUTABLE} ` +
|
|
@@ -150,7 +158,8 @@ export const registerSendTools = (server, deps) => {
|
|
|
150
158
|
title: "Reply in fmsg thread",
|
|
151
159
|
description: `Send an immediate reply to a message (linking it into that thread). ${IMMUTABLE} ` +
|
|
152
160
|
"By default the reply goes to everyone on the parent message — its sender, recipients and anyone added later — " +
|
|
153
|
-
"except you; pass recipients to narrow or widen that.
|
|
161
|
+
"except you; pass recipients to narrow or widen that. If you were added to the message after it was sent, you can " +
|
|
162
|
+
"reply to it but not to earlier messages in its thread. Fails if the parent is terminal (no replies possible); " +
|
|
154
163
|
`a parent marked no-reply is refused unless allow_no_reply is true. Secrets are redacted. ${guidance}`,
|
|
155
164
|
inputSchema: z.strictObject({
|
|
156
165
|
id: idSchema.describe("message to reply to"),
|
|
@@ -166,7 +175,9 @@ export const registerSendTools = (server, deps) => {
|
|
|
166
175
|
outputSchema: sentOutput,
|
|
167
176
|
annotations: SENDS,
|
|
168
177
|
}, async ({ id, body, recipients, type, important, no_reply, allow_no_reply, include_owner, attachments }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
169
|
-
const parent = await caller
|
|
178
|
+
const parent = await replyParent(caller, id, signal);
|
|
179
|
+
if ("content" in parent)
|
|
180
|
+
return parent;
|
|
170
181
|
const target = replyRecipients(deps, caller, parent, recipients, allow_no_reply);
|
|
171
182
|
if ("content" in target)
|
|
172
183
|
return target;
|
|
@@ -174,7 +185,10 @@ export const registerSendTools = (server, deps) => {
|
|
|
174
185
|
const to = withOwner(caller, target.to, include_owner);
|
|
175
186
|
const outbound = await prepareAttachments(attachments, deps.config);
|
|
176
187
|
const hint = base64Hint(attachments, routes);
|
|
177
|
-
const sent = await sendWithAttachments(caller.client, { to,
|
|
188
|
+
const sent = await sendWithAttachments(caller.client, { to, ...parentReference(caller, parent), body, type, important, noReply: no_reply }, outbound, signal);
|
|
189
|
+
// The reply is already sent: a failed root lookup only leaves the topic unknown.
|
|
190
|
+
const root = await threadRoot(caller.client, parent, signal).catch(() => ({ id: null, topic: null }));
|
|
191
|
+
deps.indicators.replied(caller, parent.id, root.id);
|
|
178
192
|
const structured = {
|
|
179
193
|
id: sent.id,
|
|
180
194
|
time: isoTime(sent.time),
|
|
@@ -187,8 +201,7 @@ export const registerSendTools = (server, deps) => {
|
|
|
187
201
|
redacted: sent.redacted,
|
|
188
202
|
...(resolved.length ? { resolved } : {}),
|
|
189
203
|
warnings: sendWarnings(sent.redactions, sent.redacted, resolved),
|
|
190
|
-
|
|
191
|
-
thread_topic: await rootTopic(caller.client, parent, signal).catch(() => null),
|
|
204
|
+
thread_topic: root.topic,
|
|
192
205
|
...(hint ? { hint } : {}),
|
|
193
206
|
};
|
|
194
207
|
const topic = structured.thread_topic ? ` in "${headerValue(structured.thread_topic)}"` : "";
|
|
@@ -236,7 +249,34 @@ export const registerSendTools = (server, deps) => {
|
|
|
236
249
|
}, async ({ id, emoji }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
237
250
|
const value = emoji && emoji.trim() ? emoji.trim() : null;
|
|
238
251
|
const result = await caller.client.react(id, value, signal);
|
|
252
|
+
deps.indicators.handled(caller, [id]);
|
|
239
253
|
const structured = { id, reaction_id: result.id, time: isoTime(result.time), cleared: value === null };
|
|
240
254
|
return ok(value ? `Reacted ${value} to message ${id}` : `Cleared your reaction on message ${id}`, structured);
|
|
241
255
|
}));
|
|
256
|
+
server.registerTool("indicate", {
|
|
257
|
+
title: "Show fmsg indicator",
|
|
258
|
+
description: "Show the other participants of a message a short-lived status, such as ⏳ while you work on a reply or ✍️ while " +
|
|
259
|
+
"composing. Nothing is shown unless you call this. It is kept up while you work (up to 5 minutes) and expires on " +
|
|
260
|
+
"its own; reply, react and mark_read clear it, and an empty emoji clears it now.",
|
|
261
|
+
inputSchema: z.strictObject({
|
|
262
|
+
id: idSchema,
|
|
263
|
+
emoji: z.string().max(32).default(WORKING_EMOJI).describe("a single emoji such as ⏳ or ✍️; empty clears"),
|
|
264
|
+
ttl: z.number().int().min(1).max(120).default(60).describe("seconds it lasts unless refreshed"),
|
|
265
|
+
}),
|
|
266
|
+
outputSchema: outputObject({
|
|
267
|
+
id: z.string(),
|
|
268
|
+
emoji: z.string().describe("the emoji shown; empty when cleared"),
|
|
269
|
+
ttl: z.number().describe("seconds; 0 when cleared"),
|
|
270
|
+
cleared: z.boolean(),
|
|
271
|
+
}),
|
|
272
|
+
annotations: { ...SENDS, destructiveHint: false, idempotentHint: true },
|
|
273
|
+
}, async ({ id, emoji, ttl }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
274
|
+
const value = emoji.trim();
|
|
275
|
+
// The thread root lets a reply anywhere in the thread clear it; a failed lookup only means it is not known.
|
|
276
|
+
const rootId = value ? await caller.client.getThreadMessages(id, signal).then((thread) => thread.root_id, () => null) : null;
|
|
277
|
+
await deps.indicators.indicate(caller, id, value, ttl, rootId);
|
|
278
|
+
const structured = { id, emoji: value, ttl: value ? ttl : 0, cleared: !value };
|
|
279
|
+
return ok(value ? `Showing ${value} on message ${id} until you reply, react, mark it read or clear it (up to 5 minutes)`
|
|
280
|
+
: `Cleared your indicator on message ${id}`, structured);
|
|
281
|
+
}));
|
|
242
282
|
};
|
package/dist/upload.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Caller } from "./context.js";
|
|
1
2
|
export declare const UPLOAD_PATH = "/mcp/drafts";
|
|
2
3
|
/** Content-Encoding values accepted on uploads; decoded before the bytes reach fmsg. */
|
|
3
4
|
export declare const UPLOAD_ENCODINGS: readonly ["gzip", "deflate"];
|
|
@@ -26,3 +27,24 @@ export declare function drainableBody(body: ReadableStream<Uint8Array>, idleMs:
|
|
|
26
27
|
export declare function decodedUploadBody(body: ReadableStream<Uint8Array>, encoding: string | undefined): ReadableStream<Uint8Array>;
|
|
27
28
|
/** The upload's Content-Encoding, lowercased; "identity" or none means undecoded. Throws for an unsupported one. */
|
|
28
29
|
export declare function uploadEncoding(header: string | null): string | undefined;
|
|
30
|
+
/** Who keeps track of uploads to one kind of link: one at a time per file, and which were verified. */
|
|
31
|
+
export type UploadTracker = {
|
|
32
|
+
begin(key: string): boolean;
|
|
33
|
+
end(key: string): void;
|
|
34
|
+
recordVerified(key: string, sha256: string): void;
|
|
35
|
+
verifiedSha256(key: string): string | undefined;
|
|
36
|
+
};
|
|
37
|
+
/** One file a link accepts: into which draft, under which name, and exactly which bytes. */
|
|
38
|
+
export type UploadSlot = {
|
|
39
|
+
draft: string;
|
|
40
|
+
filename: string;
|
|
41
|
+
size: number;
|
|
42
|
+
sha256: string;
|
|
43
|
+
mimeType: string;
|
|
44
|
+
fileId?: string;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Stream one upload into a draft, refusing any body that is not exactly the slot's size and SHA-256, and a second
|
|
48
|
+
* upload after one succeeded. `noun` names the link in messages ("target", "link").
|
|
49
|
+
*/
|
|
50
|
+
export declare function streamIntoDraft(tracker: UploadTracker, caller: Caller, slot: UploadSlot, request: Request, encoding: string | undefined, signal: AbortSignal, noun: string): Promise<Response>;
|