@markmnl/fmsg-mcp 1.3.0 → 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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - FMSG-006 indicators. The new `indicate` tool shows the other participants of a message a short-lived
6
+ status such as ⏳ or ✍️, refreshed at half its ttl (every 1–30 s) and cleared when the agent replies in
7
+ that thread, reacts, marks it read, after 5 minutes, or when the session ends; an empty emoji clears it
8
+ now. Nothing is shown automatically: reading a message never starts one. Refreshes and automatic clears
9
+ are best effort and never fail a tool. `FmsgClient.indicate()` posts to `/fmsg/{id}/indicate`;
10
+ `createFmsgMcpServer` takes an optional shared `indicators` registry for per-request HTTP factories.
5
11
  - Presigned links for hosts that cannot send the connection's Authorization header with their own HTTP
6
12
  requests: `get_attachment_download_url` with `presigned: true`, and the new `get_attachment_upload_url` for
7
13
  draft files. Each link is single-use, valid for 5 minutes and bound to one file's size and sha256 (`/mcp/links/{token}`),
package/README.md CHANGED
@@ -150,6 +150,7 @@ See the [TLS reverse-proxy example](docs/http-deployment.md) for a loopback depl
150
150
  | `delete_draft` | Discard an unsent draft |
151
151
  | `add_recipients` | Add `recipients` to a sent message (`add_to` is a deprecated alias) |
152
152
  | `react` | Set or clear your emoji reaction |
153
+ | `indicate` | Show the other participants a short-lived status on a message, such as ⏳ while working on a reply or ✍️ while composing (FMSG-006 indicator; `ttl` 1–120 s, default 60), kept up until you reply, react or mark it read (up to 5 minutes); an empty `emoji` clears it. Reading a message never shows one |
153
154
  | `mark_read` | Mark received messages read |
154
155
  | `download_attachment` | Fetch a small attachment inline: text as text, images as image blocks, other files as base64 resources; a wrong filename lists the names the message has |
155
156
  | `get_attachment_download_url` | Get a credential-free URL and resource link for an authenticated binary download; with `presigned: true`, also a single-use link valid for 5 minutes that needs no Authorization header, with its size, sha256 and a curl command. HTTP only, requires a public MCP URL |
@@ -179,6 +180,21 @@ required. Releases add fields and values; they do not remove or rename them. Sch
179
180
  0.2.6 and earlier were closed, so a host that cached one rejects results with newer fields (for
180
181
  example "must NOT have additional properties"); remove and re-add the connector once to refresh it.
181
182
 
183
+ ### Indicators
184
+
185
+ `indicate` shows a message's other participants a short-lived status, such as ⏳ while the agent
186
+ works on a reply or ✍️ while composing: an
187
+ [FMSG-006](https://github.com/markmnl/fmsg/blob/main/standards/fmsg-006-indicators.md) indicator.
188
+ Nothing is shown unless the agent calls it; reading a message never does. It is sent at once with
189
+ its `ttl` (default 60 s) and refreshed at half the ttl (every 1–30 s) until the agent replies to
190
+ that message or anywhere in its thread (`reply`, or `send_draft` of a reply), reacts to it, marks it
191
+ read, clears it with an empty `emoji`, or after 5 minutes, and when the session ends or the server
192
+ shuts down. At most 10 are kept up per address, the oldest cleared first. `indicate` returns the
193
+ host's refusal (such as a terminal message, or a host without indicators); refreshes and automatic
194
+ clears are best effort and never fail another tool. Over HTTP each MCP request gets a fresh server
195
+ instance, so indicators are kept per address by the HTTP server; embedders that build one server per
196
+ request pass a shared `IndicatorRegistry` as `indicators`.
197
+
182
198
  Resources `fmsg://message/{id}` and `fmsg://thread/{id}` expose the same content to hosts that
183
199
  attach resources; prompts `chat` and `reply` script the wait → reply loop and a guided reply, and
184
200
  `send_file` sends files by the most efficient route the server offers.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.3.0",
3
- "commit": "2e7b43195140e7ca0163084a3175ddd97baef3f8",
4
- "describe": "v1.3.0"
2
+ "version": "1.4.0",
3
+ "commit": "36e8dafb2afbee867f5518c63b662cd500f24ddb",
4
+ "describe": "v1.4.0"
5
5
  }
@@ -90,6 +90,12 @@ export declare class FmsgClient {
90
90
  added: number;
91
91
  }>;
92
92
  react(id: string, emoji: string | null, signal?: AbortSignal): Promise<ReactResult>;
93
+ /**
94
+ * Show an FMSG-006 indicator, a short-lived emoji such as ⏳, on a message to its other participants for `ttl`
95
+ * seconds (1 to 120; the host clamps it). An empty emoji or a ttl of 0 clears it. The host answers 202 with no body
96
+ * and may drop indicators set more than once a second on one message. Hosts without indicators answer 404 or 405.
97
+ */
98
+ indicate(id: string, emoji: string, ttl: number, signal?: AbortSignal): Promise<void>;
93
99
  downloadAttachment(id: string, filename: string, signal?: AbortSignal, maxBytes?: number): Promise<{
94
100
  data: Uint8Array;
95
101
  contentType?: string;
@@ -307,6 +307,21 @@ export class FmsgClient {
307
307
  time: result.time ?? null,
308
308
  };
309
309
  }
310
+ /**
311
+ * Show an FMSG-006 indicator, a short-lived emoji such as ⏳, on a message to its other participants for `ttl`
312
+ * seconds (1 to 120; the host clamps it). An empty emoji or a ttl of 0 clears it. The host answers 202 with no body
313
+ * and may drop indicators set more than once a second on one message. Hosts without indicators answer 404 or 405.
314
+ */
315
+ async indicate(id, emoji, ttl, signal) {
316
+ const mid = normalizeMessageId(id);
317
+ const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/indicate`, {
318
+ method: "POST",
319
+ headers: { "content-type": "application/json" },
320
+ body: JSON.stringify({ emoji, ttl: emoji ? ttl : 0 }),
321
+ ...(signal ? { signal } : {}),
322
+ });
323
+ await response.body?.cancel();
324
+ }
310
325
  async downloadAttachment(id, filename, signal, maxBytes) {
311
326
  const { stream, contentType } = await this.streamAttachment(id, filename, signal);
312
327
  const { data } = await readBytes(stream, maxBytes);
package/dist/http.js CHANGED
@@ -17,6 +17,7 @@ import { BUILD, VERSION } from "./version.js";
17
17
  import { safeErrorMessage } from "./client/redact.js";
18
18
  import { isLoopbackHost, normalizeOrigin } from "./client/url.js";
19
19
  import { LINK_PATH, PresignedLinks } from "./links.js";
20
+ import { IndicatorRegistry } from "./indicators.js";
20
21
  import { createHash } from "node:crypto";
21
22
  export const MCP_PATH = "/mcp";
22
23
  /** How long close() lets in-flight requests finish (waits end at once) before cutting them off. */
@@ -235,12 +236,20 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
235
236
  const shutdown = new AbortController();
236
237
  // Presigned links need the public URL to build them; they carry their own authorization.
237
238
  const links = downloadBaseUrl(config) ? new PresignedLinks(downloadBaseUrl(config), config.http.stateSecret) : undefined;
238
- const mcpHandler = (provider, address) => createMcpHandler(({ authInfo }) => createFmsgMcpServer(provider, config, {
239
- ...(authInfo ? { address: address(authInfo) } : {}),
240
- ...(typeof authInfo?.extra?.owner === "string" ? { owner: authInfo.extra.owner } : {}),
241
- shutdown: shutdown.signal,
242
- ...(links ? { links } : {}),
243
- }), { onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) });
239
+ // Each MCP request gets its own server instance, so indicators kept up between requests live here, one registry
240
+ // per provider so that combined mode never shares a caller's indicators across authentication paths.
241
+ const registries = [];
242
+ const mcpHandler = (provider, address) => {
243
+ const indicators = new IndicatorRegistry();
244
+ registries.push(indicators);
245
+ return createMcpHandler(({ authInfo }) => createFmsgMcpServer(provider, config, {
246
+ ...(authInfo ? { address: address(authInfo) } : {}),
247
+ ...(typeof authInfo?.extra?.owner === "string" ? { owner: authInfo.extra.owner } : {}),
248
+ shutdown: shutdown.signal,
249
+ ...(links ? { links } : {}),
250
+ indicators,
251
+ }), { onerror: (error) => safeLog(`MCP transport failed: ${error instanceof Error ? error.message : String(error)}`) });
252
+ };
244
253
  const oauthHandler = oauthProvider && mcpHandler(oauthProvider, (authInfo) => String(authInfo.extra?.address ?? ""));
245
254
  const apiKeyHandler = apiKeyProvider && mcpHandler(apiKeyProvider, (authInfo) => authInfo.clientId);
246
255
  const active = new Map();
@@ -541,6 +550,8 @@ export function createHttpServer(config, log = (l) => console.error(l)) {
541
550
  const close = ({ graceMs = SHUTDOWN_GRACE_MS } = {}) => closing ??= (async () => {
542
551
  // Waits answer "interrupted" with a resumable cursor; new requests get 503.
543
552
  shutdown.abort();
553
+ for (const indicators of registries)
554
+ indicators.close();
544
555
  const closed = new Promise((resolve) => server.close(() => resolve()));
545
556
  server.closeIdleConnections();
546
557
  let timer;
@@ -0,0 +1,35 @@
1
+ import type { Caller } from "./context.js";
2
+ /** The emoji indicate shows by default: working on a reply. */
3
+ export declare const WORKING_EMOJI = "\u23F3";
4
+ export type IndicatorOptions = {
5
+ /** How often an indicator is re-sent (default 30 s); never more often than half its ttl allows, nor below 1 s. */
6
+ refreshMs?: number;
7
+ /** How long an indicator is kept up before it is cleared (default 5 minutes). */
8
+ maxMs?: number;
9
+ /** Most indicators kept up at once per caller (default 10); the oldest is cleared to make room. */
10
+ maxActive?: number;
11
+ /** Debug log for swallowed failures; nothing is logged by default. */
12
+ log?: (line: string) => void;
13
+ };
14
+ export declare class IndicatorRegistry {
15
+ private readonly callers;
16
+ private readonly settings;
17
+ private closed;
18
+ constructor(options?: IndicatorOptions);
19
+ /** The caller's indicators, if any, switched to its current client (a renewed OAuth token brings a new one). */
20
+ private existing;
21
+ private of;
22
+ /** After a reply was sent to `parentId`: stop what is shown on it and anywhere in its thread. */
23
+ replied(caller: Caller, parentId: string, rootId: string | null): void;
24
+ /** After a reaction or mark_read: the caller has handled these messages. */
25
+ handled(caller: Caller, ids: string[]): void;
26
+ /**
27
+ * The indicate tool: show `emoji` and keep it up, or clear with an empty one. Throws the host's refusal. `rootId` is
28
+ * the message's thread root, when known, so a reply anywhere in that thread stops it.
29
+ */
30
+ indicate(caller: Caller, id: string, emoji: string, ttl: number, rootId?: string | null): Promise<void>;
31
+ /** Message ids with an indicator kept up for this caller. */
32
+ active(caller: Caller): string[];
33
+ /** Stop every indicator, clearing them in the background. Idempotent. */
34
+ close(): void;
35
+ }
@@ -0,0 +1,186 @@
1
+ import { FmsgHttpError } from "./client/errors.js";
2
+ /** The emoji indicate shows by default: working on a reply. */
3
+ export const WORKING_EMOJI = "⏳";
4
+ /** A 4xx other than 401 (renewed by the client), 408 or 429 will not succeed on a retry; neither will a closed client. */
5
+ function permanent(error) {
6
+ if (error instanceof FmsgHttpError)
7
+ return error.status >= 400 && error.status < 500 && ![401, 408, 429].includes(error.status);
8
+ return error instanceof Error && error.name === "AbortError";
9
+ }
10
+ function describe(error) {
11
+ return error instanceof Error ? error.message : String(error);
12
+ }
13
+ /** The indicators one caller shows. Sends for one message are serialized so a clear never overtakes a refresh. */
14
+ class CallerIndicators {
15
+ client;
16
+ settings;
17
+ onIdle;
18
+ entries = new Map();
19
+ tails = new Map();
20
+ constructor(client, settings, onIdle) {
21
+ this.client = client;
22
+ this.settings = settings;
23
+ this.onIdle = onIdle;
24
+ }
25
+ get idle() { return this.entries.size === 0 && this.tails.size === 0; }
26
+ ids() { return [...this.entries.keys()]; }
27
+ has(id) { return this.entries.has(id); }
28
+ enqueue(id, emoji, ttl) {
29
+ const previous = this.tails.get(id) ?? Promise.resolve();
30
+ const sent = previous.then(() => this.client.indicate(id, emoji, ttl));
31
+ const settled = sent.then(() => undefined, () => undefined);
32
+ this.tails.set(id, settled);
33
+ void settled.then(() => {
34
+ if (this.tails.get(id) === settled)
35
+ this.tails.delete(id);
36
+ if (this.idle)
37
+ this.onIdle();
38
+ });
39
+ return sent;
40
+ }
41
+ /** Send in the background, dropping the indicator when the host will never take it. */
42
+ sendQuietly(entry, emoji, ttl) {
43
+ this.enqueue(entry.id, emoji, ttl).catch((error) => {
44
+ this.settings.log(`indicator on ${entry.id} failed: ${describe(error)}`);
45
+ if (permanent(error) && this.entries.get(entry.id) === entry)
46
+ this.stop(entry.id, false);
47
+ });
48
+ }
49
+ schedule(entry) {
50
+ const remaining = this.settings.maxMs - (Date.now() - entry.startedAt);
51
+ entry.timer = setTimeout(() => this.tick(entry), Math.max(0, Math.min(entry.refreshMs, remaining)));
52
+ entry.timer.unref?.();
53
+ }
54
+ tick(entry) {
55
+ if (this.entries.get(entry.id) !== entry)
56
+ return;
57
+ if (Date.now() - entry.startedAt >= this.settings.maxMs) {
58
+ this.stop(entry.id);
59
+ return;
60
+ }
61
+ this.sendQuietly(entry, entry.emoji, entry.ttl);
62
+ this.schedule(entry);
63
+ }
64
+ add(id, emoji, ttl, rootId) {
65
+ this.stop(id, false);
66
+ while (this.entries.size >= this.settings.maxActive) {
67
+ const oldest = this.entries.keys().next().value;
68
+ if (oldest === undefined)
69
+ break;
70
+ this.stop(oldest);
71
+ }
72
+ const refreshMs = Math.max(1000, Math.min(this.settings.refreshMs, Math.floor((ttl * 1000) / 2)));
73
+ const entry = { id, emoji, ttl, refreshMs, rootId, startedAt: Date.now() };
74
+ this.entries.set(id, entry);
75
+ this.schedule(entry);
76
+ return entry;
77
+ }
78
+ /**
79
+ * Show `emoji` now, failing if the host refuses it, then keep it up. The send is queued before the old indicator stops
80
+ * so this caller never looks idle (and is dropped from the registry) while replacing one.
81
+ */
82
+ async set(id, emoji, ttl, rootId) {
83
+ rootId ??= this.entries.get(id)?.rootId ?? null;
84
+ const sent = this.enqueue(id, emoji, ttl);
85
+ this.stop(id, false);
86
+ await sent;
87
+ this.add(id, emoji, ttl, rootId);
88
+ }
89
+ /** Clear now, failing if the host refuses it. */
90
+ async clear(id) {
91
+ const sent = this.enqueue(id, "", 0);
92
+ this.stop(id, false);
93
+ await sent;
94
+ }
95
+ /** Stop refreshing; with `clear`, also clear the indicator in the background. */
96
+ stop(id, clear = true) {
97
+ const entry = this.entries.get(id);
98
+ if (!entry)
99
+ return;
100
+ clearTimeout(entry.timer);
101
+ this.entries.delete(id);
102
+ if (clear) {
103
+ this.enqueue(id, "", 0).catch((error) => this.settings.log(`clearing indicator on ${id} failed: ${describe(error)}`));
104
+ }
105
+ else if (this.idle)
106
+ this.onIdle();
107
+ }
108
+ stopThread(rootId) {
109
+ for (const entry of [...this.entries.values()])
110
+ if (entry.rootId === rootId || entry.id === rootId)
111
+ this.stop(entry.id);
112
+ }
113
+ stopAll(clear) {
114
+ for (const id of [...this.entries.keys()])
115
+ this.stop(id, clear);
116
+ }
117
+ }
118
+ export class IndicatorRegistry {
119
+ callers = new Map();
120
+ settings;
121
+ closed = false;
122
+ constructor(options = {}) {
123
+ this.settings = {
124
+ refreshMs: options.refreshMs ?? 30_000,
125
+ maxMs: options.maxMs ?? 5 * 60_000,
126
+ maxActive: options.maxActive ?? 10,
127
+ log: options.log ?? (() => undefined),
128
+ };
129
+ }
130
+ /** The caller's indicators, if any, switched to its current client (a renewed OAuth token brings a new one). */
131
+ existing(caller) {
132
+ const indicators = this.callers.get(caller.address.toLowerCase());
133
+ if (indicators)
134
+ indicators.client = caller.client;
135
+ return indicators;
136
+ }
137
+ of(caller) {
138
+ const key = caller.address.toLowerCase();
139
+ let indicators = this.existing(caller);
140
+ if (!indicators) {
141
+ const created = new CallerIndicators(caller.client, this.settings, () => {
142
+ if (this.callers.get(key) === created && created.idle)
143
+ this.callers.delete(key);
144
+ });
145
+ indicators = created;
146
+ this.callers.set(key, created);
147
+ }
148
+ return indicators;
149
+ }
150
+ /** After a reply was sent to `parentId`: stop what is shown on it and anywhere in its thread. */
151
+ replied(caller, parentId, rootId) {
152
+ const indicators = this.existing(caller);
153
+ if (!indicators)
154
+ return;
155
+ indicators.stop(parentId);
156
+ if (rootId)
157
+ indicators.stopThread(rootId);
158
+ }
159
+ /** After a reaction or mark_read: the caller has handled these messages. */
160
+ handled(caller, ids) {
161
+ const indicators = this.existing(caller);
162
+ for (const id of ids)
163
+ indicators?.stop(id);
164
+ }
165
+ /**
166
+ * The indicate tool: show `emoji` and keep it up, or clear with an empty one. Throws the host's refusal. `rootId` is
167
+ * the message's thread root, when known, so a reply anywhere in that thread stops it.
168
+ */
169
+ async indicate(caller, id, emoji, ttl, rootId = null) {
170
+ if (!emoji)
171
+ return this.of(caller).clear(id);
172
+ if (this.closed)
173
+ return caller.client.indicate(id, emoji, ttl);
174
+ return this.of(caller).set(id, emoji, ttl, rootId);
175
+ }
176
+ /** Message ids with an indicator kept up for this caller. */
177
+ active(caller) {
178
+ return this.callers.get(caller.address.toLowerCase())?.ids() ?? [];
179
+ }
180
+ /** Stop every indicator, clearing them in the background. Idempotent. */
181
+ close() {
182
+ this.closed = true;
183
+ for (const indicators of this.callers.values())
184
+ indicators.stopAll(true);
185
+ }
186
+ }
@@ -37,7 +37,9 @@ export function buildInstructions(ctx = {}) {
37
37
  "added to a message after it was sent; they can read it and its attachments.",
38
38
  "To check the inbox: list_messages with unread_only, get_thread on what matters, act or reply, then mark_read " +
39
39
  "what you have handled; reading does not mark a message read. There is no search: to find a thread by name, " +
40
- "find its first message by topic in list_messages, then get_thread on a later message for the conversation.",
40
+ "find its first message by topic in list_messages, then get_thread on a later message for the conversation. " +
41
+ "indicate can show the other participants a status such as ⏳ while you work on a reply (cleared when you reply, " +
42
+ "react or mark it read, or after 5 minutes); an empty emoji clears it.",
41
43
  filesParagraph(ctx.files),
42
44
  "Carry out the user's requested messaging task or authorized automation without repeatedly asking for " +
43
45
  "confirmation. Sending is immediate and sent messages cannot be edited or recalled. Ask the user only " +
@@ -9,7 +9,7 @@ export const TOOL_SCOPES = {
9
9
  get_thread: [READ_SCOPE], delivery_status: [READ_SCOPE], download_attachment: [READ_SCOPE],
10
10
  save_attachment: [READ_SCOPE], get_attachment_download_url: [READ_SCOPE], wait_for_message: [READ_SCOPE],
11
11
  send_message: [WRITE_SCOPE], reply: MESSAGING_SCOPES,
12
- mark_read: [WRITE_SCOPE], add_recipients: [WRITE_SCOPE], react: [WRITE_SCOPE],
12
+ mark_read: [WRITE_SCOPE], add_recipients: [WRITE_SCOPE], react: [WRITE_SCOPE], indicate: [WRITE_SCOPE],
13
13
  create_draft: MESSAGING_SCOPES, send_draft: MESSAGING_SCOPES, delete_draft: MESSAGING_SCOPES,
14
14
  get_attachment_upload_url: MESSAGING_SCOPES,
15
15
  };
package/dist/public.d.ts CHANGED
@@ -7,5 +7,6 @@ export { ApiKeyCallerProvider } from "./auth.js";
7
7
  export { waitForMessage, type WaitOptions, type WaitResult } from "./wait.js";
8
8
  export { assembleThread, renderThread, type AssembledThread } from "./thread.js";
9
9
  export { resolveAddress, resolveAddresses, normalizeFmsgAddress } from "./address.js";
10
+ export { IndicatorRegistry, type IndicatorOptions } from "./indicators.js";
10
11
  export { VERSION } from "./version.js";
11
12
  export * from "./client/index.js";
package/dist/public.js CHANGED
@@ -7,5 +7,6 @@ export { ApiKeyCallerProvider } from "./auth.js";
7
7
  export { waitForMessage } from "./wait.js";
8
8
  export { assembleThread, renderThread } from "./thread.js";
9
9
  export { resolveAddress, resolveAddresses, normalizeFmsgAddress } from "./address.js";
10
+ export { IndicatorRegistry } from "./indicators.js";
10
11
  export { VERSION } from "./version.js";
11
12
  export * from "./client/index.js";
package/dist/server.d.ts CHANGED
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/server";
2
2
  import type { Config } from "./config.js";
3
3
  import type { CallerProvider } from "./context.js";
4
4
  import type { PresignedLinks } from "./links.js";
5
+ import { IndicatorRegistry } from "./indicators.js";
5
6
  export declare const SERVER_NAME = "fmsg";
6
7
  export type CreateServerOptions = {
7
8
  /** The caller's address when already known; makes the instructions name it. */
@@ -12,6 +13,13 @@ export type CreateServerOptions = {
12
13
  shutdown?: AbortSignal;
13
14
  /** Issues presigned download and upload links (HTTP with a public URL). */
14
15
  links?: PresignedLinks;
16
+ /**
17
+ * Indicators set with the indicate tool and kept up on the caller's behalf (FMSG-006). A factory that builds one
18
+ * server per HTTP request must pass a shared registry and close it on shutdown. Without one, the server owns its own
19
+ * and clears every indicator when it closes or `shutdown` aborts, which suits a server that lives for a whole session
20
+ * (stdio).
21
+ */
22
+ indicators?: IndicatorRegistry;
15
23
  };
16
24
  /**
17
25
  * Build an fmsg MCP server. Registration only — no I/O — so the same factory
package/dist/server.js CHANGED
@@ -13,6 +13,7 @@ import { registerWaitTools } from "./tools/wait.js";
13
13
  import { registerSaveTool } from "./tools/save.js";
14
14
  import { registerDownloadTool } from "./tools/download.js";
15
15
  import { VERSION } from "./version.js";
16
+ import { IndicatorRegistry } from "./indicators.js";
16
17
  export const SERVER_NAME = "fmsg";
17
18
  /**
18
19
  * Build an fmsg MCP server. Registration only — no I/O — so the same factory
@@ -27,9 +28,19 @@ export function createFmsgMcpServer(provider, config, options = {}) {
27
28
  });
28
29
  const server = new McpServer({ name: SERVER_NAME, title: "fmsg", version: VERSION }, { instructions });
29
30
  const includeOwner = includeOwnerSchema(options.address, options.owner);
31
+ let indicators = options.indicators;
32
+ if (!indicators) {
33
+ const owned = new IndicatorRegistry();
34
+ indicators = owned;
35
+ const previous = server.server.onclose;
36
+ server.server.onclose = () => { owned.close(); previous?.(); };
37
+ options.shutdown?.addEventListener("abort", () => owned.close(), { once: true });
38
+ if (options.shutdown?.aborted)
39
+ owned.close();
40
+ }
30
41
  const deps = {
31
42
  provider, config, ...(includeOwner ? { includeOwner } : {}), ...(options.shutdown ? { shutdown: options.shutdown } : {}),
32
- ...(options.links ? { links: options.links } : {}),
43
+ ...(options.links ? { links: options.links } : {}), indicators,
33
44
  };
34
45
  registerIdentityTools(server, deps);
35
46
  registerListTools(server, deps);
@@ -3,6 +3,7 @@ import * as z from "zod/v4";
3
3
  import type { AddressResolver } from "../address.js";
4
4
  import type { FmsgMessage } from "../client/types.js";
5
5
  import type { Config } from "../config.js";
6
+ import type { IndicatorRegistry } from "../indicators.js";
6
7
  import type { PresignedLinks } from "../links.js";
7
8
  import { type Caller, type CallerProvider } from "../context.js";
8
9
  export type ToolDeps = {
@@ -14,6 +15,8 @@ export type ToolDeps = {
14
15
  shutdown?: AbortSignal;
15
16
  /** Presigned links, when this server has a public URL. */
16
17
  links?: PresignedLinks;
18
+ /** FMSG-006 indicators the caller sets with the indicate tool, kept up until replied, handled or cleared. */
19
+ indicators: IndicatorRegistry;
17
20
  };
18
21
  export declare const READ_ONLY: ToolAnnotations;
19
22
  export declare const SENDS: ToolAnnotations;
@@ -7,7 +7,7 @@ import { CHECKSUM_HEADER, UPLOAD_ENCODINGS, draftUploadUrlTemplate } from "../up
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
9
  import { parentReference, replyParent } from "./parent.js";
10
- import { rootTopic } from "./read.js";
10
+ import { threadRoot } from "./read.js";
11
11
  import { IMMUTABLE, IMPORTANT, NO_REPLY, hintOutput, uploadSteps, redactionNote, replyRecipients, resolveRecipients, resolvedNote, resolvedOutput, sendWarnings, sentAttachments, sentOutput, withHint, } from "./send.js";
12
12
  export const registerDraftTools = (server, deps) => {
13
13
  const baseUrl = downloadBaseUrl(deps.config);
@@ -124,6 +124,10 @@ export const registerDraftTools = (server, deps) => {
124
124
  return toolError(`message ${id} was already sent; send_draft sends only drafts`);
125
125
  const sent = await caller.client.sendDraft(draft.id, signal);
126
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);
127
131
  const structured = {
128
132
  id: sent.id,
129
133
  time: isoTime(sent.time),
@@ -135,8 +139,7 @@ export const registerDraftTools = (server, deps) => {
135
139
  redactions: 0,
136
140
  redacted: [],
137
141
  warnings: [],
138
- // The message is already sent: a failed topic lookup only leaves the topic unknown.
139
- thread_topic: draft.pid ? await rootTopic(caller.client, { ...draft, id: sent.id }, signal).catch(() => null) : topic,
142
+ thread_topic: root ? root.topic : topic,
140
143
  };
141
144
  const where = draft.pid ? `reply to message ${draft.pid}` : `"${headerValue(topic)}"`;
142
145
  const text = `Sent draft ${sent.id} (${where}) to ${addressList(draft.to)} at ${structured.time ?? "?"}` +
@@ -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
- * The topic of a reply's thread root (fmsg topics live only on the root): one thread lookup, made only for replies.
6
- * Null when the root is not visible or the lookup fails; a failure here never fails the read itself.
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 rootTopic(client: FmsgClient, message: FmsgMessage, signal: AbortSignal): Promise<string | null>;
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;
@@ -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
- * The topic of a reply's thread root (fmsg topics live only on the root): one thread lookup, made only for replies.
32
- * Null when the root is not visible or the lookup fails; a failure here never fails the read itself.
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 rootTopic(client, message, signal) {
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 rootTopic(caller.client, message, signal);
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) {
@@ -6,7 +6,8 @@ import { addressList, addressText, attachmentType, headerValue, isoTime, partici
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
8
  import { parentReference, replyParent } from "./parent.js";
9
- import { rootTopic } from "./read.js";
9
+ import { WORKING_EMOJI } from "../indicators.js";
10
+ import { threadRoot } from "./read.js";
10
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.";
11
12
  export const IMPORTANT = "mark as important for recipients";
12
13
  export const NO_REPLY = "ask recipients (and their agents) not to reply";
@@ -185,6 +186,9 @@ export const registerSendTools = (server, deps) => {
185
186
  const outbound = await prepareAttachments(attachments, deps.config);
186
187
  const hint = base64Hint(attachments, routes);
187
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);
188
192
  const structured = {
189
193
  id: sent.id,
190
194
  time: isoTime(sent.time),
@@ -197,8 +201,7 @@ export const registerSendTools = (server, deps) => {
197
201
  redacted: sent.redacted,
198
202
  ...(resolved.length ? { resolved } : {}),
199
203
  warnings: sendWarnings(sent.redactions, sent.redacted, resolved),
200
- // The reply is already sent: a failed topic lookup only leaves the topic unknown.
201
- thread_topic: await rootTopic(caller.client, parent, signal).catch(() => null),
204
+ thread_topic: root.topic,
202
205
  ...(hint ? { hint } : {}),
203
206
  };
204
207
  const topic = structured.thread_topic ? ` in "${headerValue(structured.thread_topic)}"` : "";
@@ -246,7 +249,34 @@ export const registerSendTools = (server, deps) => {
246
249
  }, async ({ id, emoji }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
247
250
  const value = emoji && emoji.trim() ? emoji.trim() : null;
248
251
  const result = await caller.client.react(id, value, signal);
252
+ deps.indicators.handled(caller, [id]);
249
253
  const structured = { id, reaction_id: result.id, time: isoTime(result.time), cleared: value === null };
250
254
  return ok(value ? `Reacted ${value} to message ${id}` : `Cleared your reaction on message ${id}`, structured);
251
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
+ }));
252
282
  };
package/docs/oauth.md CHANGED
@@ -119,7 +119,7 @@ are refused.
119
119
  | Operations | Required scope |
120
120
  |---|---|
121
121
  | Identity/address lookup, inbox/sent, message/thread, delivery status, attachment download, wait, resource reads | `fmsg:read` |
122
- | Send a new message, add recipients, react, mark read | `fmsg:write` |
122
+ | Send a new message, add recipients, react, show an indicator (`indicate`), mark read | `fmsg:write` |
123
123
  | Reply (reads the parent before sending) | `fmsg:read fmsg:write` |
124
124
  | Protocol initialization, tool/resource/prompt discovery and prompt templates | Valid incoming token; no messaging scope needed |
125
125
 
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": "1.3.0",
4
+ "version": "1.4.0",
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": "1.3.0",
11
+ "version": "1.4.0",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "registryBaseUrl": "https://registry.npmjs.org",
16
16
  "identifier": "@markmnl/fmsg-mcp",
17
- "version": "1.3.0",
17
+ "version": "1.4.0",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },