@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.
@@ -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 = z.object({
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.join(", ")} at ${structured.time ?? "?"}` +
78
- (sent.attachments.length ? ` with ${sent.attachments.map((a) => a.filename).join(", ")}` : "") +
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 text = `Sent reply ${sent.id} to message ${parent.id} for ${to.join(", ")} at ${structured.time ?? "?"}` +
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: z.object({
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.join(", ")}`, { ...result, recipients: addresses, add_to: 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: z.object({ id: z.string(), reaction_id: z.string().nullable(), time: z.string().nullable(), cleared: z.boolean() }),
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;
@@ -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 ${headerValue(s.from)}: ${s.emoji ? `reacted ${headerValue(s.emoji)}` : "cleared a reaction"}${s.reaction_to ? ` on message ${s.reaction_to}` : ""}`);
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: z.object({
36
- status: z.enum(["message", "timeout", "interrupted"]).describe("interrupted: the server is restarting; call again with after_id"),
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(z.object({ id: z.string(), from: z.string(), root_id: z.string().nullable() })),
43
- skipped: z.array(z.object({
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: z.enum(["own", "reaction", "no_reply", "from_mismatch", "other_thread"]),
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(z.object({ 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"),
51
- transport: z.enum(["websocket", "poll"]),
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; ${keepWaiting}.`
86
- : `Reply to message ${newest.id} with the reply tool, then ${keepWaiting}.`;
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: result.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 ${headerValue(m.from)} ---`, fence(m.body));
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
  }
@@ -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.6",
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.6",
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.6",
17
+ "version": "0.2.8",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },