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 +9 -8
- package/dist/cli.js +6 -5
- package/dist/connect.js +7 -7
- package/dist/dashboard.js +38 -22
- package/dist/db.js +72 -19
- package/dist/init.js +68 -35
- package/dist/messages.js +14 -8
- package/dist/server.js +4 -1
- package/dist/webhooks.js +59 -17
- package/package.json +2 -1
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
|
|
11
|
+
Needs Node 22.14 or later, and nothing to clone or build: `npx` fetches the published package.
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
356
|
-
//
|
|
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
|
|
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: () => (
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
1718
|
-
if
|
|
1719
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2016
|
-
"No secret can be shown for it, so if it is
|
|
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
|
|
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
|
|
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">#<channel>, mention:<agent>
|
|
4857
|
+
<span id="hook-on-hint" class="note">#<channel>, mention:<agent>, agent:<agent> 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
|
-
|
|
170
|
-
|
|
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
|
|
21
|
-
// `
|
|
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
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
112
|
-
const
|
|
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.
|
|
117
|
-
*
|
|
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 (
|
|
144
|
+
if (count(raw, WEBHOOK_PLACEHOLDER) !== 1)
|
|
135
145
|
throw misplaced;
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
144
|
-
|
|
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 +=
|
|
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.
|
|
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"
|