@markmnl/fmsg-mcp 0.2.6 → 0.2.8
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 +52 -3
- package/README.md +26 -9
- package/SECURITY.md +2 -1
- package/dist/address.d.ts +1 -1
- package/dist/address.js +33 -4
- package/dist/client/redact.js +24 -4
- package/dist/client/types.d.ts +5 -0
- package/dist/prompts.js +2 -1
- package/dist/render.d.ts +20 -2
- package/dist/render.js +39 -11
- package/dist/thread.d.ts +14 -1
- package/dist/thread.js +37 -13
- package/dist/tools/common.d.ts +33 -19
- package/dist/tools/common.js +58 -14
- package/dist/tools/download.js +6 -7
- package/dist/tools/identity.js +14 -16
- package/dist/tools/list.js +3 -3
- package/dist/tools/read.js +67 -28
- package/dist/tools/save.js +16 -4
- package/dist/tools/send.js +18 -13
- package/dist/tools/wait.js +43 -20
- package/docs/http-deployment.md +8 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/tools/send.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import * as z from "zod/v4";
|
|
2
2
|
import { resolveAddresses, sameAddress } from "../address.js";
|
|
3
3
|
import { toolError } from "../errors.js";
|
|
4
|
-
import { attachmentType, isoTime, participantsOf } from "../render.js";
|
|
5
|
-
import { SENDS, attachmentItem, idSchema, ok, resolverFor, withCaller } from "./common.js";
|
|
4
|
+
import { addressList, attachmentType, headerValue, isoTime, participantsOf, sortAttachments } from "../render.js";
|
|
5
|
+
import { SENDS, attachmentItem, idSchema, ok, outputObject, resolverFor, withCaller } from "./common.js";
|
|
6
6
|
const IMMUTABLE = "fmsg messages are immutable: once sent they cannot be edited or recalled. Send within the user's requested task or authorized automation.";
|
|
7
7
|
const attachmentInput = z.object({
|
|
8
8
|
filename: z.string().regex(/^[A-Za-z0-9._-]+$/u, "letters, digits, dot, underscore, hyphen only"),
|
|
9
9
|
data_base64: z.string().min(1),
|
|
10
10
|
content_type: z.string().optional(),
|
|
11
11
|
});
|
|
12
|
-
/** Sent attachments with the type each was given, or the one its filename implies. */
|
|
12
|
+
/** Sent attachments with the type each was given, or the one its filename implies, in the order every tool lists them. */
|
|
13
13
|
function sentAttachments(sent, outbound) {
|
|
14
|
-
return sent.map((a, i) => ({ ...a, type: attachmentType(a.filename, outbound[i]?.contentType) }));
|
|
14
|
+
return sortAttachments(sent.map((a, i) => ({ ...a, type: attachmentType(a.filename, outbound[i]?.contentType) })));
|
|
15
15
|
}
|
|
16
16
|
function decodeAttachments(items) {
|
|
17
17
|
return (items ?? []).map((a) => {
|
|
@@ -21,16 +21,17 @@ function decodeAttachments(items) {
|
|
|
21
21
|
return { filename: a.filename, data: new Uint8Array(data), ...(a.content_type ? { contentType: a.content_type } : {}) };
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
|
-
const sentOutput =
|
|
24
|
+
const sentOutput = outputObject({
|
|
25
25
|
id: z.string(),
|
|
26
26
|
time: z.string().nullable(),
|
|
27
27
|
from: z.string(),
|
|
28
28
|
to: z.array(z.string()),
|
|
29
29
|
topic: z.string(),
|
|
30
30
|
parent_id: z.string().nullable(),
|
|
31
|
-
attachments: z.array(attachmentItem),
|
|
31
|
+
attachments: z.array(attachmentItem).describe("sorted by filename, the same order in every tool"),
|
|
32
32
|
redactions: z.number().describe("secrets replaced with placeholders before sending"),
|
|
33
33
|
warnings: z.array(z.string()),
|
|
34
|
+
thread_topic: z.string().nullable().optional().describe("the thread's topic: the topic sent for a new message; for a reply, the parent's topic when the parent is the thread root, otherwise null (get_thread reports it)"),
|
|
34
35
|
});
|
|
35
36
|
export const registerSendTools = (server, deps) => {
|
|
36
37
|
server.registerTool("send_message", {
|
|
@@ -73,9 +74,10 @@ export const registerSendTools = (server, deps) => {
|
|
|
73
74
|
attachments: sentAttachments(sent.attachments, outbound),
|
|
74
75
|
redactions: sent.redactions,
|
|
75
76
|
warnings: [],
|
|
77
|
+
thread_topic: sent.topic,
|
|
76
78
|
};
|
|
77
|
-
const text = `Sent message ${sent.id} "${sent.topic}" to ${recipients
|
|
78
|
-
(
|
|
79
|
+
const text = `Sent message ${sent.id} "${sent.topic}" to ${addressList(recipients)} at ${structured.time ?? "?"}` +
|
|
80
|
+
(structured.attachments.length ? ` with ${structured.attachments.map((a) => a.filename).join(", ")}` : "") +
|
|
79
81
|
(structured.redactions ? `. ${structured.redactions} secret(s) were redacted before sending.` : ".");
|
|
80
82
|
return ok(text, structured);
|
|
81
83
|
}));
|
|
@@ -131,8 +133,11 @@ export const registerSendTools = (server, deps) => {
|
|
|
131
133
|
attachments: sentAttachments(sent.attachments, outbound),
|
|
132
134
|
redactions: sent.redactions,
|
|
133
135
|
warnings,
|
|
136
|
+
// Topics live only on a thread's root; a deeper parent would need another lookup.
|
|
137
|
+
thread_topic: parent.pid ? null : (parent.topic ?? ""),
|
|
134
138
|
};
|
|
135
|
-
const
|
|
139
|
+
const topic = structured.thread_topic ? ` in "${headerValue(structured.thread_topic)}"` : "";
|
|
140
|
+
const text = `Sent reply ${sent.id} to message ${parent.id}${topic} for ${addressList(to)} at ${structured.time ?? "?"}` +
|
|
136
141
|
(structured.redactions ? `. ${structured.redactions} secret(s) were redacted before sending.` : ".");
|
|
137
142
|
return ok(text, structured);
|
|
138
143
|
}));
|
|
@@ -145,10 +150,10 @@ export const registerSendTools = (server, deps) => {
|
|
|
145
150
|
recipients: z.array(z.string()).min(1).optional().describe("addresses or short names to add"),
|
|
146
151
|
add_to: z.array(z.string()).min(1).optional().describe("deprecated alias of recipients; pass one or the other"),
|
|
147
152
|
}),
|
|
148
|
-
outputSchema:
|
|
153
|
+
outputSchema: outputObject({
|
|
149
154
|
id: z.string(),
|
|
150
155
|
added: z.number(),
|
|
151
|
-
recipients: z.array(z.string()),
|
|
156
|
+
recipients: z.array(z.string()).optional().describe("the resolved addresses that were added"),
|
|
152
157
|
add_to: z.array(z.string()).describe("deprecated copy of recipients"),
|
|
153
158
|
}),
|
|
154
159
|
annotations: { ...SENDS, idempotentHint: true },
|
|
@@ -158,7 +163,7 @@ export const registerSendTools = (server, deps) => {
|
|
|
158
163
|
return withCaller(deps, ctx, async (caller, signal) => {
|
|
159
164
|
const addresses = resolveAddresses((recipients ?? add_to), resolverFor(deps, caller));
|
|
160
165
|
const result = await caller.client.addRecipients(id, addresses, signal);
|
|
161
|
-
return ok(`Added ${result.added} recipient(s) to message ${id}: ${addresses
|
|
166
|
+
return ok(`Added ${result.added} recipient(s) to message ${id}: ${addressList(addresses)}`, { ...result, recipients: addresses, add_to: addresses });
|
|
162
167
|
});
|
|
163
168
|
});
|
|
164
169
|
server.registerTool("react", {
|
|
@@ -169,7 +174,7 @@ export const registerSendTools = (server, deps) => {
|
|
|
169
174
|
id: idSchema,
|
|
170
175
|
emoji: z.string().max(32).nullable().describe("a single emoji; null or empty clears your reaction"),
|
|
171
176
|
}),
|
|
172
|
-
outputSchema:
|
|
177
|
+
outputSchema: outputObject({ id: z.string(), reaction_id: z.string().nullable(), time: z.string().nullable(), cleared: z.boolean() }),
|
|
173
178
|
annotations: { ...SENDS, destructiveHint: false, idempotentHint: true },
|
|
174
179
|
}, async ({ id, emoji }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
175
180
|
const value = emoji && emoji.trim() ? emoji.trim() : null;
|
package/dist/tools/wait.js
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import * as z from "zod/v4";
|
|
2
|
+
import { compareMessageIds } from "../client/message-id.js";
|
|
2
3
|
import { resolveAddress } from "../address.js";
|
|
3
4
|
import { assembleThread, renderThread } from "../thread.js";
|
|
4
|
-
import { DATA_NOT_INSTRUCTIONS, fence, headerValue, messageData, messageLine } from "../render.js";
|
|
5
|
+
import { DATA_NOT_INSTRUCTIONS, addressText, fence, headerValue, messageData, messageLine } from "../render.js";
|
|
5
6
|
import { waitForMessage } from "../wait.js";
|
|
6
|
-
import { READ_ONLY, UNTRUSTED, idSchema, messageItem, ok, resolverFor, toItem, untrustedNotice, withCaller } from "./common.js";
|
|
7
|
+
import { READ_ONLY, UNTRUSTED, idSchema, messageItem, ok, openEnum, outputObject, resolverFor, toItem, untrustedNotice, withCaller, } from "./common.js";
|
|
7
8
|
/** Skipped reactions as one line each, for the data block. */
|
|
8
9
|
function reactionLines(skipped) {
|
|
9
|
-
return skipped.filter((s) => s.emoji !== undefined).map((s) => `- ${s.id} from ${
|
|
10
|
+
return skipped.filter((s) => s.emoji !== undefined).map((s) => `- ${s.id} from ${addressText(s.from)}: ${s.emoji ? `reacted ${headerValue(s.emoji)}` : "cleared a reaction"}${s.reaction_to ? ` on message ${s.reaction_to}` : ""}`);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Messages that arrived on other threads (or beyond the batch limit) during the settle window. They are not
|
|
14
|
+
* returned and after_id moves past them, so the next step must name each one; otherwise a model following `next`
|
|
15
|
+
* never sees them.
|
|
16
|
+
*/
|
|
17
|
+
function pendingStep(pending, batchRoot) {
|
|
18
|
+
if (!pending.length)
|
|
19
|
+
return "";
|
|
20
|
+
const described = pending.map((p) => {
|
|
21
|
+
const where = p.root_id === null ? "" : p.root_id === batchRoot ? " in this thread (beyond the batch limit)" : ` in another thread (root ${p.root_id})`;
|
|
22
|
+
return `message ${p.id}${where}`;
|
|
23
|
+
});
|
|
24
|
+
const many = pending.length > 1;
|
|
25
|
+
return `Also new and not included here: ${described.join(", ")}. after_id has moved past ${many ? "them" : "it"}, so ` +
|
|
26
|
+
`the next wait will not return ${many ? "them" : "it"}: read ${many ? "each" : "it"} with get_thread (or get_message) ` +
|
|
27
|
+
`${pending.map((p) => `"${p.id}"`).join(", ")} and reply if appropriate before waiting again.`;
|
|
10
28
|
}
|
|
11
29
|
export const registerWaitTools = (server, deps) => {
|
|
12
30
|
const maxWait = deps.config.waitMaxSeconds;
|
|
@@ -19,6 +37,9 @@ export const registerWaitTools = (server, deps) => {
|
|
|
19
37
|
"which is never older than the newest inbox message when the call started, so nothing is replayed (\"0\" means " +
|
|
20
38
|
"the inbox was empty and is safe to pass). Messages arriving on the same thread within settle_seconds are " +
|
|
21
39
|
"batched into ONE result; reply once, to the newest (reply_target_id); the result's next field says what to do. " +
|
|
40
|
+
"Messages on other threads that arrive during that window are not returned: they are listed in " +
|
|
41
|
+
"pending_other_threads and pending_ids, after_id moves past them, and next says to read them with get_thread " +
|
|
42
|
+
"or get_message before waiting again (a later wait will not return them). " +
|
|
22
43
|
"Your own messages, reactions and no-reply messages never qualify; they are listed in skipped. Each call blocks " +
|
|
23
44
|
`at most timeout_seconds (max ${maxWait}); stop looping when the user interrupts or the limits they set are reached. ` +
|
|
24
45
|
"In chat hosts each wait is a model turn: tell the user you are listening and for how long rather than " +
|
|
@@ -32,25 +53,26 @@ export const registerWaitTools = (server, deps) => {
|
|
|
32
53
|
settle_seconds: z.number().int().min(0).max(30).default(3).describe("after the first message, keep collecting same-thread messages for this long"),
|
|
33
54
|
include_thread: z.boolean().default(true).describe("include the assembled thread context of the newest message"),
|
|
34
55
|
}),
|
|
35
|
-
outputSchema:
|
|
36
|
-
status:
|
|
56
|
+
outputSchema: outputObject({
|
|
57
|
+
status: openEnum(["message", "timeout", "interrupted"], "interrupted: the server is restarting; call again with after_id"),
|
|
37
58
|
after_id: z.string().describe("pass this as after_id on the next call"),
|
|
38
59
|
thread_root_id: z.string().nullable(),
|
|
39
|
-
thread_topic: z.string().nullable().describe("the thread root's topic (replies carry none); null when unknown"),
|
|
60
|
+
thread_topic: z.string().nullable().optional().describe("the thread root's topic (replies carry none); null when unknown"),
|
|
40
61
|
reply_target_id: z.string().nullable().describe("newest message of the batch; reply to this one"),
|
|
41
62
|
messages: z.array(messageItem.extend({ body: z.string().nullable() })),
|
|
42
|
-
pending_other_threads: z.array(
|
|
43
|
-
|
|
63
|
+
pending_other_threads: z.array(outputObject({ id: z.string(), from: z.string(), root_id: z.string().nullable() })).describe("new messages not returned here (other threads, or beyond the batch limit); after_id has moved past them, so read each with get_thread or get_message"),
|
|
64
|
+
pending_ids: z.array(z.string()).optional().describe("ids of pending_other_threads, oldest first: read these before waiting again"),
|
|
65
|
+
skipped: z.array(outputObject({
|
|
44
66
|
id: z.string(),
|
|
45
|
-
reason:
|
|
46
|
-
from: z.string(),
|
|
67
|
+
reason: openEnum(["own", "reaction", "no_reply", "from_mismatch", "other_thread"]),
|
|
68
|
+
from: z.string().optional(),
|
|
47
69
|
emoji: z.string().optional().describe("for reactions: the emoji (\"\" clears a reaction)"),
|
|
48
70
|
reaction_to: z.string().nullable().optional().describe("for reactions: the message reacted to"),
|
|
49
71
|
})).describe("messages deliberately passed over; after_id has advanced past them"),
|
|
50
|
-
unclassified: z.array(
|
|
51
|
-
transport:
|
|
72
|
+
unclassified: z.array(outputObject({ id: z.string(), from: z.string(), error: z.string() })).describe("messages whose thread could not be determined; after_id is held before them, call again to retry"),
|
|
73
|
+
transport: openEnum(["websocket", "poll"]),
|
|
52
74
|
note: z.string().nullable(),
|
|
53
|
-
next: z.string().describe("what to do with this result"),
|
|
75
|
+
next: z.string().optional().describe("what to do with this result"),
|
|
54
76
|
...untrustedNotice,
|
|
55
77
|
}),
|
|
56
78
|
annotations: { ...READ_ONLY, idempotentHint: false },
|
|
@@ -77,13 +99,16 @@ export const registerWaitTools = (server, deps) => {
|
|
|
77
99
|
const messages = await Promise.all(result.messages.map(async (m) => ({ ...toItem(m, caller.address), body: await caller.client.getText(m, signal) })));
|
|
78
100
|
const newest = result.messages[result.messages.length - 1];
|
|
79
101
|
const keepWaiting = `call wait_for_message with after_id "${result.after_id}"${thread_of !== undefined ? ` and thread_of "${thread_of}"` : ""} to keep listening`;
|
|
102
|
+
const pending = [...result.pending_other_threads].sort((a, b) => compareMessageIds(a.id, b.id));
|
|
103
|
+
const alsoNew = pendingStep(pending, result.thread_root_id);
|
|
104
|
+
const thenWait = alsoNew ? `${alsoNew} Then ${keepWaiting}.` : "";
|
|
80
105
|
const next = result.status === "interrupted"
|
|
81
106
|
? `The server is restarting; ${keepWaiting}. No messages are lost.`
|
|
82
107
|
: !newest
|
|
83
108
|
? `No new message yet; ${keepWaiting}, unless the user's time limit is reached.`
|
|
84
109
|
: newest.terminal
|
|
85
|
-
? `Message ${newest.id} is terminal and cannot be replied to
|
|
86
|
-
: `Reply to message ${newest.id} with the reply tool
|
|
110
|
+
? `Message ${newest.id} is terminal and cannot be replied to.${thenWait ? ` ${thenWait}` : ` Next, ${keepWaiting}.`}`
|
|
111
|
+
: `Reply to message ${newest.id} with the reply tool.${thenWait ? ` ${thenWait}` : ` Then ${keepWaiting}.`}`;
|
|
87
112
|
const structured = {
|
|
88
113
|
status: result.status,
|
|
89
114
|
after_id: result.after_id,
|
|
@@ -91,7 +116,8 @@ export const registerWaitTools = (server, deps) => {
|
|
|
91
116
|
thread_topic: result.thread_topic,
|
|
92
117
|
reply_target_id: newest?.id ?? null,
|
|
93
118
|
messages,
|
|
94
|
-
pending_other_threads:
|
|
119
|
+
pending_other_threads: pending,
|
|
120
|
+
pending_ids: pending.map((p) => p.id),
|
|
95
121
|
skipped: result.skipped,
|
|
96
122
|
unclassified: result.unclassified,
|
|
97
123
|
transport: result.transport,
|
|
@@ -112,9 +138,6 @@ export const registerWaitTools = (server, deps) => {
|
|
|
112
138
|
];
|
|
113
139
|
if (reactions.length)
|
|
114
140
|
lines.push(`Skipped reactions:\n${messageData(reactions.join("\n"))}`);
|
|
115
|
-
if (result.pending_other_threads.length) {
|
|
116
|
-
lines.push(`Also waiting on other threads: ${result.pending_other_threads.map((p) => p.id).join(", ")}`);
|
|
117
|
-
}
|
|
118
141
|
if (result.unclassified.length) {
|
|
119
142
|
lines.push(`Could not classify ${result.unclassified.map((u) => u.id).join(", ")}; after_id is held before them, call again to retry.`);
|
|
120
143
|
}
|
|
@@ -132,7 +155,7 @@ export const registerWaitTools = (server, deps) => {
|
|
|
132
155
|
lines.push("", DATA_NOT_INSTRUCTIONS);
|
|
133
156
|
for (const m of messages)
|
|
134
157
|
if (m.body !== null)
|
|
135
|
-
lines.push("", `--- message ${m.id} from ${
|
|
158
|
+
lines.push("", `--- message ${m.id} from ${addressText(m.from)} ---`, fence(m.body));
|
|
136
159
|
lines.push("", "End of message data.");
|
|
137
160
|
lines.push("", next);
|
|
138
161
|
}
|
package/docs/http-deployment.md
CHANGED
|
@@ -63,6 +63,14 @@ The metadata routes are public in `oauth` and `oauth+api-key` modes and must rea
|
|
|
63
63
|
MCP authorization discovery. In API-key mode they return 404. When OAuth is configured, its resource URL supplies
|
|
64
64
|
the public same-origin value, so explicitly listing that origin is optional.
|
|
65
65
|
|
|
66
|
+
## Upgrades and cached tool schemas
|
|
67
|
+
|
|
68
|
+
Chat hosts may cache each tool's `outputSchema` from when the connector was added and validate
|
|
69
|
+
results against it. Output schemas are open and additive (see "Output schema compatibility" in the
|
|
70
|
+
README), so later upgrades keep validating. Schemas published by 0.2.6 and earlier were closed: after
|
|
71
|
+
upgrading from those releases, users whose host reports that structured content does not match the
|
|
72
|
+
tool's output schema must reconnect (remove and re-add) the connector once.
|
|
73
|
+
|
|
66
74
|
## Binary attachment downloads
|
|
67
75
|
|
|
68
76
|
`get_attachment_download_url` returns a resource link such as
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markmnl/fmsg-mcp",
|
|
3
3
|
"mcpName": "io.github.markmnl/fmsg-mcp",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.8",
|
|
5
5
|
"description": "MCP server for fmsg: send and receive federated messages from any AI agent via a deployed fmsg Web API",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
package/server.json
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
"url": "https://github.com/markmnl/fmsg-mcp",
|
|
9
9
|
"source": "github"
|
|
10
10
|
},
|
|
11
|
-
"version": "0.2.
|
|
11
|
+
"version": "0.2.8",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
16
16
|
"identifier": "@markmnl/fmsg-mcp",
|
|
17
|
-
"version": "0.2.
|
|
17
|
+
"version": "0.2.8",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
},
|