@markmnl/fmsg-mcp 0.1.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/LICENSE +21 -0
- package/README.md +155 -0
- package/dist/address.d.ts +18 -0
- package/dist/address.js +50 -0
- package/dist/auth.d.ts +21 -0
- package/dist/auth.js +84 -0
- package/dist/client/client.d.ts +83 -0
- package/dist/client/client.js +310 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.js +5 -0
- package/dist/client/message-id.d.ts +19 -0
- package/dist/client/message-id.js +70 -0
- package/dist/client/redact.d.ts +8 -0
- package/dist/client/redact.js +25 -0
- package/dist/client/types.d.ts +126 -0
- package/dist/client/types.js +2 -0
- package/dist/client/ws.d.ts +6 -0
- package/dist/client/ws.js +25 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +74 -0
- package/dist/context.d.ts +20 -0
- package/dist/context.js +17 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +39 -0
- package/dist/http.d.ts +14 -0
- package/dist/http.js +112 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +137 -0
- package/dist/prompts.d.ts +2 -0
- package/dist/prompts.js +44 -0
- package/dist/public.d.ts +10 -0
- package/dist/public.js +10 -0
- package/dist/render.d.ts +27 -0
- package/dist/render.js +109 -0
- package/dist/resources.d.ts +3 -0
- package/dist/resources.js +37 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +26 -0
- package/dist/thread.d.ts +42 -0
- package/dist/thread.js +176 -0
- package/dist/tools/common.d.ts +62 -0
- package/dist/tools/common.js +88 -0
- package/dist/tools/identity.d.ts +2 -0
- package/dist/tools/identity.js +58 -0
- package/dist/tools/list.d.ts +2 -0
- package/dist/tools/list.js +72 -0
- package/dist/tools/read.d.ts +2 -0
- package/dist/tools/read.js +202 -0
- package/dist/tools/send.d.ts +2 -0
- package/dist/tools/send.js +170 -0
- package/dist/tools/wait.d.ts +2 -0
- package/dist/tools/wait.js +96 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +5 -0
- package/dist/wait.d.ts +41 -0
- package/dist/wait.js +210 -0
- package/package.json +74 -0
- package/server.json +24 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ResourceTemplate, ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server";
|
|
2
|
+
import { normalizeMessageId } from "./client/message-id.js";
|
|
3
|
+
import { callerFor } from "./context.js";
|
|
4
|
+
import { DATA_NOT_INSTRUCTIONS, fence, messageHeader } from "./render.js";
|
|
5
|
+
import { assembleThread, renderThread } from "./thread.js";
|
|
6
|
+
export function registerResources(server, deps) {
|
|
7
|
+
server.registerResource("message", new ResourceTemplate("fmsg://message/{id}", { list: undefined }), { title: "fmsg message", description: "One fmsg message with headers and body", mimeType: "text/markdown" }, async (uri, { id }, ctx) => {
|
|
8
|
+
let mid;
|
|
9
|
+
try {
|
|
10
|
+
mid = normalizeMessageId(String(id));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `invalid fmsg message id "${String(id)}"`);
|
|
14
|
+
}
|
|
15
|
+
const caller = await callerFor(deps.provider, ctx);
|
|
16
|
+
const message = await caller.client.getMessage(mid, ctx.mcpReq.signal);
|
|
17
|
+
const text = await caller.client.getText(message, ctx.mcpReq.signal);
|
|
18
|
+
const body = text === null ? `[non-text body: ${message.type ?? "?"}, ${message.size ?? 0} bytes]` : `${DATA_NOT_INSTRUCTIONS}\n\n${fence(text)}`;
|
|
19
|
+
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: `${messageHeader(message)}\n\n${body}` }] };
|
|
20
|
+
});
|
|
21
|
+
server.registerResource("thread", new ResourceTemplate("fmsg://thread/{id}", { list: undefined }), { title: "fmsg thread", description: "The lineage of messages from the thread root to the given message", mimeType: "text/markdown" }, async (uri, { id }, ctx) => {
|
|
22
|
+
let mid;
|
|
23
|
+
try {
|
|
24
|
+
mid = normalizeMessageId(String(id));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `invalid fmsg message id "${String(id)}"`);
|
|
28
|
+
}
|
|
29
|
+
const caller = await callerFor(deps.provider, ctx);
|
|
30
|
+
const thread = await assembleThread(caller.client, caller.address, mid, {
|
|
31
|
+
maxMessages: 100,
|
|
32
|
+
maxBodyBytesPerMessage: 65_536,
|
|
33
|
+
maxTotalBytes: 1_048_576,
|
|
34
|
+
}, ctx.mcpReq.signal);
|
|
35
|
+
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: renderThread(thread) }] };
|
|
36
|
+
});
|
|
37
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { Config } from "./config.js";
|
|
3
|
+
import type { CallerProvider } from "./context.js";
|
|
4
|
+
export declare const SERVER_NAME = "fmsg";
|
|
5
|
+
/**
|
|
6
|
+
* Build an fmsg MCP server. Registration only — no I/O — so the same factory
|
|
7
|
+
* serves one stdio connection or one HTTP request.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createFmsgMcpServer(provider: CallerProvider, config: Config): McpServer;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { registerPrompts } from "./prompts.js";
|
|
3
|
+
import { registerResources } from "./resources.js";
|
|
4
|
+
import { registerIdentityTools } from "./tools/identity.js";
|
|
5
|
+
import { registerListTools } from "./tools/list.js";
|
|
6
|
+
import { registerReadTools } from "./tools/read.js";
|
|
7
|
+
import { registerSendTools } from "./tools/send.js";
|
|
8
|
+
import { registerWaitTools } from "./tools/wait.js";
|
|
9
|
+
import { VERSION } from "./version.js";
|
|
10
|
+
export const SERVER_NAME = "fmsg";
|
|
11
|
+
/**
|
|
12
|
+
* Build an fmsg MCP server. Registration only — no I/O — so the same factory
|
|
13
|
+
* serves one stdio connection or one HTTP request.
|
|
14
|
+
*/
|
|
15
|
+
export function createFmsgMcpServer(provider, config) {
|
|
16
|
+
const server = new McpServer({ name: SERVER_NAME, title: "fmsg", version: VERSION });
|
|
17
|
+
const deps = { provider, config };
|
|
18
|
+
registerIdentityTools(server, deps);
|
|
19
|
+
registerListTools(server, deps);
|
|
20
|
+
registerReadTools(server, deps);
|
|
21
|
+
registerSendTools(server, deps);
|
|
22
|
+
registerWaitTools(server, deps);
|
|
23
|
+
registerResources(server, deps);
|
|
24
|
+
registerPrompts(server);
|
|
25
|
+
return server;
|
|
26
|
+
}
|
package/dist/thread.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { FmsgClient } from "./client/client.js";
|
|
2
|
+
export type ThreadCaps = {
|
|
3
|
+
maxMessages: number;
|
|
4
|
+
maxBodyBytesPerMessage: number;
|
|
5
|
+
maxTotalBytes: number;
|
|
6
|
+
};
|
|
7
|
+
export type AssembledMessage = {
|
|
8
|
+
id: string;
|
|
9
|
+
pid: string | null;
|
|
10
|
+
visible: boolean;
|
|
11
|
+
from?: string;
|
|
12
|
+
to?: string[];
|
|
13
|
+
time: string | null;
|
|
14
|
+
time_posix: number | null;
|
|
15
|
+
topic?: string;
|
|
16
|
+
type?: string;
|
|
17
|
+
size?: number;
|
|
18
|
+
body: string | null;
|
|
19
|
+
body_truncated: boolean;
|
|
20
|
+
attachments: Array<{
|
|
21
|
+
filename: string;
|
|
22
|
+
size: number;
|
|
23
|
+
type?: string;
|
|
24
|
+
}>;
|
|
25
|
+
};
|
|
26
|
+
export type AssembledThread = {
|
|
27
|
+
root_id: string;
|
|
28
|
+
trigger_id: string;
|
|
29
|
+
complete: boolean;
|
|
30
|
+
source: "thread_messages" | "pid_walk";
|
|
31
|
+
participants: string[];
|
|
32
|
+
reply_target_id: string;
|
|
33
|
+
terminal: boolean;
|
|
34
|
+
messages: AssembledMessage[];
|
|
35
|
+
omitted: number;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Reconstruct the direct lineage of a message (root → trigger). Uses the host's
|
|
39
|
+
* thread endpoint and falls back to a pid walk when the host declines (too deep / too large).
|
|
40
|
+
*/
|
|
41
|
+
export declare function assembleThread(client: FmsgClient, self: string, triggerId: string, caps: ThreadCaps, signal?: AbortSignal): Promise<AssembledThread>;
|
|
42
|
+
export declare function renderThread(thread: AssembledThread): string;
|
package/dist/thread.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { FmsgClient, FmsgHttpError } from "./client/client.js";
|
|
2
|
+
import { DATA_NOT_INSTRUCTIONS, fence, isoTime, participantsOf, truncateUtf8, truncationNote } from "./render.js";
|
|
3
|
+
function nonReactions(messages) {
|
|
4
|
+
// Reactions are terminal no-reply leaves; they never appear on a lineage, so nothing to filter here.
|
|
5
|
+
return messages;
|
|
6
|
+
}
|
|
7
|
+
async function fromThreadMessages(client, thread, caps, signal) {
|
|
8
|
+
const all = nonReactions(thread.messages);
|
|
9
|
+
const omitted = Math.max(0, all.length - caps.maxMessages);
|
|
10
|
+
const kept = omitted > 0 ? all.slice(all.length - caps.maxMessages) : all;
|
|
11
|
+
let budget = caps.maxTotalBytes;
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const m of kept) {
|
|
14
|
+
let body = null;
|
|
15
|
+
let truncated = false;
|
|
16
|
+
if (m.visible && m.body) {
|
|
17
|
+
let text = null;
|
|
18
|
+
if (typeof m.body.text === "string")
|
|
19
|
+
text = m.body.text;
|
|
20
|
+
else if (m.body.download && FmsgClient.isText({ type: m.body.type }) && m.body.size <= caps.maxBodyBytesPerMessage * 4) {
|
|
21
|
+
const { data } = await client.downloadPath(m.body.download, signal);
|
|
22
|
+
text = Buffer.from(data).toString("utf8");
|
|
23
|
+
}
|
|
24
|
+
if (text !== null) {
|
|
25
|
+
const limit = Math.max(0, Math.min(caps.maxBodyBytesPerMessage, budget));
|
|
26
|
+
const t = truncateUtf8(text, limit);
|
|
27
|
+
body = t.text;
|
|
28
|
+
truncated = t.truncated;
|
|
29
|
+
budget -= t.shown;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
out.push({
|
|
33
|
+
id: m.id,
|
|
34
|
+
pid: m.pid ?? null,
|
|
35
|
+
visible: m.visible,
|
|
36
|
+
...(m.from ? { from: m.from } : {}),
|
|
37
|
+
...(m.to ? { to: m.to } : {}),
|
|
38
|
+
time: isoTime(m.time),
|
|
39
|
+
time_posix: typeof m.time === "number" ? m.time : null,
|
|
40
|
+
...(m.topic ? { topic: m.topic } : {}),
|
|
41
|
+
...(m.type ? { type: m.type } : {}),
|
|
42
|
+
...(typeof m.size === "number" ? { size: m.size } : {}),
|
|
43
|
+
body,
|
|
44
|
+
body_truncated: truncated,
|
|
45
|
+
attachments: (m.attachments ?? []).map((a) => ({ filename: a.filename, size: a.size, type: a.type })),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return { messages: out, omitted };
|
|
49
|
+
}
|
|
50
|
+
async function fromPidWalk(client, triggerId, caps, signal) {
|
|
51
|
+
const chain = [];
|
|
52
|
+
let id = triggerId;
|
|
53
|
+
let complete = true;
|
|
54
|
+
while (id && chain.length < caps.maxMessages) {
|
|
55
|
+
let msg;
|
|
56
|
+
try {
|
|
57
|
+
msg = await client.getMessage(id, signal);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error instanceof FmsgHttpError && (error.status === 404 || error.status === 403)) {
|
|
61
|
+
complete = false;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
chain.push(msg);
|
|
67
|
+
id = msg.pid ?? null;
|
|
68
|
+
}
|
|
69
|
+
if (id)
|
|
70
|
+
complete = false;
|
|
71
|
+
chain.reverse();
|
|
72
|
+
let budget = caps.maxTotalBytes;
|
|
73
|
+
const messages = [];
|
|
74
|
+
for (const m of chain) {
|
|
75
|
+
let body = null;
|
|
76
|
+
let truncated = false;
|
|
77
|
+
const text = await client.getText(m, signal);
|
|
78
|
+
if (text !== null) {
|
|
79
|
+
const t = truncateUtf8(text, Math.max(0, Math.min(caps.maxBodyBytesPerMessage, budget)));
|
|
80
|
+
body = t.text;
|
|
81
|
+
truncated = t.truncated;
|
|
82
|
+
budget -= t.shown;
|
|
83
|
+
}
|
|
84
|
+
messages.push({
|
|
85
|
+
id: m.id,
|
|
86
|
+
pid: m.pid ?? null,
|
|
87
|
+
visible: true,
|
|
88
|
+
from: m.from,
|
|
89
|
+
to: m.to,
|
|
90
|
+
time: isoTime(m.time),
|
|
91
|
+
time_posix: typeof m.time === "number" ? m.time : null,
|
|
92
|
+
...(m.topic ? { topic: m.topic } : {}),
|
|
93
|
+
...(m.type ? { type: m.type } : {}),
|
|
94
|
+
...(typeof m.size === "number" ? { size: m.size } : {}),
|
|
95
|
+
body,
|
|
96
|
+
body_truncated: truncated,
|
|
97
|
+
attachments: (m.attachments ?? []).map((a) => ({ filename: a.filename, size: a.size })),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const last = chain[chain.length - 1];
|
|
101
|
+
return { root_id: chain[0].id, complete, messages, last };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Reconstruct the direct lineage of a message (root → trigger). Uses the host's
|
|
105
|
+
* thread endpoint and falls back to a pid walk when the host declines (too deep / too large).
|
|
106
|
+
*/
|
|
107
|
+
export async function assembleThread(client, self, triggerId, caps, signal) {
|
|
108
|
+
const trigger = await client.getMessage(triggerId, signal);
|
|
109
|
+
const participants = participantsOf(trigger).filter((a) => a !== self.toLowerCase());
|
|
110
|
+
try {
|
|
111
|
+
const thread = await client.getThreadMessages(triggerId, signal);
|
|
112
|
+
const { messages, omitted } = await fromThreadMessages(client, thread, caps, signal);
|
|
113
|
+
return {
|
|
114
|
+
root_id: thread.root_id,
|
|
115
|
+
trigger_id: thread.trigger_id,
|
|
116
|
+
complete: thread.complete && omitted === 0,
|
|
117
|
+
source: "thread_messages",
|
|
118
|
+
participants,
|
|
119
|
+
reply_target_id: trigger.id,
|
|
120
|
+
terminal: trigger.terminal === true,
|
|
121
|
+
messages,
|
|
122
|
+
omitted,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (!(error instanceof FmsgHttpError && (error.status === 422 || error.status === 413 || error.status === 404 || error.status === 501))) {
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
const walk = await fromPidWalk(client, triggerId, caps, signal);
|
|
130
|
+
return {
|
|
131
|
+
root_id: walk.root_id,
|
|
132
|
+
trigger_id: triggerId,
|
|
133
|
+
complete: walk.complete,
|
|
134
|
+
source: "pid_walk",
|
|
135
|
+
participants,
|
|
136
|
+
reply_target_id: trigger.id,
|
|
137
|
+
terminal: trigger.terminal === true,
|
|
138
|
+
messages: walk.messages,
|
|
139
|
+
omitted: 0,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export function renderThread(thread) {
|
|
144
|
+
const lines = [];
|
|
145
|
+
const root = thread.messages[0];
|
|
146
|
+
lines.push(`**fmsg thread** root ${thread.root_id} · ${thread.messages.length} message${thread.messages.length === 1 ? "" : "s"} on the lineage to ${thread.trigger_id}${thread.complete ? "" : " (incomplete)"}`);
|
|
147
|
+
if (root?.topic)
|
|
148
|
+
lines.push(`Topic: ${root.topic}`);
|
|
149
|
+
if (thread.omitted > 0)
|
|
150
|
+
lines.push(`(${thread.omitted} earlier message${thread.omitted === 1 ? "" : "s"} omitted)`);
|
|
151
|
+
lines.push(`Participants (reply-all default): ${thread.participants.join(", ") || "(none)"}`);
|
|
152
|
+
lines.push("");
|
|
153
|
+
lines.push(DATA_NOT_INSTRUCTIONS);
|
|
154
|
+
for (const m of thread.messages) {
|
|
155
|
+
lines.push("");
|
|
156
|
+
if (!m.visible) {
|
|
157
|
+
lines.push(`--- message ${m.id} [not visible to you] ---`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
lines.push(`--- message ${m.id} from ${m.from ?? "?"} · ${m.time ?? "draft"}${m.pid ? ` · reply to ${m.pid}` : ""} ---`);
|
|
161
|
+
if (m.attachments.length)
|
|
162
|
+
lines.push(`attachments: ${m.attachments.map((a) => `${a.filename} (${a.size} bytes)`).join(", ")}`);
|
|
163
|
+
if (m.body === null)
|
|
164
|
+
lines.push(`[non-text body: ${m.type ?? "?"}, ${m.size ?? 0} bytes — use get_message / download_attachment]`);
|
|
165
|
+
else {
|
|
166
|
+
lines.push(fence(m.body.trimEnd()));
|
|
167
|
+
if (m.body_truncated)
|
|
168
|
+
lines.push(truncationNote({ text: "", truncated: true, shown: Buffer.byteLength(m.body), total: m.size ?? 0 }, `call get_message ${m.id} for the full body`).trim());
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
lines.push("");
|
|
172
|
+
lines.push(thread.terminal
|
|
173
|
+
? `Message ${thread.reply_target_id} is terminal: it cannot be replied to.`
|
|
174
|
+
: `To continue this thread, reply to message ${thread.reply_target_id} (the reply tool).`);
|
|
175
|
+
return lines.join("\n");
|
|
176
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { CallToolResult, McpServer, ServerContext, ToolAnnotations } from "@modelcontextprotocol/server";
|
|
2
|
+
import * as z from "zod/v4";
|
|
3
|
+
import type { FmsgMessage } from "../client/types.js";
|
|
4
|
+
import type { Config } from "../config.js";
|
|
5
|
+
import { type Caller, type CallerProvider } from "../context.js";
|
|
6
|
+
export type ToolDeps = {
|
|
7
|
+
provider: CallerProvider;
|
|
8
|
+
config: Config;
|
|
9
|
+
};
|
|
10
|
+
export declare const READ_ONLY: ToolAnnotations;
|
|
11
|
+
export declare const SENDS: ToolAnnotations;
|
|
12
|
+
export declare const idSchema: z.ZodString;
|
|
13
|
+
export declare const attachmentItem: z.ZodObject<{
|
|
14
|
+
filename: z.ZodString;
|
|
15
|
+
size: z.ZodNumber;
|
|
16
|
+
}, z.core.$strip>;
|
|
17
|
+
export declare const deliveryItem: z.ZodObject<{
|
|
18
|
+
addr: z.ZodString;
|
|
19
|
+
status: z.ZodEnum<{
|
|
20
|
+
delivered: "delivered";
|
|
21
|
+
pending: "pending";
|
|
22
|
+
failed: "failed";
|
|
23
|
+
}>;
|
|
24
|
+
time: z.ZodNullable<z.ZodString>;
|
|
25
|
+
code: z.ZodNullable<z.ZodNumber>;
|
|
26
|
+
via: z.ZodEnum<{
|
|
27
|
+
to: "to";
|
|
28
|
+
add_to: "add_to";
|
|
29
|
+
}>;
|
|
30
|
+
}, z.core.$strip>;
|
|
31
|
+
export declare const messageItem: z.ZodObject<{
|
|
32
|
+
id: z.ZodString;
|
|
33
|
+
pid: z.ZodNullable<z.ZodString>;
|
|
34
|
+
from: z.ZodString;
|
|
35
|
+
to: z.ZodArray<z.ZodString>;
|
|
36
|
+
added: z.ZodArray<z.ZodString>;
|
|
37
|
+
topic: z.ZodString;
|
|
38
|
+
time: z.ZodNullable<z.ZodString>;
|
|
39
|
+
time_posix: z.ZodNullable<z.ZodNumber>;
|
|
40
|
+
read: z.ZodNullable<z.ZodBoolean>;
|
|
41
|
+
important: z.ZodBoolean;
|
|
42
|
+
no_reply: z.ZodBoolean;
|
|
43
|
+
terminal: z.ZodBoolean;
|
|
44
|
+
type: z.ZodString;
|
|
45
|
+
size: z.ZodNumber;
|
|
46
|
+
preview: z.ZodString;
|
|
47
|
+
attachments: z.ZodArray<z.ZodObject<{
|
|
48
|
+
filename: z.ZodString;
|
|
49
|
+
size: z.ZodNumber;
|
|
50
|
+
}, z.core.$strip>>;
|
|
51
|
+
reactions: z.ZodArray<z.ZodObject<{
|
|
52
|
+
emoji: z.ZodString;
|
|
53
|
+
from: z.ZodArray<z.ZodString>;
|
|
54
|
+
}, z.core.$strip>>;
|
|
55
|
+
}, z.core.$strip>;
|
|
56
|
+
export type MessageItem = z.infer<typeof messageItem>;
|
|
57
|
+
export declare function toItem(m: FmsgMessage, self: string): MessageItem;
|
|
58
|
+
export declare function deliveryOf(m: FmsgMessage): z.infer<typeof deliveryItem>[];
|
|
59
|
+
export declare function ok(text: string, structured: Record<string, unknown>): CallToolResult;
|
|
60
|
+
/** Resolve the caller and run a tool body, turning any failure into an `isError` result. */
|
|
61
|
+
export declare function withCaller(deps: ToolDeps, ctx: ServerContext, body: (caller: Caller, signal: AbortSignal) => Promise<CallToolResult>): Promise<CallToolResult>;
|
|
62
|
+
export type Register = (server: McpServer, deps: ToolDeps) => void;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { callerFor } from "../context.js";
|
|
3
|
+
import { describeError, toolError } from "../errors.js";
|
|
4
|
+
import { isoTime, preview } from "../render.js";
|
|
5
|
+
export const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
6
|
+
export const SENDS = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true };
|
|
7
|
+
export const idSchema = z.string().regex(/^[0-9]+$/u, "fmsg message ids are decimal integers").describe("fmsg message id");
|
|
8
|
+
export const attachmentItem = z.object({ filename: z.string(), size: z.number() });
|
|
9
|
+
export const deliveryItem = z.object({
|
|
10
|
+
addr: z.string(),
|
|
11
|
+
status: z.enum(["delivered", "pending", "failed"]),
|
|
12
|
+
time: z.string().nullable(),
|
|
13
|
+
code: z.number().nullable(),
|
|
14
|
+
via: z.enum(["to", "add_to"]),
|
|
15
|
+
});
|
|
16
|
+
export const messageItem = z.object({
|
|
17
|
+
id: z.string(),
|
|
18
|
+
pid: z.string().nullable(),
|
|
19
|
+
from: z.string(),
|
|
20
|
+
to: z.array(z.string()),
|
|
21
|
+
added: z.array(z.string()).describe("recipients added later via add-to"),
|
|
22
|
+
topic: z.string(),
|
|
23
|
+
time: z.string().nullable().describe("ISO-8601; null for drafts"),
|
|
24
|
+
time_posix: z.number().nullable(),
|
|
25
|
+
read: z.boolean().nullable().describe("null when not applicable (your own sent messages)"),
|
|
26
|
+
important: z.boolean(),
|
|
27
|
+
no_reply: z.boolean(),
|
|
28
|
+
terminal: z.boolean(),
|
|
29
|
+
type: z.string(),
|
|
30
|
+
size: z.number(),
|
|
31
|
+
preview: z.string(),
|
|
32
|
+
attachments: z.array(attachmentItem),
|
|
33
|
+
reactions: z.array(z.object({ emoji: z.string(), from: z.array(z.string()) })),
|
|
34
|
+
});
|
|
35
|
+
export function toItem(m, self) {
|
|
36
|
+
const mine = m.from.toLowerCase() === self.toLowerCase();
|
|
37
|
+
return {
|
|
38
|
+
id: m.id,
|
|
39
|
+
pid: m.pid ?? null,
|
|
40
|
+
from: m.from,
|
|
41
|
+
to: m.to,
|
|
42
|
+
added: (m.add_to ?? []).flatMap((b) => b.to ?? []),
|
|
43
|
+
topic: m.topic ?? "",
|
|
44
|
+
time: isoTime(m.time),
|
|
45
|
+
time_posix: typeof m.time === "number" ? m.time : null,
|
|
46
|
+
read: mine ? null : (m.read ?? null),
|
|
47
|
+
important: m.important === true,
|
|
48
|
+
no_reply: m.no_reply === true,
|
|
49
|
+
terminal: m.terminal === true,
|
|
50
|
+
type: m.type ?? "",
|
|
51
|
+
size: m.size ?? 0,
|
|
52
|
+
preview: preview(m),
|
|
53
|
+
attachments: (m.attachments ?? []).map((a) => ({ filename: a.filename, size: a.size })),
|
|
54
|
+
reactions: (m.reactions ?? []).map((r) => ({ emoji: r.emoji, from: r.from })),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function deliveryOf(m) {
|
|
58
|
+
const out = [];
|
|
59
|
+
const push = (addr, entry, via) => {
|
|
60
|
+
const time = entry?.time_delivered ?? null;
|
|
61
|
+
const code = entry?.response_code ?? null;
|
|
62
|
+
const status = time !== null ? "delivered" : code !== null ? "failed" : "pending";
|
|
63
|
+
out.push({ addr, status, time, code, via });
|
|
64
|
+
};
|
|
65
|
+
m.to.forEach((addr, i) => push(addr, m.to_delivery?.[i], "to"));
|
|
66
|
+
for (const batch of m.add_to ?? [])
|
|
67
|
+
(batch.to ?? []).forEach((addr, i) => push(addr, batch.to_delivery?.[i], "add_to"));
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
export function ok(text, structured) {
|
|
71
|
+
return { content: [{ type: "text", text }], structuredContent: structured };
|
|
72
|
+
}
|
|
73
|
+
/** Resolve the caller and run a tool body, turning any failure into an `isError` result. */
|
|
74
|
+
export async function withCaller(deps, ctx, body) {
|
|
75
|
+
let caller;
|
|
76
|
+
try {
|
|
77
|
+
caller = await callerFor(deps.provider, ctx);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
return toolError(describeError(error));
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return await body(caller, ctx.mcpReq.signal);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return toolError(describeError(error, caller.address));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { resolveAddress } from "../address.js";
|
|
3
|
+
import { isoTime } from "../render.js";
|
|
4
|
+
import { READ_ONLY, ok, withCaller } from "./common.js";
|
|
5
|
+
import { toolError } from "../errors.js";
|
|
6
|
+
export const registerIdentityTools = (server, deps) => {
|
|
7
|
+
server.registerTool("whoami", {
|
|
8
|
+
title: "Show fmsg identity",
|
|
9
|
+
description: "Report the fmsg address this server acts as (derived from the API key), the fmsg Web API URL, " +
|
|
10
|
+
"when the current access token expires (it is renewed automatically; no action needed), and the " +
|
|
11
|
+
"address-resolution defaults. Call this first if unsure who you are sending as.",
|
|
12
|
+
outputSchema: z.object({
|
|
13
|
+
address: z.string(),
|
|
14
|
+
api_url: z.string(),
|
|
15
|
+
token_expires_at: z.string().nullable(),
|
|
16
|
+
transport: z.enum(["stdio", "http"]),
|
|
17
|
+
default_domain: z.string().nullable(),
|
|
18
|
+
directory_names: z.array(z.string()),
|
|
19
|
+
}),
|
|
20
|
+
annotations: { ...READ_ONLY, openWorldHint: false },
|
|
21
|
+
}, async (ctx) => withCaller(deps, ctx, async (caller) => {
|
|
22
|
+
const expires = isoTime((await caller.tokenExpiresAt()) / 1000);
|
|
23
|
+
const structured = {
|
|
24
|
+
address: caller.address,
|
|
25
|
+
api_url: caller.client.apiUrl,
|
|
26
|
+
token_expires_at: expires,
|
|
27
|
+
transport: deps.config.transport,
|
|
28
|
+
default_domain: deps.config.defaultDomain ?? null,
|
|
29
|
+
directory_names: Object.keys(deps.config.directory ?? {}),
|
|
30
|
+
};
|
|
31
|
+
const lines = [
|
|
32
|
+
`You are **${caller.address}** on ${caller.client.apiUrl} (${deps.config.transport}).`,
|
|
33
|
+
`Access token expires ${expires ?? "unknown"} and is renewed automatically.`,
|
|
34
|
+
];
|
|
35
|
+
if (deps.config.defaultDomain)
|
|
36
|
+
lines.push(`Short names resolve to @name@${deps.config.defaultDomain}.`);
|
|
37
|
+
if (structured.directory_names.length)
|
|
38
|
+
lines.push(`Directory names: ${structured.directory_names.join(", ")}.`);
|
|
39
|
+
return ok(lines.join("\n"), structured);
|
|
40
|
+
}));
|
|
41
|
+
server.registerTool("resolve_address", {
|
|
42
|
+
title: "Resolve fmsg address",
|
|
43
|
+
description: "Resolve a short name to a full fmsg address without sending anything: a literal @user@domain is returned " +
|
|
44
|
+
"as-is, otherwise a configured directory entry is used, otherwise @name@<default domain>. " +
|
|
45
|
+
"Fails when nothing matches so you can ask the user for the full address.",
|
|
46
|
+
inputSchema: z.object({ name: z.string().describe("Full fmsg address (@user@domain) or a short name") }),
|
|
47
|
+
outputSchema: z.object({ address: z.string(), resolution: z.enum(["literal", "directory", "default_domain"]) }),
|
|
48
|
+
annotations: { ...READ_ONLY, openWorldHint: false },
|
|
49
|
+
}, async ({ name }) => {
|
|
50
|
+
try {
|
|
51
|
+
const resolved = resolveAddress(name, deps.config);
|
|
52
|
+
return ok(`${name} → ${resolved.address} (${resolved.resolution})`, resolved);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return toolError(error instanceof Error ? error.message : String(error));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { messageLine } from "../render.js";
|
|
3
|
+
import { READ_ONLY, deliveryItem, deliveryOf, messageItem, ok, toItem, withCaller } from "./common.js";
|
|
4
|
+
const pageInput = {
|
|
5
|
+
limit: z.number().int().min(1).max(100).default(20).describe("page size (host maximum 100)"),
|
|
6
|
+
offset: z.number().int().min(0).default(0).describe("number of newest messages to skip"),
|
|
7
|
+
include_reactions: z.boolean().default(false).describe("also list reaction messages (normally hidden)"),
|
|
8
|
+
};
|
|
9
|
+
export const registerListTools = (server, deps) => {
|
|
10
|
+
server.registerTool("list_messages", {
|
|
11
|
+
title: "List inbox",
|
|
12
|
+
description: "List messages received by this address, newest first. Each item carries the id, sender, recipients, topic, " +
|
|
13
|
+
"time, read state, flags, size, attachment names and a short preview. Reaction messages are hidden unless " +
|
|
14
|
+
"include_reactions is true. Use get_message for a full body and get_thread for the conversation around a message.",
|
|
15
|
+
inputSchema: z.object({
|
|
16
|
+
...pageInput,
|
|
17
|
+
unread_only: z.boolean().default(false).describe("keep only unread messages from the fetched page"),
|
|
18
|
+
}),
|
|
19
|
+
outputSchema: z.object({
|
|
20
|
+
messages: z.array(messageItem),
|
|
21
|
+
count: z.number(),
|
|
22
|
+
offset: z.number(),
|
|
23
|
+
next_offset: z.number().nullable().describe("offset for the next page, or null when this page was short"),
|
|
24
|
+
}),
|
|
25
|
+
annotations: READ_ONLY,
|
|
26
|
+
}, async ({ limit, offset, include_reactions, unread_only }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
27
|
+
const page = await caller.client.listInbox(limit, offset, signal);
|
|
28
|
+
const shown = page.filter((m) => (include_reactions || m.reaction === null || m.reaction === undefined) && (!unread_only || m.read === false));
|
|
29
|
+
const structured = {
|
|
30
|
+
messages: shown.map((m) => toItem(m, caller.address)),
|
|
31
|
+
count: shown.length,
|
|
32
|
+
offset,
|
|
33
|
+
next_offset: page.length === limit ? offset + limit : null,
|
|
34
|
+
};
|
|
35
|
+
const text = shown.length
|
|
36
|
+
? `${shown.length} message${shown.length === 1 ? "" : "s"} (offset ${offset}):\n${shown.map((m) => messageLine(m, caller.address)).join("\n")}`
|
|
37
|
+
: `No ${unread_only ? "unread " : ""}messages at offset ${offset}.`;
|
|
38
|
+
return ok(text, structured);
|
|
39
|
+
}));
|
|
40
|
+
server.registerTool("list_sent", {
|
|
41
|
+
title: "List sent messages",
|
|
42
|
+
description: "List messages sent by this address (including unsent drafts, shown with time null), newest first, with " +
|
|
43
|
+
"per-recipient delivery state. Use delivery_status for one message's detail.",
|
|
44
|
+
inputSchema: z.object(pageInput),
|
|
45
|
+
outputSchema: z.object({
|
|
46
|
+
messages: z.array(messageItem.extend({ delivery: z.array(deliveryItem) })),
|
|
47
|
+
count: z.number(),
|
|
48
|
+
offset: z.number(),
|
|
49
|
+
next_offset: z.number().nullable(),
|
|
50
|
+
}),
|
|
51
|
+
annotations: READ_ONLY,
|
|
52
|
+
}, async ({ limit, offset, include_reactions }, ctx) => withCaller(deps, ctx, async (caller, signal) => {
|
|
53
|
+
const page = await caller.client.listSent(limit, offset, signal);
|
|
54
|
+
const shown = page.filter((m) => include_reactions || m.reaction === null || m.reaction === undefined);
|
|
55
|
+
const structured = {
|
|
56
|
+
messages: shown.map((m) => ({ ...toItem(m, caller.address), delivery: deliveryOf(m) })),
|
|
57
|
+
count: shown.length,
|
|
58
|
+
offset,
|
|
59
|
+
next_offset: page.length === limit ? offset + limit : null,
|
|
60
|
+
};
|
|
61
|
+
const text = shown.length
|
|
62
|
+
? `${shown.length} sent message${shown.length === 1 ? "" : "s"} (offset ${offset}):\n${shown
|
|
63
|
+
.map((m) => {
|
|
64
|
+
const d = deliveryOf(m);
|
|
65
|
+
const summary = d.length ? ` · delivered ${d.filter((x) => x.status === "delivered").length}/${d.length}` : "";
|
|
66
|
+
return `${messageLine(m, caller.address)}${summary}`;
|
|
67
|
+
})
|
|
68
|
+
.join("\n")}`
|
|
69
|
+
: `No sent messages at offset ${offset}.`;
|
|
70
|
+
return ok(text, structured);
|
|
71
|
+
}));
|
|
72
|
+
};
|