message-cp 0.1.0 → 0.2.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/README.md CHANGED
@@ -8,18 +8,19 @@ message-cp does not sign people in. Over HTTP it accepts only bearer tokens it i
8
8
 
9
9
  ## Quick start
10
10
 
11
- Needs Node 22.14 or later and git.
11
+ Needs Node 22.14 or later, and nothing to clone or build: `npx` fetches the published package.
12
12
 
13
13
  ```sh
14
- git clone https://github.com/colinhallett/message-cp.git
15
- cd message-cp
16
- npm ci --ignore-scripts
17
- npm run build
18
- npm test
19
- npm install -g .
14
+ npx -y message-cp init
20
15
  ```
21
16
 
22
- `--ignore-scripts` skips compiling the SQLite driver, `better-sqlite3`, which ships prebuilt binaries for Linux, macOS and Windows on x64 and arm64; without it, `npm ci` needs `make` and a C++ compiler. That gives you `dist/index.js`, the stdio server, and `dist/cli.js`, the `message-cp` command for everything else (`message-cp serve`, `wait`, `account`, `token`; `message-cp help` lists them all). `npm install -g .` links that command onto your `PATH` from the clone, compiling and downloading nothing. On Linux or macOS, if npm's global folder needs root, that step fails with `EACCES`; `npm install -g . --prefix ~/.local` puts the command in `~/.local/bin` instead, which must be on your `PATH`. Without either, `node dist/cli.js` in the clone does the same, for example `node dist/cli.js account list`. Add the stdio server to an MCP client as [Add it to an MCP client](https://message-cp.vercel.app/clients.html#add-it-to-an-mcp-client) in the reference says (the agents are the client's sessions; there is no separate chat command), open two sessions, and ask one to "register as alice" and the other to "register as bob and send alice a message".
17
+ `init` adds message-cp to Claude Code, Codex, Cursor or Claude Desktop; a `message-cp` entry that runs something else is left as it is, and `init` stops and says how to remove it. Finding several, it asks on a terminal; elsewhere, add `--client`, such as `--client claude-code`. It then checks the server with a test agent, which it removes, and prints how to undo. By hand, for Claude Code:
18
+
19
+ ```sh
20
+ claude mcp add message-cp -- npx -y message-cp
21
+ ```
22
+
23
+ Each of the client's sessions registers as its own agent, and all share one database; there is no separate chat command. Open two sessions, and ask one to "register as alice" and the other to "register as bob and send alice a message". `npx -y message-cp help` lists the other commands. For a team server or Docker, see [Getting started](https://message-cp.vercel.app/getting-started.html); for Cursor by hand or Claude Code over HTTP, [Add it to an MCP client](https://message-cp.vercel.app/clients.html#add-it-to-an-mcp-client). To build from a clone, see [CONTRIBUTING.md](CONTRIBUTING.md#building-and-testing).
23
24
 
24
25
  ## Documentation
25
26
 
package/dist/cli.js CHANGED
@@ -17,7 +17,7 @@ const USAGE = {
17
17
  serve: "message-cp serve [--host <address>] [--port <port>] [--public-url <url>]",
18
18
  wait: "message-cp wait <name>",
19
19
  account: "message-cp account add <name> | message-cp account list | message-cp token add <account> [--read-only | --agent-only | --person <name>] | message-cp token list | message-cp token revoke <token id>",
20
- webhook: "message-cp webhook add --url <url> --on <#channel | mention:<agent> | agent:<agent>> [--account <name>] [--no-text] [--header-stdin] [--max-per-hour <n>] [--body <json>] | message-cp webhook list [--account <name>] | message-cp webhook remove <webhook id>",
20
+ webhook: "message-cp webhook add --url <url> --on <#channel | mention:<agent> | agent:<agent> | agent:*> [--account <name>] [--no-text] [--header-stdin] [--max-per-hour <n>] [--body <json>] | message-cp webhook list [--account <name>] | message-cp webhook remove <webhook id>",
21
21
  person: "message-cp person add <name> [--account <name>] [--admin] | message-cp person admin <name> [--off] [--account <name>] | message-cp person remove <name> [--account <name>] | message-cp person list [--account <name>]",
22
22
  agent: "message-cp agent list [--account <name>] | message-cp agent remove <name> [--account <name>] | message-cp agent key reset <name> [--account <name>]",
23
23
  connect: "message-cp connect <agent> --url <public url> [--account <name>] [--new-key]",
@@ -352,8 +352,9 @@ else if (command === "agent") {
352
352
  db.close();
353
353
  }
354
354
  else if (command === "connect") {
355
- // Run on the server, like `agent`. The token and the agent's key are printed this once, to stdout, inside the client
356
- // setup; stderr says what was made and how to revoke the token, and never carries a token, a key or a hash.
355
+ // Run on the server, like `agent`. The token is printed this once, to stdout, inside the client setup, and the agent's
356
+ // key never is, since the token is bound to the agent; stderr says what was made and how to revoke the token, and
357
+ // never carries a token, a key or a hash.
357
358
  const usage = () => {
358
359
  console.error(`usage: ${USAGE.connect}`);
359
360
  process.exit(2);
@@ -385,7 +386,7 @@ else if (command === "connect") {
385
386
  console.error(c.reset
386
387
  ? `message-cp: new key for agent '${c.name}' of account '${c.account}'; its old key no longer works and its current sessions have ended.`
387
388
  : `message-cp: agent '${c.name}' in account '${c.account}'.`);
388
- console.error(`message-cp: agent token ${c.tokenId} for account '${c.account}'. The output holds this token and the agent's key, shown only now; keep them. To revoke the token: message-cp token list, then message-cp token revoke ${c.tokenId}.`);
389
+ console.error(`message-cp: agent token ${c.tokenId} for account '${c.account}'. The output holds this token, shown only now; keep it. To revoke the token: message-cp token list, then message-cp token revoke ${c.tokenId}.`);
389
390
  console.log(clientSetup(endpoint, c).replace(/\n$/, ""));
390
391
  }
391
392
  catch (err) {
@@ -505,7 +506,7 @@ else if (command === "init") {
505
506
  run: realRunner,
506
507
  interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
507
508
  ask: askOnTerminal,
508
- prove: () => (remote === undefined ? proveServer(ownServer()) : proveRemote(remote)),
509
+ prove: (checked) => (checked === undefined ? proveServer(ownServer()) : proveRemote(checked)),
509
510
  out: (line) => console.log(line),
510
511
  err: (line) => console.error(line),
511
512
  });
package/dist/connect.js CHANGED
@@ -2,7 +2,8 @@ import { addToken } from "./accounts.js";
2
2
  import { registerWithIdentity, resetAgentKey, validateName } from "./agents.js";
3
3
  import { shellQuote } from "./wait.js";
4
4
  // `message-cp connect`: what a client needs to act as one agent of an account over HTTP, made in one step on the
5
- // server. The token and key are returned for the CLI to print once, to stdout; only their hashes are stored.
5
+ // server. The token is returned for the CLI to print once, to stdout; only its hash is stored, and the agent's key,
6
+ // also returned, is not printed, since the token is bound to the agent.
6
7
  export class ConnectError extends Error {
7
8
  }
8
9
  /**
@@ -68,13 +69,12 @@ export const connectAgent = (db, account, name, newKey) => db.transaction(() =>
68
69
  const token = addToken(db, acct.name, false, true, agent.id);
69
70
  return { name: agent.name, account: acct.name, reset: live !== undefined, key: agent.key, tokenId: token.id, token: token.token };
70
71
  }).immediate();
71
- /** The two things `connect` prints: a `claude mcp add` command, then the same server as a JSON `mcpServers` entry. */
72
+ /**
73
+ * The two things `connect` prints: a `claude mcp add` command, then the same server as a JSON `mcpServers` entry. Both
74
+ * send only the token: it is bound to the agent, so it alone acts as it, and the agent's key is never printed.
75
+ */
72
76
  export function clientSetup(endpoint, c) {
73
- const headers = {
74
- Authorization: `Bearer ${c.token}`,
75
- "X-Message-CP-Agent": c.name,
76
- "X-Message-CP-Agent-Key": c.key,
77
- };
77
+ const headers = { Authorization: `Bearer ${c.token}` };
78
78
  const command = [
79
79
  "claude mcp add --transport http message-cp",
80
80
  shellQuote(endpoint.href),
package/dist/dashboard.js CHANGED
@@ -672,7 +672,7 @@ const NO_CHANNELS_FULL = ' You can also start one from "New channel" above.';
672
672
  const NO_MESSAGES = "No direct messages yet: they appear here when an agent sends one with the send_message tool.";
673
673
  const NO_MESSAGES_FULL = ' You can also start one from "New message" above.';
674
674
  const NO_DELIVERIES =
675
- "No webhook deliveries yet: add a webhook on the server with message-cp webhook add --url <url> --on <#channel | mention:<agent> | agent:<agent>>, and its deliveries show here once it fires.";
675
+ "No webhook deliveries yet: add a webhook on the server with message-cp webhook add --url <url> --on <#channel | mention:<agent> | agent:<agent> | agent:*>, and its deliveries show here once it fires.";
676
676
  const NO_APPROVALS =
677
677
  "No approved clients yet: a client approved as one named agent, or as one that registers its own agents, appears here " +
678
678
  "once it connects through the server's authorization flow (serve --public-url), and its token is revoked on the server " +
@@ -702,6 +702,9 @@ let peopleRowsKey = "";
702
702
  let inviteClosings = 0;
703
703
  // The id of the invite whose link the view shows, or undefined, so cancelling that invite takes its link away.
704
704
  let shownInvite;
705
+ // How many times showInvite has shown a link, so a write with no answer hides only a link shown before it started, never
706
+ // one another request showed meanwhile.
707
+ let invitesShown = 0;
705
708
  // The invite the "People" view asks before cancelling ({ id, person, createdAt }), or undefined.
706
709
  let cancelTarget;
707
710
  // The list the people view's invites and the "New link" select were last built from, so a poll rebuilds them only when it
@@ -1545,12 +1548,22 @@ function showInvite(body) {
1545
1548
  "It works once and lasts " + INVITE_DAYS + " days, until " + when(invite.expiresAt) + ". It shows only here, until you leave this view.";
1546
1549
  inviteLink.value = link;
1547
1550
  shownInvite = invite.id;
1551
+ invitesShown++;
1548
1552
  copyNote.textContent = "";
1549
1553
  inviteMade.hidden = false;
1550
1554
  inviteLink.focus();
1551
1555
  return true;
1552
1556
  }
1553
1557
 
1558
+ // Hides the invite link showInvite showed, with its line and its copy note.
1559
+ function hideInvite() {
1560
+ inviteMade.hidden = true;
1561
+ inviteFor.textContent = "";
1562
+ inviteLink.value = "";
1563
+ copyNote.textContent = "";
1564
+ shownInvite = undefined;
1565
+ }
1566
+
1554
1567
  // Reads the people and the invites again at once, after a write that may have changed them.
1555
1568
  function refresh() {
1556
1569
  cancel();
@@ -1573,6 +1586,7 @@ async function addInvitedPerson() {
1573
1586
  }
1574
1587
  adding = tokenGen;
1575
1588
  const closings = inviteClosings;
1589
+ const shown = invitesShown;
1576
1590
  personAdd.disabled = true;
1577
1591
  personNote.textContent = "Adding…";
1578
1592
  const ac = new AbortController();
@@ -1598,7 +1612,10 @@ async function addInvitedPerson() {
1598
1612
  return;
1599
1613
  }
1600
1614
  if (answer.error) {
1601
- personNote.textContent = answer.error + ", so " + name + " may have been added: the agents panel and the invites below show whether.";
1615
+ // A link may have been made, so the one shown before may be taken for it. A link another request showed meanwhile
1616
+ // stays: it is that request's own result.
1617
+ if (shown === invitesShown) hideInvite();
1618
+ personNote.textContent = answer.error + ", so " + name + " may or may not have been added: the agents panel and the invites below show which once they have been read again.";
1602
1619
  refresh();
1603
1620
  return;
1604
1621
  }
@@ -1618,6 +1635,7 @@ async function makeInvite() {
1618
1635
  }
1619
1636
  linking = tokenGen;
1620
1637
  const closings = inviteClosings;
1638
+ const shown = invitesShown;
1621
1639
  linkMake.disabled = true;
1622
1640
  linkNote.textContent = "Making a link…";
1623
1641
  const ac = new AbortController();
@@ -1640,7 +1658,10 @@ async function makeInvite() {
1640
1658
  return;
1641
1659
  }
1642
1660
  if (answer.error) {
1643
- linkNote.textContent = answer.error + ", so a link for " + person + " may have been made: the invites below show whether.";
1661
+ // A link may have been made, so the one shown before may be taken for it. A link another request showed meanwhile
1662
+ // stays: it is that request's own result.
1663
+ if (shown === invitesShown) hideInvite();
1664
+ linkNote.textContent = answer.error + ", so a link for " + person + " may or may not have been made: the invites below show which once they have been read again.";
1644
1665
  refresh();
1645
1666
  return;
1646
1667
  }
@@ -1714,17 +1735,12 @@ async function cancelInvite() {
1714
1735
  if (answer.status === 403) return unmarked();
1715
1736
  // A confirmation opened for another invite meanwhile stays.
1716
1737
  if (cancelTarget === invite) closeCancelInvite(true);
1717
- // Once the server says this invite is gone (200, or 404: used, cancelled or expired), its link, if shown, goes too.
1718
- if ((answer.status === 200 || answer.status === 404) && shownInvite === invite.id) {
1719
- inviteMade.hidden = true;
1720
- inviteFor.textContent = "";
1721
- inviteLink.value = "";
1722
- copyNote.textContent = "";
1723
- shownInvite = undefined;
1724
- }
1738
+ // Once the server says this invite is gone (200, or 404: used, cancelled or expired), or may be (no answer), its link,
1739
+ // if shown, goes too.
1740
+ if ((answer.status === 200 || answer.status === 404 || answer.error) && shownInvite === invite.id) hideInvite();
1725
1741
  if (answer.status === 200) cancelNote.textContent = "Cancelled " + invite.person + "'s invite.";
1726
1742
  else if (answer.status === 404) cancelNote.textContent = invite.person + "'s invite was already used, cancelled or expired (404).";
1727
- else if (answer.error) cancelNote.textContent = answer.error + ", so " + invite.person + "'s invite may have been cancelled: the list below shows whether.";
1743
+ else if (answer.error) cancelNote.textContent = answer.error + ", so " + invite.person + "'s invite may or may not have been cancelled: the list below shows which once it has been read again.";
1728
1744
  else cancelNote.textContent = refusal(answer);
1729
1745
  if (answer.status === 200 || answer.status === 404 || answer.error) refresh();
1730
1746
  }
@@ -1755,7 +1771,7 @@ async function markPerson(name, admin) {
1755
1771
  if (answer.status === 403) return unmarked();
1756
1772
  if (answer.status === 200) peopleNote.textContent = admin ? name + " is now an admin." : name + " is no longer an admin.";
1757
1773
  else if (answer.status === 404) peopleNote.textContent = name + " is already gone (404).";
1758
- else if (answer.error) peopleNote.textContent = answer.error + ", so " + name + " may have been changed: the list below shows whether.";
1774
+ else if (answer.error) peopleNote.textContent = answer.error + ", so " + name + " may or may not have been changed: the list below shows which once it has been read again.";
1759
1775
  else peopleNote.textContent = refusal(answer);
1760
1776
  if (answer.status === 200 || answer.status === 404 || answer.error) refresh();
1761
1777
  }
@@ -1868,7 +1884,7 @@ async function removeAccountPerson() {
1868
1884
  ? "Removed " + name + ": " + counted(body.messages, "message") + " and " + counted(body.mentions, "mention") + " deleted."
1869
1885
  : "Removed " + name + ".") + stopped;
1870
1886
  } else if (answer.status === 404) peopleNote.textContent = name + " is already gone (404).";
1871
- else if (answer.error) peopleNote.textContent = answer.error + ", so " + name + " may have been removed: the list below shows whether.";
1887
+ else if (answer.error) peopleNote.textContent = answer.error + ", so " + name + " may or may not have been removed: the list below shows which once it has been read again.";
1872
1888
  else peopleNote.textContent = refusal(answer);
1873
1889
  if (answer.status === 200 || answer.status === 404 || answer.error) refresh();
1874
1890
  }
@@ -1932,7 +1948,7 @@ async function removeWebhook() {
1932
1948
  ? "Removed webhook " + hook.id + " and its " + (body.deliveries === 1 ? "1 delivery" : body.deliveries + " deliveries") + "."
1933
1949
  : "Removed webhook " + hook.id + ".";
1934
1950
  } else if (answer.status === 404) webhookNote.textContent = "Webhook " + hook.id + " was already gone (404).";
1935
- else if (answer.error) webhookNote.textContent = answer.error + ", so webhook " + hook.id + " may have been removed: the list below shows whether.";
1951
+ else if (answer.error) webhookNote.textContent = answer.error + ", so webhook " + hook.id + " may or may not have been removed: the list below shows which once it has been read again.";
1936
1952
  else webhookNote.textContent = refusal(answer);
1937
1953
  if (answer.status === 200 || answer.status === 404 || answer.error) refresh();
1938
1954
  }
@@ -2012,8 +2028,8 @@ async function addAccountWebhook() {
2012
2028
  // Another webhook may have been added, so the secret shown before may no longer be the newest.
2013
2029
  hideWebhookSecret();
2014
2030
  hookNote.textContent =
2015
- answer.error + ", so the webhook may or may not have been added: the list below shows whether. " +
2016
- "No secret can be shown for it, so if it is listed, remove it and add it again.";
2031
+ answer.error + ", so the webhook may or may not have been added: the list below shows which once it has been read again. " +
2032
+ "No secret can be shown for it, and the list cannot show which webhook this attempt added, so remove one there only if you know it is that one, then add it again.";
2017
2033
  refresh();
2018
2034
  return;
2019
2035
  }
@@ -2109,7 +2125,7 @@ async function revokeApproval() {
2109
2125
  const named = "token " + target.id + " (" + target.client + ")";
2110
2126
  if (answer.status === 200) approvalNote.textContent = "Revoked " + named + "." + endedAgents(answer.body);
2111
2127
  else if (answer.status === 404) approvalNote.textContent = "Token " + target.id + " (" + target.client + ") was already revoked or gone (404).";
2112
- else if (answer.error) approvalNote.textContent = answer.error + ", so " + named + " may have been revoked: the list below shows whether.";
2128
+ else if (answer.error) approvalNote.textContent = answer.error + ", so " + named + " may or may not have been revoked: the list below shows which once it has been read again.";
2113
2129
  else approvalNote.textContent = refusal(answer);
2114
2130
  if (answer.status === 200 || answer.status === 404 || answer.error) refresh();
2115
2131
  }
@@ -2184,7 +2200,7 @@ async function revokeToken() {
2184
2200
  tokenNote.textContent = "Revoked token " + target.id + " (" + target.label + ")." + ended + endedAgents(answer.body);
2185
2201
  }
2186
2202
  else if (answer.status === 404) tokenNote.textContent = "Token " + target.id + " was already revoked or gone (404).";
2187
- else if (answer.error) tokenNote.textContent = answer.error + ", so token " + target.id + " may or may not have been revoked: the list below shows which.";
2203
+ else if (answer.error) tokenNote.textContent = answer.error + ", so token " + target.id + " may or may not have been revoked: the list below shows which once it has been read again.";
2188
2204
  else tokenNote.textContent = refusal(answer);
2189
2205
  if (answer.status === 200 || answer.status === 404 || answer.error) refresh();
2190
2206
  }
@@ -2449,7 +2465,7 @@ async function removeAgent() {
2449
2465
  ? "Removed " + name + ": " + counted(body.messages, "message") + " and " + counted(body.mentions, "mention") + " deleted."
2450
2466
  : "Removed " + name + ".") + stopped;
2451
2467
  } else if (answer.status === 404) agentNote.textContent = name + " is already gone (404).";
2452
- else if (answer.error) agentNote.textContent = answer.error + ", so " + name + " may have been removed: the list shows whether.";
2468
+ else if (answer.error) agentNote.textContent = answer.error + ", so " + name + " may or may not have been removed: the list shows which once it has been read again.";
2453
2469
  else agentNote.textContent = refusal(answer);
2454
2470
  if (forgot) refresh();
2455
2471
  }
@@ -4838,7 +4854,7 @@ export const DASHBOARD_HTML = `<!doctype html>
4838
4854
  <input id="hook-url" type="text" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://example.com/hook"></div>
4839
4855
  <div class="field"><label for="hook-on">Fires on</label>
4840
4856
  <input id="hook-on" type="text" autocomplete="off" spellcheck="false" placeholder="#general" aria-describedby="hook-on-hint">
4841
- <span id="hook-on-hint" class="note">#&lt;channel&gt;, mention:&lt;agent&gt; or agent:&lt;agent&gt;</span></div>
4857
+ <span id="hook-on-hint" class="note">#&lt;channel&gt;, mention:&lt;agent&gt;, agent:&lt;agent&gt; or agent:* (every agent, not people)</span></div>
4842
4858
  <label class="check"><input id="hook-text" type="checkbox" checked> Send the message text</label>
4843
4859
  <div class="field"><label for="hook-header-name">Header name (optional)</label>
4844
4860
  <input id="hook-header-name" type="text" autocomplete="off" spellcheck="false" placeholder="Authorization"></div>
@@ -4848,7 +4864,7 @@ export const DASHBOARD_HTML = `<!doctype html>
4848
4864
  <input id="hook-max" type="number" min="1" step="1" inputmode="numeric" autocomplete="off"></div>
4849
4865
  <div class="field wide"><label for="hook-body">Fixed body (optional)</label>
4850
4866
  <textarea id="hook-body" rows="2" autocomplete="off" spellcheck="false" placeholder='{"requestId":"{{delivery}}"}' aria-describedby="hook-body-hint"></textarea>
4851
- <span id="hook-body-hint" class="note">A JSON object sent in place of the event, with {{delivery}} once in a string value; it never carries the text.</span></div>
4867
+ <span id="hook-body-hint" class="note">A JSON object sent in place of the event, with {{delivery}} once in a string value, and for agent: any {{agent}} in one; it never carries the text.</span></div>
4852
4868
  <div class="actions"><button id="hook-add" type="submit">Add webhook</button></div>
4853
4869
  </form>
4854
4870
  <div id="webhook-made" hidden>
package/dist/db.js CHANGED
@@ -60,6 +60,37 @@ const MESSAGES_REPLY_INDEXES = `
60
60
  CREATE INDEX IF NOT EXISTS messages_by_reply ON messages (in_reply_to);
61
61
  -- \`ask\` finds the replies to one question sent to the asker through this, in id order, not by walking every reply to it.
62
62
  CREATE INDEX IF NOT EXISTS messages_by_question ON messages (in_reply_to, recipient_id);`;
63
+ // Outgoing webhooks, added only with `message-cp webhook`; an admin person token also lists and removes them over
64
+ // /api/webhooks. `event` is 'channel', 'mention' or 'agent' and `name` the channel or agent in the same account,
65
+ // which need not exist yet, or '*' with 'agent' for every agent of the account (`agent:*`), a name no agent can have. The secret is stored as is: deliveries sign with it.
66
+ // So is the value of the one header a webhook may carry (`header_name`, `header_value`), since deliveries send it.
67
+ // `max_per_hour` (null for none) caps its delivery requests in a fixed window: `window_started_at` is when the first
68
+ // request after the last window ended was claimed, and `window_count` how many requests have been claimed since; the
69
+ // window lasts 60 minutes. `fixed_body` is the JSON body the owner set with `webhook add --body`, sent in place of the
70
+ // event body, or null to send the event body. AUTOINCREMENT so a webhook's id, which GET and DELETE /api/webhooks and
71
+ // `webhook list` hand out, never names a later webhook once the highest one is removed. A table from before it is
72
+ // rebuilt by rebuildWebhooks(), which creates the same table; the columns after `created_at` are added to it in
73
+ // migrate first.
74
+ const webhooksTable = (table) => `
75
+ CREATE TABLE IF NOT EXISTS ${table} (
76
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
77
+ account_id INTEGER NOT NULL REFERENCES accounts(id),
78
+ url TEXT NOT NULL,
79
+ event TEXT NOT NULL CHECK (event IN ('channel', 'mention', 'agent')),
80
+ name TEXT NOT NULL,
81
+ include_text INTEGER NOT NULL,
82
+ secret TEXT NOT NULL,
83
+ created_at INTEGER NOT NULL,
84
+ header_name TEXT,
85
+ header_value TEXT,
86
+ max_per_hour INTEGER,
87
+ window_started_at INTEGER,
88
+ window_count INTEGER NOT NULL DEFAULT 0,
89
+ fixed_body TEXT
90
+ );`;
91
+ // Dropping a table drops its indexes, so rebuildWebhooks() creates this again.
92
+ const WEBHOOKS_INDEXES = `
93
+ CREATE INDEX IF NOT EXISTS webhooks_by_name ON webhooks (account_id, event, name COLLATE NOCASE);`;
63
94
  // One-time dashboard sign-in codes from `message-cp login-link`, and invites an admin person made (src/login.ts). Only
64
95
  // the code's hash is kept. `person_id` is set for a link that signs in as that person; `used_at` once it has given its
65
96
  // token or, for an invite, was cancelled. AUTOINCREMENT so an invite's id, which GET and DELETE /api/invites hand out,
@@ -166,25 +197,8 @@ CREATE INDEX IF NOT EXISTS mentions_unread ON mentions (agent_id, read_at, post_
166
197
  -- GET /api/channel-posts reads a page of posts' mentions through this, not by scanning every mention.
167
198
  CREATE INDEX IF NOT EXISTS mentions_by_post ON mentions (post_id);
168
199
 
169
- -- Outgoing webhooks, added only with \`message-cp webhook\`; an admin person token also lists and removes them over
170
- -- /api/webhooks. \`event\` is 'channel', 'mention' or 'agent' and \`name\` the channel or agent in the same account,
171
- -- which need not exist yet. The secret is stored as is: deliveries sign with it.
172
- -- So is the value of the one header a webhook may carry (\`header_name\`, \`header_value\`, added in \`migrate\`), since
173
- -- deliveries send it. \`max_per_hour\` (also added in \`migrate\`, null for none) caps its delivery requests in a fixed
174
- -- window: \`window_started_at\` is when the first request after the last window ended was claimed, and \`window_count\`
175
- -- how many requests have been claimed since; the window lasts 60 minutes. \`fixed_body\` (also added in \`migrate\`) is the
176
- -- JSON body the owner set with \`webhook add --body\`, sent in place of the event body, or null to send the event body.
177
- CREATE TABLE IF NOT EXISTS webhooks (
178
- id INTEGER PRIMARY KEY,
179
- account_id INTEGER NOT NULL REFERENCES accounts(id),
180
- url TEXT NOT NULL,
181
- event TEXT NOT NULL CHECK (event IN ('channel', 'mention', 'agent')),
182
- name TEXT NOT NULL,
183
- include_text INTEGER NOT NULL,
184
- secret TEXT NOT NULL,
185
- created_at INTEGER NOT NULL
186
- );
187
- CREATE INDEX IF NOT EXISTS webhooks_by_name ON webhooks (account_id, event, name COLLATE NOCASE);
200
+ ${webhooksTable("webhooks")}
201
+ ${WEBHOOKS_INDEXES}
188
202
 
189
203
  -- What a webhook is owed: one row per webhook per message or post it is for, queued in the transaction that stores the
190
204
  -- message or post. Only ids are kept, so the body is built when it is sent. \`serve\` sends them: \`attempts\` is counted
@@ -460,6 +474,43 @@ function rebuildDashboardSessions(db) {
460
474
  db.exec(DASHBOARD_SESSIONS_INDEXES);
461
475
  }).immediate();
462
476
  }
477
+ /** 1 when rebuildWebhooks() has copied every webhook into its new table. Both tables hold only webhooks, not history. */
478
+ export const COUNT_WEBHOOKS_COPIED = "SELECT (SELECT count(*) FROM webhooks) = (SELECT count(*) FROM webhooks_autoincrement)";
479
+ const webhooksAutoincrement = (db) => /\bAUTOINCREMENT\b/i.test(db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'webhooks'").get().sql);
480
+ /**
481
+ * Rebuilds a `webhooks` table from before AUTOINCREMENT as `webhooksTable`, keeping every row, its id and every column,
482
+ * as rebuildLoginCodes() does for `login_codes`, so that no webhook id is handed out again once the highest webhook is
483
+ * removed. Under the write lock, so exactly one process rebuilds. `webhook_deliveries` refers to `webhooks`, so as in
484
+ * rebuildMessages() foreign keys are off around it. Ids are copied as they are, so every delivery still finds its webhook
485
+ * once every row is copied; it checks that by counting webhooks rather than with foreign_key_check, which would read every
486
+ * table's history under the write lock.
487
+ */
488
+ function rebuildWebhooks(db) {
489
+ if (webhooksAutoincrement(db))
490
+ return;
491
+ // Dropping the old table must not cascade to the deliveries that reference it.
492
+ const foreignKeys = db.pragma("foreign_keys", { simple: true });
493
+ db.pragma("foreign_keys = OFF");
494
+ try {
495
+ db.transaction(() => {
496
+ if (webhooksAutoincrement(db))
497
+ return;
498
+ db.exec(webhooksTable("webhooks_autoincrement"));
499
+ db.exec(`INSERT INTO webhooks_autoincrement (id, account_id, url, event, name, include_text, secret, created_at, header_name, header_value,
500
+ max_per_hour, window_started_at, window_count, fixed_body)
501
+ SELECT id, account_id, url, event, name, include_text, secret, created_at, header_name, header_value,
502
+ max_per_hour, window_started_at, window_count, fixed_body FROM webhooks ORDER BY id`);
503
+ if (db.prepare(COUNT_WEBHOOKS_COPIED).pluck().get() !== 1)
504
+ throw new Error("webhooks migration lost a webhook");
505
+ db.exec("DROP TABLE webhooks");
506
+ db.exec("ALTER TABLE webhooks_autoincrement RENAME TO webhooks");
507
+ db.exec(WEBHOOKS_INDEXES);
508
+ }).immediate();
509
+ }
510
+ finally {
511
+ db.pragma(`foreign_keys = ${foreignKeys ? "ON" : "OFF"}`);
512
+ }
513
+ }
463
514
  function migrate(db, path) {
464
515
  addColumn(db, "key_hash", "TEXT");
465
516
  // A random token per registration, so a session can tell its agent from a later one that reuses the id or name.
@@ -522,6 +573,8 @@ function migrate(db, path) {
522
573
  addColumn(db, "window_count", "INTEGER NOT NULL DEFAULT 0", "webhooks");
523
574
  // The JSON body a webhook sends in place of the event body (`webhook add --body`); null, so the event body, for one without.
524
575
  addColumn(db, "fixed_body", "TEXT", "webhooks");
576
+ // Every webhooks column above is added before rebuildWebhooks() copies it.
577
+ rebuildWebhooks(db);
525
578
  addColumn(db, "delivery_uuid", "TEXT", "webhook_deliveries");
526
579
  // Set once a DELETE ends an HTTP session, so no server restores it or records it again.
527
580
  addColumn(db, "ended", "INTEGER NOT NULL DEFAULT 0", "http_sessions");
package/dist/init.js CHANGED
@@ -11,14 +11,15 @@ import { fileURLToPath } from "node:url";
11
11
  import { NAME_PATTERN, NOT_REGISTERED, PurgeError, purgeAgent } from "./agents.js";
12
12
  import { ConnectError, mcpEndpoint } from "./connect.js";
13
13
  import { dbPath, openDb } from "./db.js";
14
+ import { VERSION } from "./server.js";
14
15
  import { shellQuote } from "./wait.js";
15
16
  // `message-cp init`: adds the stdio server `npx -y message-cp` to one coding agent's user-level MCP configuration on
16
17
  // this machine, then proves the server works by registering a test agent, sending it a message and reading it back.
17
18
  // It never prints a token or key: the test agent's key is made here, stays in memory, and the agent is purged after.
18
19
  // With `--url` and `--agent` it adds a team server over HTTP instead, carrying the token from MESSAGE_CP_HTTP_TOKEN in
19
20
  // its headers, with the agent's name and its key from MESSAGE_CP_AGENT_KEY unless the token is bound to the agent, and
20
- // nothing it prints holds the token or key. With `--from` it reads the URL, agent, token and key from the file
21
- // `message-cp connect` wrote instead, and once the check passes also writes the token to ~/.message-cp/http-token for
21
+ // nothing it prints holds the token or key. With `--from` it reads the URL and token from the file `message-cp connect`
22
+ // wrote instead (and the agent's name and key, from a file an earlier `connect` wrote with them), and once the check passes also writes the token to ~/.message-cp/http-token for
22
23
  // the wake-up command.
23
24
  export class InitError extends Error {
24
25
  }
@@ -175,10 +176,12 @@ export const cliCommands = {
175
176
  const MAX_CONNECT_FILE = 64 * 1024;
176
177
  /**
177
178
  * The team server in the file `message-cp connect` wrote: the `mcpServers` JSON after its `claude mcp add` line, which
178
- * must name exactly one server with a `url` and the three headers `clientSetup` in `src/connect.ts` writes. The URL goes
179
- * through `mcpEndpoint`, the check `init --url` uses. Refuses a file it cannot read, one that is not a regular file or
180
- * is over 64 KiB, one whose mode is wider than 0600 (except on Windows, which has no such modes), and one without
181
- * exactly one usable URL, agent, token and key. No message holds the file's contents.
179
+ * must name exactly one server with a `url` and the `Authorization` header `clientSetup` in `src/connect.ts` writes. A
180
+ * file an earlier `connect` wrote also has the agent's name and key headers, and then both must be usable; without
181
+ * them the agent is left out, for `checkRemote` to find from the token. The URL goes through `mcpEndpoint`, the check
182
+ * `init --url` uses. Refuses a file it cannot read, one that is not a regular file or is over 64 KiB, one whose mode is
183
+ * wider than 0600 (except on Windows, which has no such modes), and one without exactly one usable URL and token. No
184
+ * message holds the file's contents.
182
185
  */
183
186
  export function readConnectFile(file, platform = process.platform) {
184
187
  const refuse = (why) => new InitError(`${file} ${why}. Nothing was changed.`);
@@ -232,9 +235,11 @@ export function readConnectFile(file, platform = process.platform) {
232
235
  const word = (v) => typeof v === "string" && /^[\x21-\x7e]+$/.test(v);
233
236
  if (!word(token))
234
237
  throw notConnect("has no token");
235
- if (typeof agent !== "string" || !NAME_PATTERN.test(agent) || agent.toLowerCase() === "all")
238
+ // A file a later `connect` wrote has neither: its token is bound to the agent.
239
+ const named = agent !== undefined || key !== undefined;
240
+ if (named && (typeof agent !== "string" || !NAME_PATTERN.test(agent) || agent.toLowerCase() === "all"))
236
241
  throw notConnect("has no usable agent name");
237
- if (!word(key))
242
+ if (named && !word(key))
238
243
  throw notConnect("has no agent key");
239
244
  let endpoint;
240
245
  try {
@@ -245,7 +250,7 @@ export function readConnectFile(file, platform = process.platform) {
245
250
  throw err;
246
251
  throw refuse(`has a server URL init refuses: ${err.message.replace(/^--url /, "it ")}`);
247
252
  }
248
- return { endpoint, agent, token, key, from: file };
253
+ return named ? { endpoint, agent: agent, token, key: key, from: file } : { endpoint, token, from: file };
249
254
  }
250
255
  /**
251
256
  * Writes `token` to `~/.message-cp/http-token` under `home`, where the wake-up command reads it, as a new 0600 file in a
@@ -422,7 +427,7 @@ export function remoteRefusal(client, m, endpoint) {
422
427
  return `Codex joins a team server through the server's OAuth flow (\`serve --public-url\`), which init does not run: run \`codex mcp add message-cp --url ${shellQuote(endpoint.href)}\`, then \`codex mcp login message-cp\` if add did not start the login, and have a person with a full or person token of the account approve it at the address Codex prints.`;
423
428
  }
424
429
  if (client === "claude-desktop") {
425
- return `Claude Desktop takes a team server as a custom connector in its app: in Customize, Connectors, choose Add, then Add custom connector, give it a name and the URL ${endpoint.href}, choose No sign in, and add the three request headers.`;
430
+ return `Claude Desktop takes a team server as a custom connector in its app: in Customize, Connectors, choose Add, then Add custom connector, give it a name and the URL ${endpoint.href}, choose No sign in, and add the header Authorization: Bearer <token> (for a token not bound to the agent, also X-Message-CP-Agent and X-Message-CP-Agent-Key).`;
426
431
  }
427
432
  if (client === "claude-code" && m.platform === "win32") {
428
433
  return "on Windows, message-cp init cannot run `claude mcp add` without passing the token through a shell; run the `claude mcp add` command `message-cp connect` printed instead, or use --client cursor.";
@@ -521,7 +526,7 @@ export async function proveServer(server, env = process.env) {
521
526
  if (v !== undefined && k !== "MESSAGE_CP_AGENT" && k !== "MESSAGE_CP_AGENT_KEY")
522
527
  childEnv[k] = v;
523
528
  childEnv.MESSAGE_CP_DB = db;
524
- const client = new Client({ name: "message-cp-init", version: "1.0.0" });
529
+ const client = new Client({ name: "message-cp-init", version: VERSION });
525
530
  let failure;
526
531
  let step = "starting the server";
527
532
  let stderr = "";
@@ -594,7 +599,7 @@ export async function proveServer(server, env = process.env) {
594
599
  }
595
600
  /** A client of the team server `r`, sending `headers` on every request. */
596
601
  function remoteClient(r, headers) {
597
- const client = new Client({ name: "message-cp-init", version: "1.0.0" });
602
+ const client = new Client({ name: "message-cp-init", version: VERSION });
598
603
  const transport = new StreamableHTTPClientTransport(r.endpoint, { requestInit: { headers } });
599
604
  return { client, transport };
600
605
  }
@@ -642,6 +647,10 @@ async function callTool(client, tool, args) {
642
647
  * is ended. Throws an InitError that says which was refused: the token (401, or 403 for a read-only one), the agent's
643
648
  * name (not on the server, or not the bound token's agent) or its key (403, or none for an unbound token), or that the
644
649
  * server could not be reached. No message holds the token or key.
650
+ *
651
+ * Without `r.agent`, as from the file a later `connect` wrote, `list_channels` (which marks nothing read) on that
652
+ * session fails as not registered for an unbound token, which is refused, since nothing names its agent; for a bound
653
+ * one, the refused `register` names the agent, and `checkRemote` sets `r.agent` to it.
645
654
  */
646
655
  export async function checkRemote(r) {
647
656
  const tokenIn = r.from === undefined ? "the token in MESSAGE_CP_HTTP_TOKEN" : `the token in ${r.from}`;
@@ -660,6 +669,23 @@ export async function checkRemote(r) {
660
669
  };
661
670
  const said = (err) => redact(err instanceof Error ? err.message : String(err), r);
662
671
  const plain = remoteClient(r, { Authorization: `Bearer ${r.token}` });
672
+ // The agent a bound token is: `register` with a random name is refused before it does anything, so nothing is
673
+ // registered, and its message names the token's agent.
674
+ const tokenAgent = async () => {
675
+ const name = `init-check-${randomBytes(10).toString("hex")}`;
676
+ let refused = null;
677
+ try {
678
+ await callTool(plain.client, "register", { name, bio: "message-cp init check", key: randomBytes(24).toString("hex") });
679
+ }
680
+ catch (err) {
681
+ refused = /This token is already agent '([^']*)'/.exec(err instanceof Error ? err.message : "");
682
+ if (!refused)
683
+ throw new InitError(`register: ${said(err).slice(0, 300)}`);
684
+ }
685
+ if (!refused)
686
+ throw new InitError(`register: the token could read as an agent but could also register '${name}'; remove it on the server with: message-cp agent remove ${name}`);
687
+ return refused[1];
688
+ };
663
689
  let bound = false;
664
690
  try {
665
691
  try {
@@ -668,6 +694,19 @@ export async function checkRemote(r) {
668
694
  catch (err) {
669
695
  throw refusal(err, false);
670
696
  }
697
+ if (r.agent === undefined) {
698
+ try {
699
+ await callTool(plain.client, "list_channels", { limit: 1 });
700
+ }
701
+ catch (err) {
702
+ const message = said(err);
703
+ if (message.includes(NOT_REGISTERED))
704
+ throw new InitError(`${tokenIn} is not bound to an agent, and the file names none; give the file message-cp connect wrote, unchanged`);
705
+ throw new InitError(`list_channels: ${message.slice(0, 300)}`);
706
+ }
707
+ r.agent = await tokenAgent();
708
+ return true;
709
+ }
671
710
  let agent;
672
711
  try {
673
712
  agent = await callTool(plain.client, "get_agent", { name: r.agent });
@@ -691,22 +730,9 @@ export async function checkRemote(r) {
691
730
  throw new InitError(`read_conversation: ${message.slice(0, 300)}`);
692
731
  }
693
732
  if (bound) {
694
- // Refused before it does anything, so nothing is registered: its message names the token's agent.
695
- const name = `init-check-${randomBytes(10).toString("hex")}`;
696
- let refused = null;
697
- try {
698
- await callTool(plain.client, "register", { name, bio: "message-cp init check", key: randomBytes(24).toString("hex") });
699
- }
700
- catch (err) {
701
- refused = /This token is already agent '([^']*)'/.exec(err instanceof Error ? err.message : "");
702
- if (!refused)
703
- throw new InitError(`register: ${said(err).slice(0, 300)}`);
704
- }
705
- if (!refused)
706
- throw new InitError(`register: the token could read as an agent but could also register '${name}'; remove it on the server with: message-cp agent remove ${name}`);
707
- if (refused[1].toLowerCase() !== r.agent.toLowerCase()) {
708
- throw new InitError(`${tokenIn} is bound to agent '${refused[1]}', not '${r.agent}'`);
709
- }
733
+ const actual = await tokenAgent();
734
+ if (actual.toLowerCase() !== r.agent.toLowerCase())
735
+ throw new InitError(`${tokenIn} is bound to agent '${actual}', not '${r.agent}'`);
710
736
  }
711
737
  }
712
738
  finally {
@@ -860,22 +886,27 @@ export async function runInit(o) {
860
886
  }
861
887
  client = found[n - 1];
862
888
  }
863
- const remote = o.remote === undefined ? undefined : { ...o.remote };
864
- if (remote !== undefined) {
865
- const refused = remoteRefusal(client, o.machine, remote.endpoint);
889
+ let remote;
890
+ if (o.remote !== undefined) {
891
+ const refused = remoteRefusal(client, o.machine, o.remote.endpoint);
866
892
  if (refused !== undefined) {
867
893
  o.err(`message-cp init: ${refused} Nothing was changed.`);
868
894
  return 1;
869
895
  }
896
+ const unchecked = { ...o.remote };
897
+ let bound;
870
898
  try {
871
- remote.bound = (await (o.check ?? checkRemote)(remote)) === true;
899
+ bound = (await (o.check ?? checkRemote)(unchecked)) === true;
900
+ if (unchecked.agent === undefined)
901
+ throw new InitError("the check did not find which agent the token is");
872
902
  }
873
903
  catch (err) {
874
904
  if (!(err instanceof InitError))
875
905
  throw err;
876
- o.err(`message-cp init: ${redact(err.message, remote)}. Nothing was changed.`);
906
+ o.err(`message-cp init: ${redact(err.message, unchecked)}. Nothing was changed.`);
877
907
  return 1;
878
908
  }
909
+ remote = { ...unchecked, agent: unchecked.agent, bound };
879
910
  o.out(remote.key === undefined
880
911
  ? `Server: ${remote.endpoint.href} accepted the token, which is agent '${remote.agent}'.`
881
912
  : remote.bound
@@ -898,7 +929,9 @@ export async function runInit(o) {
898
929
  ? `message-cp init: ${label} already has a server named message-cp that does not run \`npx -y message-cp\`; it was left as it is.`
899
930
  : `message-cp init: ${label} already has a server named message-cp that is not ${remote.endpoint.href} as agent '${remote.agent}' with this ${remote.key === undefined ? "token" : "token and key"}; it was left as it is.`);
900
931
  if (remote?.bound && remote.key === undefined) {
901
- o.err("If it is an earlier init's entry with the agent's name and key, set MESSAGE_CP_AGENT_KEY so init can check that key, or remove it so init writes the token alone.");
932
+ o.err(remote.from === undefined
933
+ ? "If it is an earlier init's entry with the agent's name and key, set MESSAGE_CP_AGENT_KEY so init can check that key, or remove it so init writes the token alone."
934
+ : "If it is an earlier init's entry, for this token with the agent's name and key or for another token, remove it so init writes this token alone.");
902
935
  }
903
936
  o.err(`To remove it, then run message-cp init again: ${setUp.undo}`);
904
937
  return 1;
@@ -906,7 +939,7 @@ export async function runInit(o) {
906
939
  o.out(setUp.state === "added" ? `${label}: added message-cp ${setUp.changed}.` : `${label}: already set up; nothing was changed.`);
907
940
  let proved;
908
941
  try {
909
- proved = await o.prove();
942
+ proved = await o.prove(remote);
910
943
  }
911
944
  catch (err) {
912
945
  o.err(`Check failed: ${redact(err instanceof Error ? err.message : String(err), remote)}`);
package/dist/messages.js CHANGED
@@ -18,6 +18,16 @@ export function validateBody(body, kind = "Message") {
18
18
  throw new ToolError(`${kind} is too long: at most ${MAX_BODY_LENGTH} characters.`);
19
19
  }
20
20
  }
21
+ /**
22
+ * Queues a stored message for every `agent:<recipient>` webhook of the account and, unless the recipient is a person,
23
+ * every `agent:*` one (stored with the name `*`), held for the recipient while it is waiting for mail (`markWaiting`),
24
+ * so `sendPending` leaves it to the waiting agent. Both are found through `webhooks_by_name`. Parameters: the message id,
25
+ * the time now, the recipient's id, the time now again, the account, the recipient's name and its `person` flag.
26
+ */
27
+ export const QUEUE_MESSAGE_SQL = `INSERT INTO webhook_deliveries (webhook_id, message_id, created_at, held_for)
28
+ SELECT id, ?, ?, (SELECT a.id FROM agents a WHERE a.id = ? AND a.waiting_until > ?)
29
+ FROM webhooks WHERE account_id = ? AND event = 'agent' AND name COLLATE NOCASE IN (?, CASE WHEN ? = 0 THEN '*' END)
30
+ ORDER BY id`;
21
31
  /**
22
32
  * Stores one delivery per recipient: the named agent, or for `all` every agent of the account registered right now
23
33
  * except the sender. Read state therefore belongs to each recipient. A message belongs to its recipient's account,
@@ -45,7 +55,7 @@ export function sendMessage(db, account, from, to, body, own, inReplyTo) {
45
55
  let recipients;
46
56
  if (broadcast) {
47
57
  recipients = db
48
- .prepare("SELECT id, name FROM agents WHERE account_id = ? AND name <> ? AND removed_at IS NULL ORDER BY name")
58
+ .prepare("SELECT id, name, person FROM agents WHERE account_id = ? AND name <> ? AND removed_at IS NULL ORDER BY name")
49
59
  .all(account, from);
50
60
  }
51
61
  else {
@@ -53,20 +63,16 @@ export function sendMessage(db, account, from, to, body, own, inReplyTo) {
53
63
  if (!agent)
54
64
  throw new ToolError(`No agent named '${to}'.`);
55
65
  recipients = db
56
- .prepare("SELECT id, name FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
66
+ .prepare("SELECT id, name, person FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
57
67
  .all(account, agent.name);
58
68
  }
59
69
  const insert = db.prepare("INSERT INTO messages (recipient_id, sender, body, broadcast, created_at, in_reply_to) VALUES (?, ?, ?, ?, ?, ?)");
60
- // Each recipient's row is owed to every `agent:<recipient>` webhook of the account, held for the recipient while
61
- // it is waiting for mail (`markWaiting`), so `sendPending` leaves it to the waiting agent.
62
- const queue = db.prepare(`INSERT INTO webhook_deliveries (webhook_id, message_id, created_at, held_for)
63
- SELECT id, ?, ?, (SELECT a.id FROM agents a WHERE a.id = ? AND a.waiting_until > ?)
64
- FROM webhooks WHERE account_id = ? AND event = 'agent' AND name = ? COLLATE NOCASE ORDER BY id`);
70
+ const queue = db.prepare(QUEUE_MESSAGE_SQL);
65
71
  const now = Date.now();
66
72
  const stored = [];
67
73
  for (const r of recipients) {
68
74
  const id = insert.run(r.id, from, body, broadcast ? 1 : 0, now, inReplyTo ?? null).lastInsertRowid;
69
- queue.run(id, now, r.id, now, account, r.name);
75
+ queue.run(id, now, r.id, now, account, r.name, r.person);
70
76
  stored.push({ id: Number(id), to: r.name });
71
77
  }
72
78
  // Sending clears the sender's status line, on its own registration only, so a send from a session whose agent was
package/dist/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { createRequire } from "node:module";
2
3
  import { z } from "zod";
3
4
  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
5
  import { OWNER_ACCOUNT } from "./db.js";
@@ -123,13 +124,15 @@ async function poll(deadline, signal, answer) {
123
124
  return found;
124
125
  }
125
126
  }
127
+ /** The version in package.json, one directory up from this file whether it runs from src/ (tsx) or dist/ (a build or the npm package). */
128
+ export const VERSION = createRequire(import.meta.url)("../package.json").version;
126
129
  /** One server per session. */
127
130
  export function createServer(db, session = new Session(OWNER_ACCOUNT), options = {}) {
128
131
  const remote = options.remote ?? false;
129
132
  const hold = options.hold ?? holdInProcess;
130
133
  session.attach(db);
131
134
  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) });
135
+ const server = new McpServer({ name: "message-cp", version: VERSION }, { instructions: instructions(process.env, remote, options.publicUrl, options.agent, options.approval, session.account) });
133
136
  // Aborts when the call is cancelled or, where the transport knows it, the response that would carry its answer closes.
134
137
  const ended = (extra) => {
135
138
  const closed = options.closed?.(extra.requestId);
package/dist/webhooks.js CHANGED
@@ -11,7 +11,9 @@ import { MAX_INBOX_LIMIT } from "./messages.js";
11
11
  // of its deliveries; its value is stored as is too, and never printed, logged or returned. And it may carry a limit on
12
12
  // how many delivery requests it gets per window of 60 minutes; deliveries past it wait in the queue for a later window.
13
13
  // And it may carry a fixed JSON body the owner sets, sent in place of the event body, signed the same way, with its one
14
- // `{{delivery}}` replaced by an id of that delivery that every retry repeats; the body is never printed or logged.
14
+ // `{{delivery}}` replaced by an id of that delivery that every retry repeats, and, for direct messages, each `{{agent}}`
15
+ // replaced by the name of the agent the message went to; the body is never printed or logged. `agent:*` is for direct
16
+ // messages to every agent of the account, stored with the name `*`, which no agent can have.
15
17
  export class WebhookError extends Error {
16
18
  }
17
19
  /**
@@ -38,14 +40,18 @@ export function webhookUrl(raw) {
38
40
  url.hash = "";
39
41
  return url.href;
40
42
  }
43
+ /** The name an `agent:*` webhook is stored with: it fires for a direct message to any agent of its account. */
44
+ export const EVERY_AGENT = "*";
41
45
  /**
42
- * Parses `--on`: `#<channel>`, `mention:<agent>` or `agent:<agent>`, each name following the agent-name rules. An agent
43
- * cannot be named `all`, so `mention:all` and `agent:all` are refused; `#all` is an ordinary channel.
46
+ * Parses `--on`: `#<channel>`, `mention:<agent>`, `agent:<agent>` or `agent:*`, each name following the agent-name
47
+ * rules. An agent cannot be named `all`, so `mention:all` and `agent:all` are refused; `#all` is an ordinary channel.
44
48
  */
45
49
  export function webhookOn(on) {
50
+ if (on === `agent:${EVERY_AGENT}`)
51
+ return { event: "agent", name: EVERY_AGENT };
46
52
  const [, prefix, name] = /^(#|mention:|agent:)(.*)$/s.exec(on) ?? [];
47
53
  if (prefix === undefined || !NAME_PATTERN.test(name)) {
48
- throw new WebhookError("Invalid --on: use #<channel>, mention:<agent> or agent:<agent>, names of 1-32 letters, digits, '-' and '_'.");
54
+ throw new WebhookError("Invalid --on: use #<channel>, mention:<agent>, agent:<agent> or agent:*, names of 1-32 letters, digits, '-' and '_'.");
49
55
  }
50
56
  if (prefix !== "#" && name.toLowerCase() === "all") {
51
57
  throw new WebhookError("Invalid --on: 'all' is reserved and names no agent.");
@@ -106,17 +112,21 @@ export function webhookMaxPerHour(raw) {
106
112
  }
107
113
  /** What a fixed body's placeholder is replaced with: the delivery's id, the same on every attempt of it. */
108
114
  export const WEBHOOK_PLACEHOLDER = "{{delivery}}";
115
+ /** What a fixed body's optional agent placeholder is replaced with, each time: the name of the message's recipient. */
116
+ export const WEBHOOK_AGENT_PLACEHOLDER = "{{agent}}";
109
117
  /** The longest `--body`, in UTF-8 bytes. */
110
118
  export const WEBHOOK_MAX_BODY_BYTES = 4096;
111
- /** How many times `text` contains the placeholder. */
112
- const placeholders = (text) => text.split(WEBHOOK_PLACEHOLDER).length - 1;
119
+ /** How many times `text` contains `placeholder`. */
120
+ const count = (text, placeholder) => text.split(placeholder).length - 1;
113
121
  /**
114
122
  * Checks `--body`: a JSON object of at most `WEBHOOK_MAX_BODY_BYTES`, with `WEBHOOK_PLACEHOLDER` written as is exactly
115
123
  * once in the text, inside a string value that parsing keeps (not in a key, and not dropped by a later duplicate key),
116
- * and nowhere else in any parsed string or key, `\u` escapes included. Returns it unchanged, so a delivery sends the
117
- * owner's own bytes with only the placeholder replaced. Errors never repeat the body.
124
+ * and nowhere else in any parsed string or key, `\u` escapes included. With `agent`, for an `agent:` webhook,
125
+ * `WEBHOOK_AGENT_PLACEHOLDER` may also be written as is, any number of times, each inside a string value parsing keeps;
126
+ * without it, it may not appear at all. Returns it unchanged, so a delivery sends the owner's own bytes with only the
127
+ * placeholders replaced. Errors never repeat the body.
118
128
  */
119
- export function webhookBody(raw) {
129
+ export function webhookBody(raw, agent = false) {
120
130
  if (Buffer.byteLength(raw, "utf8") > WEBHOOK_MAX_BODY_BYTES) {
121
131
  throw new WebhookError(`Invalid --body: it is over ${WEBHOOK_MAX_BODY_BYTES} bytes.`);
122
132
  }
@@ -131,32 +141,57 @@ export function webhookBody(raw) {
131
141
  throw new WebhookError("Invalid --body: give a JSON object.");
132
142
  }
133
143
  const misplaced = new WebhookError(`Invalid --body: put ${WEBHOOK_PLACEHOLDER} exactly once, inside a JSON string value.`);
134
- if (placeholders(raw) !== 1)
144
+ if (count(raw, WEBHOOK_PLACEHOLDER) !== 1)
135
145
  throw misplaced;
136
- // Parsed again with the literal placeholder swapped for a value found nowhere else, as a delivery swaps it, so the
137
- // count is of what a receiver parses: one sentinel in a string value, and no placeholder left in any string or key.
146
+ const agents = count(raw, WEBHOOK_AGENT_PLACEHOLDER);
147
+ const agentMisplaced = agent
148
+ ? new WebhookError(`Invalid --body: put each ${WEBHOOK_AGENT_PLACEHOLDER} inside a JSON string value.`)
149
+ : new WebhookError(`Invalid --body: ${WEBHOOK_AGENT_PLACEHOLDER} is only for --on agent:<agent> or agent:*.`);
150
+ if (agents > 0 && !agent)
151
+ throw agentMisplaced;
152
+ // Parsed again with each literal placeholder swapped for a value found nowhere else, as a delivery swaps it, so the
153
+ // count is of what a receiver parses: one sentinel in a string value, each agent sentinel in a string value, and no
154
+ // placeholder left in any string or key.
138
155
  const sentinel = randomUUID();
156
+ const agentSentinel = randomUUID();
139
157
  let sentinels = 0;
158
+ let agentSentinels = 0;
140
159
  let others = 0;
160
+ let agentOthers = 0;
141
161
  const walk = (v) => {
142
162
  if (typeof v === "string") {
143
- sentinels += v.split(sentinel).length - 1;
144
- others += placeholders(v);
163
+ sentinels += count(v, sentinel);
164
+ agentSentinels += count(v, agentSentinel);
165
+ others += count(v, WEBHOOK_PLACEHOLDER);
166
+ agentOthers += count(v, WEBHOOK_AGENT_PLACEHOLDER);
145
167
  }
146
168
  else if (Array.isArray(v))
147
169
  v.forEach(walk);
148
170
  else if (typeof v === "object" && v !== null) {
149
171
  for (const [k, x] of Object.entries(v)) {
150
- others += placeholders(k) + k.split(sentinel).length - 1;
172
+ others += count(k, WEBHOOK_PLACEHOLDER) + count(k, sentinel);
173
+ agentOthers += count(k, WEBHOOK_AGENT_PLACEHOLDER) + count(k, agentSentinel);
151
174
  walk(x);
152
175
  }
153
176
  }
154
177
  };
155
- walk(JSON.parse(raw.replace(WEBHOOK_PLACEHOLDER, () => sentinel)));
178
+ walk(JSON.parse(raw.replace(WEBHOOK_PLACEHOLDER, () => sentinel).replaceAll(WEBHOOK_AGENT_PLACEHOLDER, () => agentSentinel)));
156
179
  if (sentinels !== 1 || others !== 0)
157
180
  throw misplaced;
181
+ if (agentOthers !== 0 || agentSentinels !== agents)
182
+ throw agentMisplaced;
158
183
  return raw;
159
184
  }
185
+ /** True when `webhookBody` would take `raw` for an `agent:` webhook, so each `{{agent}}` in it is in a string value. */
186
+ function agentPlaceable(raw) {
187
+ try {
188
+ webhookBody(raw, true);
189
+ return true;
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ }
160
195
  /** How `--on` names a webhook's event, for printing. */
161
196
  export const formatOn = (w) => (w.event === "channel" ? `#${w.name}` : `${w.event}:${w.name}`);
162
197
  /**
@@ -173,7 +208,7 @@ export const addWebhook = (db, account, url, on, includeText, header, maxPerHour
173
208
  // A fixed body never carries the text, so --no-text with it would mean nothing.
174
209
  if (body !== undefined && !includeText)
175
210
  throw new WebhookError("--body and --no-text cannot go together: a fixed body never carries the text.");
176
- const fixed = body === undefined ? null : webhookBody(body);
211
+ const fixed = body === undefined ? null : webhookBody(body, event === "agent");
177
212
  const row = db.prepare("SELECT id, name FROM accounts WHERE name = ?").get(account);
178
213
  if (!row)
179
214
  throw new WebhookError(`No account named '${account}'.`);
@@ -419,6 +454,13 @@ export async function sendPending(db, options = {}) {
419
454
  return;
420
455
  // `webhookBody` checked that the placeholder is there once, inside a string, and a UUID needs no escaping.
421
456
  text = row.fixed_body.replace(WEBHOOK_PLACEHOLDER, () => id.delivery_uuid);
457
+ // Each agent placeholder of an `agent:` webhook's body is inside a string too, and gets the name escaped as one.
458
+ // Only there, and only when the stored body passes today's check: a body stored before `{{agent}}` existed, for a
459
+ // `#channel` or `mention:` webhook or with the text in a key, keeps any such text as it is.
460
+ if (body.event === "agent" && agentPlaceable(row.fixed_body)) {
461
+ const agent = JSON.stringify(body.recipient).slice(1, -1);
462
+ text = text.replaceAll(WEBHOOK_AGENT_PLACEHOLDER, () => agent);
463
+ }
422
464
  }
423
465
  let ok = false;
424
466
  try {
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "message-cp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A messaging service for AI agents: register a name, find each other, send messages and be woken when mail arrives",
5
5
  "license": "MIT",
6
+ "mcpName": "io.github.colinhallett/message-cp",
6
7
  "main": "dist/index.js",
7
8
  "files": [
8
9
  "dist"