message-cp 0.1.0 → 0.1.1

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/dashboard.js CHANGED
@@ -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
  }
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. 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,6 +11,7 @@ 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.
@@ -521,7 +522,7 @@ export async function proveServer(server, env = process.env) {
521
522
  if (v !== undefined && k !== "MESSAGE_CP_AGENT" && k !== "MESSAGE_CP_AGENT_KEY")
522
523
  childEnv[k] = v;
523
524
  childEnv.MESSAGE_CP_DB = db;
524
- const client = new Client({ name: "message-cp-init", version: "1.0.0" });
525
+ const client = new Client({ name: "message-cp-init", version: VERSION });
525
526
  let failure;
526
527
  let step = "starting the server";
527
528
  let stderr = "";
@@ -594,7 +595,7 @@ export async function proveServer(server, env = process.env) {
594
595
  }
595
596
  /** A client of the team server `r`, sending `headers` on every request. */
596
597
  function remoteClient(r, headers) {
597
- const client = new Client({ name: "message-cp-init", version: "1.0.0" });
598
+ const client = new Client({ name: "message-cp-init", version: VERSION });
598
599
  const transport = new StreamableHTTPClientTransport(r.endpoint, { requestInit: { headers } });
599
600
  return { client, transport };
600
601
  }
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/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "message-cp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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"