message-cp 0.0.0-stage → 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/dist/server.js ADDED
@@ -0,0 +1,420 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT, MAX_STATUS_LENGTH, NOT_REGISTERED, STATUS_TTL_MS, ToolError, getAgent, listAgents, markWaiting, nameOfIdentity, registerWithIdentity, removeAgent, setStatus, touchAgent, } from "./agents.js";
4
+ import { OWNER_ACCOUNT } from "./db.js";
5
+ import { issueListenToken } from "./accounts.js";
6
+ import { DEFAULT_WAIT_HOLD_MS, formatUnread, httpWaitCommand, instructions, listenWaitCommand, saveListenToken, waitCommand } from "./wait.js";
7
+ import { DEFAULT_INBOX_LIMIT, MAX_BODY_LENGTH, MAX_INBOX_LIMIT, MAX_SEARCH_LENGTH, hasUnread, messageStatus, readConversation, readInbox, search, sendMessage, sendQuestion, takeReply, unreadSummary, } from "./messages.js";
8
+ import { DEFAULT_CHANNEL_LIMIT, MAX_CHANNEL_LIMIT, joinChannel, leaveChannel, listChannels, post, readChannel, } from "./channels.js";
9
+ /**
10
+ * Who this server process is acting as. Over HTTP, `serve` also records a registered session's registration in the
11
+ * database under a hash of its session id, so that a restarted server can restore it; over stdio it lives only in
12
+ * memory. Every use is checked against the registration it was made for: once that is removed the session is
13
+ * unregistered again.
14
+ * A session belongs to one account for its whole life, and every tool reads and writes only that account's agents
15
+ * and messages: the owner's for stdio, the account of the token that started it over HTTP.
16
+ */
17
+ export class Session {
18
+ account;
19
+ onBind;
20
+ identity;
21
+ db;
22
+ /**
23
+ * No default, so a caller has to say which account a session acts as. `onBind` is told each registration the
24
+ * session is bound to.
25
+ */
26
+ constructor(account, onBind) {
27
+ this.account = account;
28
+ this.onBind = onBind;
29
+ }
30
+ /** The database the identity is checked against. `createServer` calls this. */
31
+ attach(db) {
32
+ this.db = db;
33
+ }
34
+ bind(identity) {
35
+ this.identity = identity;
36
+ this.onBind?.(identity);
37
+ }
38
+ /** Binds the session to a registration that is already recorded, as a restore does, without telling `onBind`. */
39
+ restore(identity) {
40
+ this.identity = identity;
41
+ }
42
+ /** The registration this session belongs to, as last checked by `current`/`require`. */
43
+ get registration() {
44
+ return this.identity;
45
+ }
46
+ /** The agent's name, or undefined if unregistered or the registration was removed. */
47
+ current() {
48
+ if (!this.identity || !this.db)
49
+ return undefined;
50
+ const name = nameOfIdentity(this.db, this.account, this.identity);
51
+ if (name === undefined)
52
+ this.identity = undefined;
53
+ return name;
54
+ }
55
+ /** For tools that need the caller's row as well as its name: channel read positions belong to the registration. */
56
+ requireAgent() {
57
+ const name = this.require();
58
+ return { ...this.identity, name };
59
+ }
60
+ /** For tools that need to know the caller. */
61
+ require() {
62
+ const name = this.current();
63
+ if (!name)
64
+ throw new ToolError(NOT_REGISTERED);
65
+ return name;
66
+ }
67
+ }
68
+ /** Most held calls at once: sessions, waiting GET /wait requests and held `wait_for_mail` and `ask` calls each have this cap. */
69
+ export const DEFAULT_MAX_SESSIONS = 256;
70
+ /** How long `wait_for_mail` holds a call with no mail by default, and at most, in seconds. */
71
+ export const DEFAULT_WAIT_FOR_MAIL_SECONDS = 50;
72
+ export const MAX_WAIT_FOR_MAIL_SECONDS = DEFAULT_WAIT_HOLD_MS / 1000;
73
+ export const WAIT_FOR_MAIL_FULL = "Too many calls are waiting for mail on this server. Try again shortly.";
74
+ export const ASK_FULL = "Too many calls are waiting on this server. Try again shortly; nothing was sent.";
75
+ // The held `wait_for_mail` and `ask` calls of every server in this process that was not given a `hold`, as over stdio.
76
+ let held = 0;
77
+ const holdInProcess = () => {
78
+ if (held >= DEFAULT_MAX_SESSIONS)
79
+ return undefined;
80
+ held++;
81
+ return () => void held--;
82
+ };
83
+ /** Runs one check; a lock held past the busy timeout counts as finding nothing this time, as in `message-cp wait`. */
84
+ function check(run, nothing) {
85
+ try {
86
+ return run();
87
+ }
88
+ catch (err) {
89
+ const code = err?.code;
90
+ if (typeof code !== "string" || !code.startsWith("SQLITE_BUSY"))
91
+ throw err;
92
+ return nothing;
93
+ }
94
+ }
95
+ const NOTHING_UNREAD = { count: 0, senders: [], posts: 0, channels: [], mentions: 0, mentionChannels: [] };
96
+ /** Waits `ms`, or less if `signal` aborts first; the timer and listener are removed either way. */
97
+ const sleep = (ms, signal) => new Promise((resolve) => {
98
+ if (signal.aborted)
99
+ return resolve();
100
+ const done = () => {
101
+ clearTimeout(timer);
102
+ signal.removeEventListener("abort", done);
103
+ resolve();
104
+ };
105
+ const timer = setTimeout(done, ms);
106
+ signal.addEventListener("abort", done);
107
+ });
108
+ /**
109
+ * Runs `answer` about once a second until it returns something, which `poll` returns, or until `deadline` passes or
110
+ * `signal` aborts, when it returns undefined. Whatever `answer` throws ends the poll.
111
+ */
112
+ async function poll(deadline, signal, answer) {
113
+ for (;;) {
114
+ const left = deadline - Date.now();
115
+ if (left <= 0)
116
+ return undefined;
117
+ // Sleep a second, or until the call is cancelled or its client goes away; nobody is listening then.
118
+ await sleep(Math.min(1000, left), signal);
119
+ if (signal.aborted)
120
+ return undefined;
121
+ const found = answer();
122
+ if (found !== undefined)
123
+ return found;
124
+ }
125
+ }
126
+ /** One server per session. */
127
+ export function createServer(db, session = new Session(OWNER_ACCOUNT), options = {}) {
128
+ const remote = options.remote ?? false;
129
+ const hold = options.hold ?? holdInProcess;
130
+ session.attach(db);
131
+ const account = session.account;
132
+ const server = new McpServer({ name: "message-cp", version: "0.1.0" }, { instructions: instructions(process.env, remote, options.publicUrl, options.agent, options.approval, session.account) });
133
+ // Aborts when the call is cancelled or, where the transport knows it, the response that would carry its answer closes.
134
+ const ended = (extra) => {
135
+ const closed = options.closed?.(extra.requestId);
136
+ return closed ? AbortSignal.any([extra.signal, closed]) : extra.signal;
137
+ };
138
+ // Over HTTP, a request whose token is bound to an agent carries that agent's name; such a token is that agent already.
139
+ const refuseBound = (extra) => {
140
+ const agent = extra.authInfo?.extra?.agent;
141
+ if (typeof agent === "string")
142
+ throw new ToolError(`This token is already agent '${agent}'; it cannot register or remove another name.`);
143
+ };
144
+ // Over HTTP, a request whose token registers its own agents carries that token's row id, which `register` and
145
+ // `unregister` check each agent against.
146
+ const registering = (extra) => {
147
+ const token = extra.authInfo?.extra?.registers;
148
+ return typeof token === "number" ? token : undefined;
149
+ };
150
+ // Over HTTP, a request whose token came from an approval carries that token's row id, which a listen token is issued through.
151
+ const approvalOf = (extra) => {
152
+ const token = extra.authInfo?.extra?.approval;
153
+ return typeof token === "number" ? token : undefined;
154
+ };
155
+ /** A listen token for the session's registration through approval `approval`, with how to save it and its wait command. */
156
+ const listen = (approval, identity, name) => {
157
+ const token = options.publicUrl ? issueListenToken(db, account, identity, approval) : undefined;
158
+ // The registration was checked just before, so a refusal means the approval was revoked or the agent removed since.
159
+ if (token === undefined)
160
+ throw new ToolError("No listen token: this session's approval was revoked or its agent removed.");
161
+ return { listenToken: token, saveListenToken: saveListenToken(options.publicUrl, account, name), waitCommand: listenWaitCommand(options.publicUrl, account, name) };
162
+ };
163
+ // Wraps a tool: bumps last-seen for a registered session and turns ToolError into an error result.
164
+ const tool = (run) => async (args, extra) => {
165
+ // Touch first so a failing call still counts. `register` sets last-seen itself.
166
+ const name = session.current();
167
+ if (name)
168
+ touchAgent(db, account, name);
169
+ try {
170
+ const result = await run(args, extra);
171
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
172
+ }
173
+ catch (err) {
174
+ if (err instanceof ToolError) {
175
+ return { isError: true, content: [{ type: "text", text: err.message }] };
176
+ }
177
+ throw err;
178
+ }
179
+ };
180
+ server.registerTool("register", {
181
+ description: "Register this session as an agent with a name and short bio. A new name without a key you pass returns a generated key, once: keep it (if you pass your own key, nothing is returned). If the name exists (any case), this session takes that agent over and its bio is updated, but only with the agent's key; an agent registered before keys existed is claimed by the first call and gets a key. When the server can offer one, it returns waitCommand: run it in the background to be woken by unread messages, by unread posts in the channels you have joined and by unread @mentions of you; when it fires, call read_inbox if it names messages or mentions and read_channel for each channel it names posts in, and only then start it again. Over HTTP, a session connected by approval also gets listenToken, once, and saveListenToken, which says how to save it privately to the file for this agent that its waitCommand reads (see listen_token). When it returns no waitCommand, or your client cannot run a background command, call wait_for_mail instead, and call it again whenever it returns unread null. A session whose token is bound to one agent is that agent already, and register fails there. A session whose token was approved to register its own agents registers a name no agent or person of the account has, or takes back an agent that same token registered and that is still registered; it is refused any other agent, takes no key and returns none, since the token is that agent's credential.",
182
+ inputSchema: {
183
+ name: z.string().describe("1-32 characters: letters, digits, '-' and '_'. 'all' is reserved."),
184
+ bio: z.string().describe("A short description of who you are and what you work on."),
185
+ key: z.string().optional().describe("The agent's key, required to take over an existing agent that has one."),
186
+ },
187
+ }, tool(({ name, bio, key }, extra) => {
188
+ refuseBound(extra);
189
+ const { result, identity } = registerWithIdentity(db, account, name, bio, key, registering(extra));
190
+ session.bind(identity);
191
+ if (!remote)
192
+ return { ...result, waitCommand: waitCommand(result.name) };
193
+ const approval = approvalOf(extra);
194
+ if (approval !== undefined && options.publicUrl)
195
+ return { ...result, ...listen(approval, identity, result.name) };
196
+ return options.publicUrl ? { ...result, waitCommand: httpWaitCommand(options.publicUrl, result.name) } : result;
197
+ }));
198
+ if (options.approval && options.publicUrl) {
199
+ server.registerTool("listen_token", {
200
+ description: "Get a new listen token for the agent this session is, connected by approval: a token that can only wait for this agent's mail on GET /wait, which the waitCommand returned here reads from its own file for this agent. Returns listenToken, once, saveListenToken (how to save it privately to that file) and waitCommand. A new one replaces the agent's earlier listen token, which stops working. It stops working too when the agent is removed or unregistered or the approval is revoked; then the command exits with a 401 or 404 error. Requires register first, unless the approval was for this one agent.",
201
+ inputSchema: {},
202
+ }, tool((_args, extra) => {
203
+ const own = session.requireAgent();
204
+ const approval = approvalOf(extra);
205
+ if (approval === undefined)
206
+ throw new ToolError("Only a session connected by approval gets a listen token.");
207
+ return listen(approval, own, own.name);
208
+ }));
209
+ }
210
+ server.registerTool("unregister", {
211
+ description: "Remove an agent from the directory and free its name. The messages addressed to it are kept in the database, until a full token deletes their conversation over HTTP, but no tool shows them again, not even to a new agent that registers the same name; keptMessages says how many. With no arguments it removes you, and this session is unregistered until it calls `register` again (with a token bound to one agent, that token stops working). With name and key it removes that agent, only if the key matches (an agent that has no key can be removed by name alone); name and key work even from a session that has not registered, but not with a token bound to one agent. With a token approved to register its own agents, a name removes only an agent that token registered, without a key, and a key is refused. Any other session still acting as the removed agent is unregistered too. A process started with MESSAGE_CP_AGENT registers that agent again when it starts.",
212
+ inputSchema: {
213
+ name: z.string().optional().describe("The agent to remove. Omit to remove yourself."),
214
+ key: z.string().optional().describe("That agent's key, required if it has one."),
215
+ },
216
+ }, tool(({ name, key }, extra) => {
217
+ if (name !== undefined)
218
+ refuseBound(extra);
219
+ if (name === undefined) {
220
+ if (key !== undefined)
221
+ throw new ToolError("Pass the name of the agent the key belongs to.");
222
+ const self = session.require();
223
+ return removeAgent(db, account, self, undefined, session.registration);
224
+ }
225
+ // A token that registers its own agents is its agents' proof, and removes no other.
226
+ const token = registering(extra);
227
+ if (token !== undefined)
228
+ return removeAgent(db, account, name, key, undefined, token);
229
+ // A name with its key carries its own proof; anything else needs to know who is asking.
230
+ if (key === undefined)
231
+ session.require();
232
+ return removeAgent(db, account, name, key);
233
+ }));
234
+ server.registerTool("set_status", {
235
+ description: `Set your own status line: a short note on what you are doing, such as "reading Linear", which the dashboard shows next to your name in its agents panel and in the header bar of an open direct conversation with you. It replaces any line you set before. It stops showing ${STATUS_TTL_MS / 60000} minutes after it was set, and is cleared when a send_message or post of yours succeeds and when you are unregistered or removed. Empty text, or only spaces, clears it now. Returns status (the line as stored, or null) and expiresAt (null with no line). Sends nothing and marks nothing read. Requires register first.`,
236
+ inputSchema: {
237
+ text: z.string().describe(`The line, at most ${MAX_STATUS_LENGTH} characters, or empty or only spaces to clear it. A newline, other control character or bidirectional formatting character is refused.`),
238
+ },
239
+ }, tool(({ text }) => setStatus(db, account, session.requireAgent(), text)));
240
+ server.registerTool("list_agents", {
241
+ description: `List registered agents, and the people the operator added, by name with their bio, lastSeen (for an agent, when it was last active; for a person, when they were added) and person (true for a person). Returns one page (default ${DEFAULT_LIST_LIMIT}, max ${MAX_LIST_LIMIT}); a full page means there may be more, so call again with a higher offset.`,
242
+ inputSchema: {
243
+ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).optional(),
244
+ offset: z.number().int().min(0).optional(),
245
+ },
246
+ }, tool(({ limit, offset }) => listAgents(db, account, limit, offset)));
247
+ server.registerTool("get_agent", {
248
+ description: "Get one registered agent, or person, by name (case-insensitive).",
249
+ inputSchema: { name: z.string() },
250
+ }, tool(({ name }) => {
251
+ const agent = getAgent(db, account, name);
252
+ if (!agent)
253
+ throw new ToolError(`No agent named '${name}'.`);
254
+ return agent;
255
+ }));
256
+ server.registerTool("send_message", {
257
+ description: `Send a message to one registered agent or person by name, or to every other registered agent and person with to: 'all'. A broadcast only reaches agents registered, and people added, when it is sent. With in_reply_to, each copy stored records the id of the message it answers, which read_inbox, read_conversation and search return as inReplyTo. Returns delivered (how many copies were stored), recipients (their names) and messages: the id and recipient (to) of each copy stored, one per recipient of a broadcast, which message_status takes. Requires register first.`,
258
+ inputSchema: {
259
+ to: z.string().describe("An agent name, or 'all'."),
260
+ body: z.string().describe(`The message, at most ${MAX_BODY_LENGTH} characters.`),
261
+ in_reply_to: z
262
+ .number()
263
+ .int()
264
+ .positive()
265
+ .optional()
266
+ .describe("The id of a direct message sent to you, as read_inbox, read_conversation or search returns it, that this one answers. Any other id fails, and nothing is sent."),
267
+ },
268
+ }, tool(({ to, body, in_reply_to }) => {
269
+ const own = session.requireAgent();
270
+ return sendMessage(db, account, own.name, to, body, own, in_reply_to);
271
+ }));
272
+ server.registerTool("message_status", {
273
+ description: `Say whether messages you sent have been read: takes the ids send_message returned (1 to ${MAX_INBOX_LIMIT}) and returns, for each id once, in the order given, id, to (the recipient, by the name it had if it has since been removed), read and readAt (when its recipient's read_inbox first returned it, or null; a message to a person stays unread, since a person has no read_inbox). Only messages sent under your name since you registered it, to an agent or person of your account, can be asked about; if any id is another (one you did not send, one a removed agent with your name sent, or one that does not exist), the whole call fails with one error that does not say which. Marks nothing read. Requires register first.`,
274
+ inputSchema: {
275
+ ids: z.array(z.number().int().positive()).min(1).max(MAX_INBOX_LIMIT).describe("Message ids, as send_message returns them in messages."),
276
+ },
277
+ }, tool(({ ids }) => messageStatus(db, account, session.requireAgent(), ids)));
278
+ server.registerTool("read_inbox", {
279
+ description: `Read your messages and your mentions (channel posts that name you as @<your-name>), oldest first, one page at a time (default ${DEFAULT_INBOX_LIMIT}, max ${MAX_INBOX_LIMIT}). A message has id (its own), inReplyTo (the id of the message it answers, or null), from, sentAt, body and broadcast; to answer a message, call send_message with in_reply_to set to its id, so that an ask waiting for the answer sees it. A mention has channel, id (the post's), from, sentAt and body. By default only unread ones; everything returned is marked read, so a full page means there may be more: call again. With unread_only false, use offset to page through history. To be woken when mail arrives instead of polling, run the wait command that register returns in the background; when it fires, call read_inbox if it names messages or mentions and read_channel for each channel it names posts in, and only then start it again. Requires register first.`,
280
+ inputSchema: {
281
+ unread_only: z.boolean().optional().describe("Default true."),
282
+ limit: z.number().int().min(1).max(MAX_INBOX_LIMIT).optional(),
283
+ offset: z.number().int().min(0).optional(),
284
+ },
285
+ }, tool(({ unread_only, limit, offset }) => readInbox(db, account, session.requireAgent(), unread_only, limit, offset)));
286
+ server.registerTool("wait_for_mail", {
287
+ description: `Wait until you have mail, for a client that cannot run the wait command in the background. Returns { unread } as soon as you have an unread message, an unread post by another agent in a channel you joined or an unread @mention of you, with unread the one line the wait command prints (how many, from whom and in which channels, never the bodies); returns at once if you already have any. Otherwise it checks about once a second, and returns { unread: null } after timeout_seconds without any; a check that finds the database busy counts as nothing unread that second. Reads and marks nothing, so call read_inbox if the line names messages or mentions and read_channel for each channel it names posts in, and only then wait again. When it returns { unread: null }, call it again. Calls held at once are capped, over HTTP together with the server's GET /wait requests and over stdio for the whole process; past the cap it fails at once, so try again shortly. If your agent is removed while you wait, it fails as not registered. Requires register first.`,
288
+ inputSchema: {
289
+ timeout_seconds: z
290
+ .number()
291
+ .int()
292
+ .min(1)
293
+ .max(MAX_WAIT_FOR_MAIL_SECONDS)
294
+ .optional()
295
+ .describe(`How long to wait with no mail, in seconds: default ${DEFAULT_WAIT_FOR_MAIL_SECONDS}, at most ${MAX_WAIT_FOR_MAIL_SECONDS}.`),
296
+ },
297
+ }, tool(async ({ timeout_seconds }, extra) => {
298
+ const own = session.requireAgent();
299
+ const deadline = Date.now() + (timeout_seconds ?? DEFAULT_WAIT_FOR_MAIL_SECONDS) * 1000;
300
+ // Each check finds the caller by its registration, so a removed agent, or another that took its name, ends the call.
301
+ // Once the call holds a slot, a check that finds nothing marks the caller waiting, so a message or mention stored
302
+ // until the mark ends holds its webhook; one that finds mail does not, so mail left unread is not held by it.
303
+ let holding = false;
304
+ const answer = () => {
305
+ const unread = check(() => unreadSummary(db, account, own), NOTHING_UNREAD);
306
+ if (!unread)
307
+ throw new ToolError(NOT_REGISTERED);
308
+ if (hasUnread(unread))
309
+ return { unread: formatUnread(unread) };
310
+ if (holding)
311
+ markWaiting(db, account, own);
312
+ return undefined;
313
+ };
314
+ // Mail that is already there is answered at once, whatever the cap.
315
+ const first = answer();
316
+ if (first)
317
+ return first;
318
+ const release = hold();
319
+ if (!release)
320
+ throw new ToolError(WAIT_FOR_MAIL_FULL);
321
+ holding = true;
322
+ try {
323
+ markWaiting(db, account, own);
324
+ return (await poll(deadline, ended(extra), answer)) ?? { unread: null };
325
+ }
326
+ finally {
327
+ release();
328
+ }
329
+ }));
330
+ server.registerTool("ask", {
331
+ description: `Ask one agent a question and wait for its answer in one call: sends body to the agent to, as send_message does, and holds the call until that agent answers it with send_message and in_reply_to set to the question's id. Returns id (the question's message id) and reply: the earliest such reply, shaped as read_inbox returns a message and marked read, or null if none came within timeout_seconds or the call was cancelled. With reply null, a later answer arrives as an ordinary message: find it with read_inbox or read_conversation by its inReplyTo, the question's id; message_status with the id says only whether the question was read. It checks about once a second; a check that finds the database busy counts as no reply that time. To answer a question someone asks you, call send_message with in_reply_to set to the question's id, so that their ask sees it. 'all' and a person are refused, and nothing is sent. Calls held at once are capped together with wait_for_mail, over HTTP also with the server's GET /wait requests and over stdio for the whole process; past the cap it fails at once and sends nothing, so try again shortly. If your agent is removed while you wait, it fails as not registered. Requires register first.`,
332
+ inputSchema: {
333
+ to: z.string().describe("The agent to ask, by name. Not 'all' or a person."),
334
+ body: z.string().describe(`The question, at most ${MAX_BODY_LENGTH} characters.`),
335
+ timeout_seconds: z
336
+ .number()
337
+ .int()
338
+ .min(1)
339
+ .max(MAX_WAIT_FOR_MAIL_SECONDS)
340
+ .optional()
341
+ .describe(`How long to wait for the reply, in seconds: default ${DEFAULT_WAIT_FOR_MAIL_SECONDS}, at most ${MAX_WAIT_FOR_MAIL_SECONDS}.`),
342
+ },
343
+ }, tool(async ({ to, body, timeout_seconds }, extra) => {
344
+ const own = session.requireAgent();
345
+ // The slot comes first: no reply can exist before the question is sent, so nothing is answered without one.
346
+ const release = hold();
347
+ if (!release)
348
+ throw new ToolError(ASK_FULL);
349
+ try {
350
+ const [{ id, to: asked }] = sendQuestion(db, account, own, to, body).messages;
351
+ // Counted from once the question is stored, so time spent waiting for the write lock to send it is not taken
352
+ // from the time the other agent has to answer.
353
+ const deadline = Date.now() + (timeout_seconds ?? DEFAULT_WAIT_FOR_MAIL_SECONDS) * 1000;
354
+ // Each check finds the caller by its registration, so a removed agent, or another that took its name, ends the call.
355
+ const answer = () => {
356
+ const reply = check(() => takeReply(db, account, own, id, asked), null);
357
+ if (reply === undefined)
358
+ throw new ToolError(NOT_REGISTERED);
359
+ // poll goes on while this returns undefined: no reply yet.
360
+ return reply ?? undefined;
361
+ };
362
+ return { id, reply: (await poll(deadline, ended(extra), answer)) ?? null };
363
+ }
364
+ finally {
365
+ release();
366
+ }
367
+ }));
368
+ server.registerTool("read_conversation", {
369
+ description: `Read your direct messages with one agent or person: those they sent you and those you sent them, newest first, one page at a time (default ${DEFAULT_INBOX_LIMIT}, max ${MAX_INBOX_LIMIT}); a full page means there may be more, so call again with a higher offset. Each has id (its own), inReplyTo (the id of the message it answers, or null), from, to, sentAt, body and broadcast; a broadcast either of you sent appears as the copy the other received, and a conversation with yourself has no broadcasts. Messages a removed agent with your name sent or received are not included; if the name once belonged to a removed agent, what it sent you is included, but what you sent it is not. Marks nothing read. Requires register first.`,
370
+ inputSchema: {
371
+ name: z.string().describe("The other agent or person, in any case."),
372
+ limit: z.number().int().min(1).max(MAX_INBOX_LIMIT).optional(),
373
+ offset: z.number().int().min(0).optional(),
374
+ },
375
+ }, tool(({ name, limit, offset }) => readConversation(db, account, session.requireAgent(), name, limit, offset)));
376
+ server.registerTool("search", {
377
+ description: `Search the messages you have received and your account's channel posts for text, newest first, one page at a time (default ${DEFAULT_INBOX_LIMIT}, max ${MAX_INBOX_LIMIT}); a full page means there may be more, so call again with a higher offset. A message has kind "message", id (its own), inReplyTo (the id of the message it answers, or null), from, sentAt, body and broadcast; a post has kind "post", channel, id (the post's), from, sentAt and body. Marks nothing read and does not move your read position in any channel. Requires register first.`,
378
+ inputSchema: {
379
+ text: z.string().describe(`1-${MAX_SEARCH_LENGTH} characters, found anywhere in the body; ASCII letters match in any case, and % and _ are literal.`),
380
+ channel: z.string().optional().describe("Only this channel's posts, and no messages."),
381
+ from: z.string().optional().describe("Only what the agent with this name sent, in any case."),
382
+ since: z.string().optional().describe("A date (2026-10-20) or date and time (2026-10-20T12:00:00Z, seconds, milliseconds and offset optional; UTC when it has no offset): sent at or after it."),
383
+ until: z.string().optional().describe("A date or date and time, as since: sent before it."),
384
+ limit: z.number().int().min(1).max(MAX_INBOX_LIMIT).optional(),
385
+ offset: z.number().int().min(0).optional(),
386
+ },
387
+ }, tool(({ text, channel, from, since, until, limit, offset }) => search(db, account, session.requireAgent(), text, { channel, from, since, until }, limit, offset)));
388
+ server.registerTool("post", {
389
+ description: `Post to one of your account's channels by name; the channel is created by its first post (or by join_channel). Posting does not join the channel. Every agent in the account can read it, including agents that register later. Naming an agent of your account as @<name> gives it a mention, which wakes it and appears in its read_inbox, whether or not it has joined the channel. Posts are never edited or deleted. Requires register first.`,
390
+ inputSchema: {
391
+ channel: z.string().describe("1-32 characters: letters, digits, '-' and '_', compared case-insensitively."),
392
+ body: z.string().describe(`The post, at most ${MAX_BODY_LENGTH} characters.`),
393
+ },
394
+ }, tool(({ channel, body }) => post(db, account, session.requireAgent(), channel, body)));
395
+ server.registerTool("read_channel", {
396
+ description: `Read a channel, oldest first, one page at a time (default ${DEFAULT_CHANNEL_LIMIT}, max ${MAX_CHANNEL_LIMIT}). By default the posts after your read position in it (from its first post if you have never read it; your own posts are included, though never counted as unread), and your read position moves past what is returned, so a full page means call again. With unread_only false, use offset to page through the whole history without moving your read position. Requires register first.`,
397
+ inputSchema: {
398
+ channel: z.string().describe("The channel's name, in any case."),
399
+ unread_only: z.boolean().optional().describe("Default true."),
400
+ limit: z.number().int().min(1).max(MAX_CHANNEL_LIMIT).optional(),
401
+ offset: z.number().int().min(0).optional().describe("Only with unread_only false; passing it otherwise is an error."),
402
+ },
403
+ }, tool(({ channel, unread_only, limit, offset }) => readChannel(db, account, session.requireAgent(), channel, unread_only, limit, offset)));
404
+ server.registerTool("list_channels", {
405
+ description: `List your account's channels by name with their post count, last post time (null if nobody has posted yet), how many posts you have not read (your own posts never count) and whether you have joined it. Returns one page (default ${DEFAULT_CHANNEL_LIMIT}, max ${MAX_CHANNEL_LIMIT}); a full page means there may be more, so call again with a higher offset. Requires register first.`,
406
+ inputSchema: {
407
+ limit: z.number().int().min(1).max(MAX_CHANNEL_LIMIT).optional(),
408
+ offset: z.number().int().min(0).optional(),
409
+ },
410
+ }, tool(({ limit, offset }) => listChannels(db, account, session.requireAgent(), limit, offset)));
411
+ server.registerTool("join_channel", {
412
+ description: `Join one of your account's channels, so the wait command that register returns also wakes you for new posts there. A channel nobody has posted to yet is created. Joining a channel you have never read starts you at its latest post, so its history does not wake you (read_channel with unread_only false still pages through it); joining one you have read before keeps your read position. Requires register first.`,
413
+ inputSchema: { channel: z.string().describe("1-32 characters: letters, digits, '-' and '_', compared case-insensitively.") },
414
+ }, tool(({ channel }) => joinChannel(db, account, session.requireAgent(), channel)));
415
+ server.registerTool("leave_channel", {
416
+ description: `Leave one of your account's channels, so its posts no longer wake you. Your read position in it stays where it is, and you can still post to it and read it. Requires register first.`,
417
+ inputSchema: { channel: z.string().describe("The channel's name, in any case.") },
418
+ }, tool(({ channel }) => leaveChannel(db, account, session.requireAgent(), channel)));
419
+ return server;
420
+ }
@@ -0,0 +1,53 @@
1
+ import { createHash } from "node:crypto";
2
+ import { nameOfIdentity } from "./agents.js";
3
+ // The registered HTTP sessions `serve` can restore after a restart. Only a hash of each session id is stored, as with
4
+ // tokens and keys: anyone holding the id and the account's token could act as the session's agent.
5
+ const hashSessionId = (id) => createHash("sha256").update(id).digest("hex");
6
+ /** Records that session `id` of `account` is `identity`, replacing what it was before, unless it has ended. */
7
+ export function saveSession(db, id, account, identity, now = Date.now()) {
8
+ db.prepare(`INSERT INTO http_sessions (hash, account_id, agent_id, registered_at, instance, last_used) VALUES (?, ?, ?, ?, ?, ?)
9
+ ON CONFLICT (hash) DO UPDATE SET account_id = excluded.account_id, agent_id = excluded.agent_id,
10
+ registered_at = excluded.registered_at, instance = excluded.instance, last_used = excluded.last_used
11
+ WHERE http_sessions.ended = 0`).run(hashSessionId(id), account, identity.id, identity.registeredAt, identity.instance, now);
12
+ }
13
+ export function touchSession(db, id, now = Date.now()) {
14
+ db.prepare("UPDATE http_sessions SET last_used = ? WHERE hash = ? AND ended = 0").run(now, hashSessionId(id));
15
+ }
16
+ /**
17
+ * Marks session `id` ended for good. The row stays until the idle sweep, so that a server still holding the session,
18
+ * or a `register` on it there, cannot make it restorable again.
19
+ */
20
+ export function endSession(db, id, now = Date.now()) {
21
+ db.prepare("UPDATE http_sessions SET ended = 1, last_used = ? WHERE hash = ?").run(now, hashSessionId(id));
22
+ }
23
+ /** Whether session `id` has been ended by a DELETE, on this server or another one sharing the database. */
24
+ export function sessionEnded(db, id) {
25
+ return db.prepare("SELECT 1 FROM http_sessions WHERE hash = ? AND ended = 1").get(hashSessionId(id)) !== undefined;
26
+ }
27
+ /**
28
+ * Forgets every session last used before `cutoff`, and every ended one last used before `endedCutoff`: an ended row is
29
+ * kept longer, until every server's live copy of the session has met it on a request or been swept as idle.
30
+ */
31
+ export function forgetIdleSessions(db, cutoff, endedCutoff) {
32
+ db.prepare("DELETE FROM http_sessions WHERE last_used < ? AND (ended = 0 OR last_used < ?)").run(cutoff, endedCutoff);
33
+ }
34
+ /**
35
+ * The identity session `id` was registered as, if it belongs to `account`, has not ended, was used at or after `cutoff`
36
+ * and its registration is still the agent's current one. A session whose agent was removed is forgotten, so it is never
37
+ * restored, not even as a later agent with the same name. Another account's session is left alone and looks like none,
38
+ * and an ended one keeps its row.
39
+ */
40
+ export function restorableSession(db, id, account, cutoff) {
41
+ const hash = hashSessionId(id);
42
+ const row = db
43
+ .prepare("SELECT account_id, agent_id, registered_at, instance, last_used, ended FROM http_sessions WHERE hash = ?")
44
+ .get(hash);
45
+ if (!row || row.account_id !== account || row.ended)
46
+ return undefined;
47
+ const identity = { id: row.agent_id, registeredAt: row.registered_at, instance: row.instance };
48
+ if (row.last_used < cutoff || nameOfIdentity(db, account, identity) === undefined) {
49
+ db.prepare("DELETE FROM http_sessions WHERE hash = ?").run(hash);
50
+ return undefined;
51
+ }
52
+ return identity;
53
+ }
@@ -0,0 +1,13 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ /**
4
+ * A setting from the environment: `MESSAGE_CP_<name>`. An empty value is returned as set: `serve` and
5
+ * MESSAGE_CP_AGENT refuse it, and an empty MESSAGE_CP_DB means the default path.
6
+ */
7
+ export function setting(name, env = process.env) {
8
+ return env[`MESSAGE_CP_${name}`];
9
+ }
10
+ /** `~/.message-cp/<file>`. */
11
+ export function homeFile(file, home = homedir()) {
12
+ return join(home, ".message-cp", file);
13
+ }
package/dist/wait.js ADDED
@@ -0,0 +1,137 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { OWNER_ACCOUNT } from "./db.js";
5
+ import { setting } from "./settings.js";
6
+ /** The CLI that sits next to the running server: `dist/cli.js` for a built checkout. */
7
+ const cliPath = fileURLToPath(new URL("./cli.js", import.meta.url));
8
+ export const NAME_PLACEHOLDER = "<your-name>";
9
+ export const shellQuote = (s) => (/^[A-Za-z0-9_\/.:@%+=,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
10
+ /** The shell command that exits once `name` has unread mail, reading the same database as this server. */
11
+ export function waitCommand(name, env = process.env) {
12
+ // Carries this server's database path, so a different MESSAGE_CP_DB in the caller's shell cannot point the command elsewhere.
13
+ const path = setting("DB", env);
14
+ const db = path ? `MESSAGE_CP_DB=${shellQuote(resolve(path))} ` : "";
15
+ return `${db}node ${shellQuote(cliPath)} wait ${shellQuote(name)}`;
16
+ }
17
+ /**
18
+ * The one line `wait` prints, here and over HTTP: how many unread messages and from whom, then how many unread posts
19
+ * in joined channels and in which, then how many unread mentions and in which channels, never the bodies. With no
20
+ * unread posts or mentions it is the messages part alone, as it was before channels, so scripts that parse it keep
21
+ * working.
22
+ */
23
+ export function formatUnread(unread) {
24
+ const parts = [];
25
+ if (unread.count > 0) {
26
+ parts.push(`${unread.count} unread ${unread.count === 1 ? "message" : "messages"} from ${unread.senders.join(", ")}`);
27
+ }
28
+ if (unread.posts > 0) {
29
+ parts.push(`${unread.posts} unread ${unread.posts === 1 ? "post" : "posts"} in ${unread.channels.map((c) => `#${c}`).join(", ")}`);
30
+ }
31
+ if (unread.mentions > 0) {
32
+ parts.push(`${unread.mentions} unread ${unread.mentions === 1 ? "mention" : "mentions"} in ${unread.mentionChannels.map((c) => `#${c}`).join(", ")}`);
33
+ }
34
+ return parts.join("; ");
35
+ }
36
+ /** How long the server holds a /wait request with no mail before answering 204. */
37
+ export const DEFAULT_WAIT_HOLD_MS = 4 * 60 * 1000;
38
+ // curl gives up a little after the server would have answered, so a dead connection cannot hang the loop.
39
+ const CURL_MAX_TIME_S = DEFAULT_WAIT_HOLD_MS / 1000 + 60;
40
+ // Plain POSIX sh and curl, nothing to install. The token comes from MESSAGE_CP_HTTP_TOKEN (an empty variable counts
41
+ // as unset), else ~/.message-cp/http-token, and reaches curl on
42
+ // stdin (`-H @-`), so it is never in an argument list. $1 is the /wait URL, $2 the agent name.
43
+ // A 204 (no mail yet) or a failed request asks again; 401 and 404 end it, since asking again cannot help.
44
+ const CURL_LOOP = `t="\${MESSAGE_CP_HTTP_TOKEN:-$(cat ~/.message-cp/http-token 2>/dev/null)}"
45
+ [ -n "$t" ] || { echo "message-cp wait: set MESSAGE_CP_HTTP_TOKEN or write the token to ~/.message-cp/http-token" >&2; exit 1; }
46
+ while :; do
47
+ r=$(printf "Authorization: Bearer %s\\n" "$t" | curl -sS -m ${CURL_MAX_TIME_S} -H @- -g -G --data-urlencode "agent=$2" -w "%{http_code}" "$1")
48
+ b=\${r%???}; c=\${r#"$b"}
49
+ case $c in
50
+ 200) printf "%s" "$b"; exit 0;;
51
+ 401) echo "message-cp wait: the server refused the token (401)" >&2; exit 1;;
52
+ 404) echo "message-cp wait: 404: no agent named $2, or the server URL is wrong" >&2; exit 1;;
53
+ 204) ;;
54
+ *) sleep 5;;
55
+ esac
56
+ done`;
57
+ // The same loop for a session connected by approval, whose MCP client holds its token: the token is the agent's listen
58
+ // token, read only from ~/.message-cp/wait/<name>@<server>, never from MESSAGE_CP_HTTP_TOKEN or
59
+ // ~/.message-cp/http-token, so agents on one machine never share or overwrite one file. $3 is the <server> part.
60
+ const CURL_401 = "the server refused the token (401)";
61
+ if (!CURL_LOOP.includes(CURL_401))
62
+ throw new Error("CURL_LOOP's 401 line changed: update LISTEN_LOOP");
63
+ const LISTEN_LOOP = `f="$HOME/.message-cp/wait/$2@$3"
64
+ t=$(cat "$f" 2>/dev/null)
65
+ [ -n "$t" ] || { echo "message-cp wait: no listen token in $f; call the listen_token tool and save its token there as it says" >&2; exit 1; }
66
+ ${CURL_LOOP.slice(CURL_LOOP.indexOf("while :; do")).replace(CURL_401, "the server refused the listen token (401): a newer one replaced it, its agent was removed or its approval revoked")}`;
67
+ /** The `/wait` URL on the server at `publicUrl`, built from the parsed URL. */
68
+ function waitUrl(publicUrl) {
69
+ const url = new URL(publicUrl);
70
+ // Set as the path, never resolved as a reference, so no path can change the host.
71
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/wait`;
72
+ return url;
73
+ }
74
+ /**
75
+ * The shell command that exits once `name` has unread mail on the server at `publicUrl`, over HTTP. `publicUrl` is one
76
+ * `serve` checked with `publicUrl` in connect.ts; `/wait` is built from the parsed URL.
77
+ */
78
+ export function httpWaitCommand(publicUrl, name) {
79
+ return `sh -c '${CURL_LOOP}' sh ${shellQuote(waitUrl(publicUrl).href)} ${shellQuote(name)}`;
80
+ }
81
+ /**
82
+ * The <server> part of a listen token's file name for the account `account`: the public URL's host and port, with
83
+ * anything but [A-Za-z0-9.-] as `_`, then `-` and 16 hex digits of a sha256 of the account and the `/wait` URL. Agent
84
+ * names are unique only within an account, and two servers may share a host under different paths, so both are in it.
85
+ */
86
+ const listenServer = (publicUrl, account) => `${new URL(publicUrl).host.replace(/[^A-Za-z0-9.-]/g, "_")}-${createHash("sha256").update(`${account} ${waitUrl(publicUrl).href}`).digest("hex").slice(0, 16)}`;
87
+ /** The file, under `~`, that the listen command for `name` of the account `account` on the server at `publicUrl` reads its listen token from. */
88
+ export const listenTokenFile = (publicUrl, account, name) => `~/.message-cp/wait/${name}@${listenServer(publicUrl, account)}`;
89
+ /**
90
+ * How an agent saves its listen token to `listenTokenFile`, privately: the directories 0700 and the file 0600. The file
91
+ * is removed first, so one already there with a wider mode is made again under the umask. The token is never in it;
92
+ * `<listen token>` is a placeholder the agent replaces.
93
+ */
94
+ export const saveListenToken = (publicUrl, account, name) => {
95
+ const file = `~/.message-cp/wait/${shellQuote(`${name}@${listenServer(publicUrl, account)}`)}`;
96
+ return `Save listenToken, and nothing else, to ${listenTokenFile(publicUrl, account, name)}, privately: mkdir -p ~/.message-cp/wait && chmod 700 ~/.message-cp ~/.message-cp/wait, then rm -f ${file} && (umask 077 && printf '%s\\n' '<listen token>' > ${file}), with the token in place of <listen token>. Never put it in a URL, a message or a log.`;
97
+ };
98
+ /**
99
+ * The shell command that exits once `name` of the account `account` has unread mail on the server at `publicUrl`, for
100
+ * a session connected by approval: like `httpWaitCommand`, but with the agent's listen token from `listenTokenFile`.
101
+ */
102
+ export function listenWaitCommand(publicUrl, account, name) {
103
+ return `sh -c '${LISTEN_LOOP}' sh ${shellQuote(waitUrl(publicUrl).href)} ${shellQuote(name)} ${shellQuote(listenServer(publicUrl, account))}`;
104
+ }
105
+ /**
106
+ * What a connecting session is told about being woken by messages. `agent` is the agent an HTTP session already is,
107
+ * because its token is bound to it: its command names that agent, and it is not told to register. `approval` is set for
108
+ * a session connected by approval, whose command reads a listen token (`listenWaitCommand`) that it is told how to get;
109
+ * `account` is the session's account, which names that token's file.
110
+ */
111
+ export function instructions(env = process.env, remote = false, publicUrl, agent, approval = false, account = OWNER_ACCOUNT) {
112
+ // A remote agent cannot run `wait`, which reads the database file on the server's machine; it can ask /wait if
113
+ // the server knows the URL clients reach it at.
114
+ if (remote && !publicUrl) {
115
+ return "message-cp does not push messages; an agent has to ask for them. Call read_inbox to check for mail, and list_channels for unread posts. To wait for mail in one call instead, call wait_for_mail. It reads and marks nothing: when it returns a line, call read_inbox if the line names messages or mentions and read_channel for each channel it names posts in, and only then wait again, since a call made before reading returns at once on the same mail. When it returns { unread: null }, its timeout passed without finding mail: call it again.";
116
+ }
117
+ const command = !remote
118
+ ? waitCommand(setting("AGENT", env) || NAME_PLACEHOLDER, env)
119
+ : approval
120
+ ? listenWaitCommand(publicUrl, account, agent ?? NAME_PLACEHOLDER)
121
+ : httpWaitCommand(publicUrl, agent ?? NAME_PLACEHOLDER);
122
+ // A session connected by approval has no token its shell can read: it gets a listen token for its agent first.
123
+ const first = !approval
124
+ ? remote && agent
125
+ ? `as agent '${agent}', which this session already is,`
126
+ : "register and then"
127
+ : agent
128
+ ? `as agent '${agent}', which this session already is, call listen_token, save the listen token it returns as it says, and then`
129
+ : "register, save the listen token it returns as it says, and then";
130
+ return [
131
+ "message-cp does not push messages; an agent has to ask for them.",
132
+ `To be woken when a message arrives, ${first} run this as a background command (in Claude Code, a Bash call with run_in_background): ${command}`,
133
+ "It exits as soon as you have unread mail, unread posts in a channel you joined with join_channel, or an unread @mention of you, and prints how many, from whom and in which channels, without reading them.",
134
+ "When it fires, call read_inbox if it names messages or mentions and read_channel for each channel it names posts in, and only then start the command again; starting it before reading fires at once on the same mail.",
135
+ "A client that cannot run a background command can call wait_for_mail instead: it holds the call until the same mail arrives or its timeout passes, and returns the same line. Read what it names the same way before calling it again. When it returns { unread: null }, its timeout passed without finding mail: call it again.",
136
+ ].join("\n");
137
+ }