@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 +6 -0
- package/README.md +16 -0
- package/dist/build-info.json +3 -3
- package/dist/client/client.d.ts +6 -0
- package/dist/client/client.js +15 -0
- package/dist/http.js +17 -6
- package/dist/indicators.d.ts +35 -0
- package/dist/indicators.js +186 -0
- package/dist/instructions.js +3 -1
- package/dist/oauth/scopes.js +1 -1
- package/dist/public.d.ts +1 -0
- package/dist/public.js +1 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +12 -1
- package/dist/tools/common.d.ts +3 -0
- package/dist/tools/draft.js +6 -3
- package/dist/tools/read.d.ts +7 -3
- package/dist/tools/read.js +10 -8
- package/dist/tools/send.js +33 -3
- package/docs/oauth.md +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
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.
|
package/dist/build-info.json
CHANGED
package/dist/client/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/client/client.js
CHANGED
|
@@ -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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
}
|
package/dist/instructions.js
CHANGED
|
@@ -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 " +
|
package/dist/oauth/scopes.js
CHANGED
|
@@ -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);
|
package/dist/tools/common.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/draft.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 ?? "?"}` +
|
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.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
17
|
+
"version": "1.4.0",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
},
|