@markmnl/fmsg-mcp 0.1.3 → 0.1.4

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.
@@ -6,6 +6,7 @@
6
6
  /** Validate and normalise an id (number or digit string) to its decimal string form. */
7
7
  export declare function normalizeMessageId(value: unknown, label?: string): string;
8
8
  export declare function compareMessageIds(a: string, b: string): number;
9
+ export declare function minMessageId(ids: Iterable<string>): string | undefined;
9
10
  export declare function maxMessageId(ids: Iterable<string>): string | undefined;
10
11
  /**
11
12
  * Parse fmsg JSON, converting id fields to exact decimal strings using the
@@ -27,6 +27,13 @@ export function compareMessageIds(a, b) {
27
27
  const y = BigInt(b);
28
28
  return x < y ? -1 : x > y ? 1 : 0;
29
29
  }
30
+ export function minMessageId(ids) {
31
+ let best;
32
+ for (const id of ids)
33
+ if (best === undefined || compareMessageIds(id, best) < 0)
34
+ best = id;
35
+ return best;
36
+ }
30
37
  export function maxMessageId(ids) {
31
38
  let best;
32
39
  for (const id of ids)
@@ -30,6 +30,8 @@ export const registerWaitTools = (server, deps) => {
30
30
  reply_target_id: z.string().nullable().describe("newest message of the batch; reply to this one"),
31
31
  messages: z.array(messageItem.extend({ body: z.string().nullable() })),
32
32
  pending_other_threads: z.array(z.object({ id: z.string(), from: z.string(), root_id: z.string().nullable() })),
33
+ skipped: z.array(z.object({ id: z.string(), reason: z.enum(["own", "reaction", "no_reply", "from_mismatch", "other_thread"]) })).describe("messages deliberately passed over; after_id has advanced past them"),
34
+ 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"),
33
35
  transport: z.enum(["websocket", "poll"]),
34
36
  note: z.string().nullable(),
35
37
  }),
@@ -62,6 +64,8 @@ export const registerWaitTools = (server, deps) => {
62
64
  reply_target_id: newest?.id ?? null,
63
65
  messages,
64
66
  pending_other_threads: result.pending_other_threads,
67
+ skipped: result.skipped,
68
+ unclassified: result.unclassified,
65
69
  transport: result.transport,
66
70
  note: result.note,
67
71
  };
@@ -75,6 +79,9 @@ export const registerWaitTools = (server, deps) => {
75
79
  if (result.pending_other_threads.length) {
76
80
  lines.push(`Also waiting on other threads: ${result.pending_other_threads.map((p) => `${p.id} from ${p.from}`).join(", ")}`);
77
81
  }
82
+ if (result.unclassified.length) {
83
+ lines.push(`Could not classify ${result.unclassified.map((u) => `${u.id} from ${u.from}`).join(", ")}; after_id is held before them, call again to retry.`);
84
+ }
78
85
  if (result.note)
79
86
  lines.push(`Note: ${result.note}`);
80
87
  if (include_thread && newest) {
package/dist/wait.d.ts CHANGED
@@ -21,12 +21,26 @@ export type Pending = {
21
21
  from: string;
22
22
  root_id: string | null;
23
23
  };
24
+ export type SkipReason = "own" | "reaction" | "no_reply" | "from_mismatch" | "other_thread";
25
+ export type Skipped = {
26
+ id: string;
27
+ reason: SkipReason;
28
+ };
29
+ export type Unclassified = {
30
+ id: string;
31
+ from: string;
32
+ error: string;
33
+ };
24
34
  export type WaitResult = {
25
35
  status: "message" | "timeout";
26
36
  after_id: string;
27
37
  thread_root_id: string | null;
28
38
  messages: FmsgMessage[];
29
39
  pending_other_threads: Pending[];
40
+ /** Messages deliberately passed over (the cursor advances past these). */
41
+ skipped: Skipped[];
42
+ /** Messages whose thread could not be determined; the cursor never advances past these. */
43
+ unclassified: Unclassified[];
30
44
  transport: "websocket" | "poll";
31
45
  note: string | null;
32
46
  };
package/dist/wait.js CHANGED
@@ -1,4 +1,4 @@
1
- import { compareMessageIds, maxMessageId } from "./client/message-id.js";
1
+ import { compareMessageIds, maxMessageId, minMessageId } from "./client/message-id.js";
2
2
  import { openFmsgWebSocket, parseWsEvent } from "./client/ws.js";
3
3
  /**
4
4
  * Block until the next qualifying inbound message (plus any that arrive on the
@@ -18,47 +18,73 @@ export async function waitForMessage(client, self, options, signal, deps = {}) {
18
18
  floor = newest?.id ?? "0";
19
19
  }
20
20
  let skippedMax = floor;
21
+ let finished = false;
21
22
  const rootCache = new Map();
22
- const rootOf = async (id) => {
23
- if (rootCache.has(id))
24
- return rootCache.get(id);
25
- let root = null;
23
+ const lookupRoot = async (id) => {
26
24
  try {
27
- root = (await client.getThreadMessages(id, signal)).root_id;
25
+ return (await client.getThreadMessages(id, signal)).root_id;
28
26
  }
29
- catch {
30
- // Fall back to a bounded pid walk.
27
+ catch (error) {
28
+ // Fall back to a bounded pid walk; any failure here propagates as "unknown".
29
+ let cur = id;
30
+ for (let i = 0; i < 100; i++) {
31
+ const m = await client.getMessage(cur, signal);
32
+ if (!m.pid)
33
+ return m.id;
34
+ cur = m.pid;
35
+ }
36
+ throw error;
37
+ }
38
+ };
39
+ /**
40
+ * Resolve a message's thread root. A lookup can fail transiently (the host's
41
+ * WebSocket announces a message slightly before it is readable), so retry a
42
+ * few times; if it still fails, throw rather than guess: a message whose
43
+ * thread is unknown must never be mistaken for one on another thread.
44
+ */
45
+ const rootOf = async (id, attempts = 3) => {
46
+ const cached = rootCache.get(id);
47
+ if (cached !== undefined)
48
+ return cached;
49
+ let lastError;
50
+ for (let i = 0; i < attempts; i++) {
51
+ if (signal?.aborted || finished)
52
+ break;
31
53
  try {
32
- let cur = id;
33
- for (let i = 0; i < 100; i++) {
34
- const m = await client.getMessage(cur, signal);
35
- if (!m.pid) {
36
- root = m.id;
37
- break;
38
- }
39
- cur = m.pid;
40
- }
54
+ const root = await lookupRoot(id);
55
+ rootCache.set(id, root);
56
+ return root;
41
57
  }
42
- catch {
43
- root = null;
58
+ catch (error) {
59
+ lastError = error;
60
+ if (i + 1 < attempts)
61
+ await new Promise((r) => setTimeout(r, 400 * (i + 1)));
44
62
  }
45
63
  }
46
- rootCache.set(id, root);
47
- return root;
64
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
48
65
  };
49
- const targetRoot = options.threadOf ? await rootOf(options.threadOf) : undefined;
50
- if (options.threadOf && targetRoot === null)
51
- throw new Error(`could not determine the thread of message ${options.threadOf}`);
66
+ let targetRoot;
67
+ if (options.threadOf) {
68
+ try {
69
+ targetRoot = await rootOf(options.threadOf, 1);
70
+ }
71
+ catch {
72
+ throw new Error(`could not determine the thread of message ${options.threadOf}`);
73
+ }
74
+ }
52
75
  const seen = new Set();
53
76
  const batch = [];
54
77
  const pending = [];
78
+ const skipped = [];
79
+ const unclassified = [];
80
+ /** Messages whose thread lookup is still running; if the call ends first they count as unclassified. */
81
+ const inflight = new Map();
55
82
  let batchRoot = null;
56
83
  let transport = "websocket";
57
84
  let note = null;
58
85
  let socket;
59
86
  let pollTimer;
60
87
  let settleTimer;
61
- let finished = false;
62
88
  return new Promise((resolve, reject) => {
63
89
  const cleanup = () => {
64
90
  finished = true;
@@ -81,13 +107,28 @@ export async function waitForMessage(client, self, options, signal, deps = {}) {
81
107
  if (finished)
82
108
  return;
83
109
  cleanup();
110
+ for (const [id, from] of inflight)
111
+ unclassified.push({ id, from, error: "thread lookup did not complete before the call returned" });
112
+ inflight.clear();
84
113
  const ids = batch.map((m) => m.id);
114
+ // The cursor advances only over messages returned or deliberately skipped,
115
+ // and never past a message whose thread could not be determined.
116
+ let afterId = maxMessageId([...ids, skippedMax, floor]);
117
+ const firstUnknown = unclassified.length ? minMessageId(unclassified.map((u) => u.id)) : undefined;
118
+ if (firstUnknown !== undefined && compareMessageIds(afterId, firstUnknown) >= 0) {
119
+ const before = (BigInt(firstUnknown) - 1n).toString();
120
+ afterId = compareMessageIds(before, floor) > 0 ? before : floor;
121
+ const held = `cursor held at ${afterId}: could not determine the thread of ${unclassified.map((u) => u.id).join(", ")}; call again to retry`;
122
+ note = note ? `${note}; ${held}` : held;
123
+ }
85
124
  resolve({
86
125
  status: batch.length ? "message" : "timeout",
87
- after_id: batch.length ? maxMessageId(ids) : skippedMax,
126
+ after_id: afterId,
88
127
  thread_root_id: batchRoot,
89
128
  messages: [...batch].sort((a, b) => compareMessageIds(a.id, b.id)),
90
129
  pending_other_threads: pending,
130
+ skipped: [...skipped].sort((a, b) => compareMessageIds(a.id, b.id)),
131
+ unclassified: [...unclassified].sort((a, b) => compareMessageIds(a.id, b.id)),
91
132
  transport,
92
133
  note,
93
134
  });
@@ -117,23 +158,36 @@ export async function waitForMessage(client, self, options, signal, deps = {}) {
117
158
  seen.add(m.id);
118
159
  if (compareMessageIds(m.id, floor) <= 0)
119
160
  return;
120
- const disqualified = m.from.toLowerCase() === me ||
121
- (m.reaction !== null && m.reaction !== undefined) ||
122
- m.no_reply === true ||
123
- (wantFrom !== undefined && m.from.toLowerCase() !== wantFrom);
124
- if (disqualified) {
161
+ const skip = (reason) => {
162
+ skipped.push({ id: m.id, reason });
125
163
  if (compareMessageIds(m.id, skippedMax) > 0)
126
164
  skippedMax = m.id;
165
+ };
166
+ if (m.from.toLowerCase() === me)
167
+ return skip("own");
168
+ if (m.reaction !== null && m.reaction !== undefined)
169
+ return skip("reaction");
170
+ if (m.no_reply === true)
171
+ return skip("no_reply");
172
+ if (wantFrom !== undefined && m.from.toLowerCase() !== wantFrom)
173
+ return skip("from_mismatch");
174
+ let root;
175
+ inflight.set(m.id, m.from);
176
+ try {
177
+ root = await rootOf(m.id);
178
+ }
179
+ catch (error) {
180
+ if (!finished)
181
+ unclassified.push({ id: m.id, from: m.from, error: error instanceof Error ? error.message : String(error) });
127
182
  return;
128
183
  }
129
- const root = await rootOf(m.id);
184
+ finally {
185
+ inflight.delete(m.id);
186
+ }
130
187
  if (finished)
131
188
  return;
132
- if (targetRoot !== undefined && root !== targetRoot) {
133
- if (compareMessageIds(m.id, skippedMax) > 0)
134
- skippedMax = m.id;
135
- return;
136
- }
189
+ if (targetRoot !== undefined && root !== targetRoot)
190
+ return skip("other_thread");
137
191
  if (batch.length === 0) {
138
192
  batchRoot = root;
139
193
  batch.push(m);
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.1.3",
4
+ "version": "0.1.4",
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
@@ -6,13 +6,13 @@
6
6
  "url": "https://github.com/markmnl/fmsg-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.1.3",
9
+ "version": "0.1.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "registryBaseUrl": "https://registry.npmjs.org",
14
14
  "identifier": "@markmnl/fmsg-mcp",
15
- "version": "0.1.3",
15
+ "version": "0.1.4",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },