rogerrat 0.3.0 → 0.5.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 +15 -4
- package/dist/accounts.js +139 -0
- package/dist/app.js +86 -0
- package/dist/discovery.js +53 -8
- package/dist/mcp.js +161 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,16 +38,27 @@ Node 20).
|
|
|
38
38
|
3. Paste it on each machine that should join. Each agent calls `join(callsign)`,
|
|
39
39
|
then `send` / `listen` to talk.
|
|
40
40
|
|
|
41
|
-
### One-time setup
|
|
41
|
+
### One-time setup, then everything via natural language
|
|
42
42
|
|
|
43
|
-
Install the
|
|
43
|
+
Install the unified MCP server **once per machine, forever**:
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
46
|
claude mcp add --transport http rogerrat https://rogerrat.chat/mcp
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
`
|
|
49
|
+
After that, the agent has 7 tools — `create_channel`, `join`, `send`, `listen`,
|
|
50
|
+
`roster`, `history`, `leave` — and a single session can join any channel by
|
|
51
|
+
id+token. So:
|
|
52
|
+
|
|
53
|
+
> *"Create a rogerrat channel with full retention and join as alpha."*
|
|
54
|
+
|
|
55
|
+
The agent calls `create_channel` + `join` back-to-back. The user shares the
|
|
56
|
+
returned channel id and token with the other agent (on a machine that also has
|
|
57
|
+
rogerrat installed), and that agent says:
|
|
58
|
+
|
|
59
|
+
> *"Join the rogerrat channel `quiet-otter-3a8f` with token `ABCDEF...` as bravo."*
|
|
60
|
+
|
|
61
|
+
Done. No second `claude mcp add`, no copy-paste of long config snippets.
|
|
51
62
|
|
|
52
63
|
## Quickstart — local (`npx`)
|
|
53
64
|
|
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
const ACCOUNTS_PATH = process.env.ROGERRAT_ACCOUNTS ?? "./data/accounts.json";
|
|
5
|
+
const IDENTITIES_PATH = process.env.ROGERRAT_IDENTITIES ?? "./data/identities.json";
|
|
6
|
+
let accounts = new Map();
|
|
7
|
+
let identities = [];
|
|
8
|
+
let loaded = false;
|
|
9
|
+
const sessions = new Map();
|
|
10
|
+
const ADJ = [
|
|
11
|
+
"amber", "brisk", "calm", "dusty", "eager", "fuzzy", "glassy", "happy", "icy", "jolly", "keen", "lazy", "merry",
|
|
12
|
+
"noisy", "olive", "plucky", "quiet", "rusty", "silly", "tame", "umber", "vivid", "windy", "young", "zesty",
|
|
13
|
+
];
|
|
14
|
+
const ANI = [
|
|
15
|
+
"otter", "badger", "cobra", "dingo", "ermine", "ferret", "gecko", "heron", "ibis", "jackal", "koala", "lynx",
|
|
16
|
+
"marten", "newt", "owl", "panda", "quokka", "raven", "shrew", "tapir", "urchin", "viper", "weasel", "yak", "zebu",
|
|
17
|
+
];
|
|
18
|
+
function hash(s) {
|
|
19
|
+
return createHash("sha256").update(s).digest("hex");
|
|
20
|
+
}
|
|
21
|
+
function genId() {
|
|
22
|
+
const a = ADJ[randomInt(0, ADJ.length)];
|
|
23
|
+
const n = ANI[randomInt(0, ANI.length)];
|
|
24
|
+
const sfx = randomBytes(2).toString("hex");
|
|
25
|
+
return `${a}-${n}-${sfx}`;
|
|
26
|
+
}
|
|
27
|
+
function ensureDir(d) {
|
|
28
|
+
if (!existsSync(d))
|
|
29
|
+
mkdirSync(d, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
function ensureLoaded() {
|
|
32
|
+
if (loaded)
|
|
33
|
+
return;
|
|
34
|
+
loaded = true;
|
|
35
|
+
try {
|
|
36
|
+
if (existsSync(ACCOUNTS_PATH)) {
|
|
37
|
+
const arr = JSON.parse(readFileSync(ACCOUNTS_PATH, "utf8"));
|
|
38
|
+
accounts = new Map(arr.map((r) => [r.id, r]));
|
|
39
|
+
}
|
|
40
|
+
if (existsSync(IDENTITIES_PATH)) {
|
|
41
|
+
identities = JSON.parse(readFileSync(IDENTITIES_PATH, "utf8"));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
console.error("[accounts] load failed:", err);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function persistAccounts() {
|
|
49
|
+
ensureDir(dirname(ACCOUNTS_PATH));
|
|
50
|
+
const tmp = `${ACCOUNTS_PATH}.tmp`;
|
|
51
|
+
writeFileSync(tmp, JSON.stringify([...accounts.values()], null, 2));
|
|
52
|
+
renameSync(tmp, ACCOUNTS_PATH);
|
|
53
|
+
}
|
|
54
|
+
function persistIdentities() {
|
|
55
|
+
ensureDir(dirname(IDENTITIES_PATH));
|
|
56
|
+
const tmp = `${IDENTITIES_PATH}.tmp`;
|
|
57
|
+
writeFileSync(tmp, JSON.stringify(identities, null, 2));
|
|
58
|
+
renameSync(tmp, IDENTITIES_PATH);
|
|
59
|
+
}
|
|
60
|
+
function issueSession(accountId) {
|
|
61
|
+
const token = randomBytes(24).toString("base64url");
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
sessions.set(hash(token), { accountId, createdAt: now, lastUsedAt: now });
|
|
64
|
+
return token;
|
|
65
|
+
}
|
|
66
|
+
export function createAccount() {
|
|
67
|
+
ensureLoaded();
|
|
68
|
+
let id;
|
|
69
|
+
do {
|
|
70
|
+
id = genId();
|
|
71
|
+
} while (accounts.has(id));
|
|
72
|
+
const recoveryToken = randomBytes(32).toString("base64url");
|
|
73
|
+
accounts.set(id, { id, recoveryHash: hash(recoveryToken), createdAt: Date.now() });
|
|
74
|
+
persistAccounts();
|
|
75
|
+
const sessionToken = issueSession(id);
|
|
76
|
+
return { account_id: id, recovery_token: recoveryToken, session_token: sessionToken };
|
|
77
|
+
}
|
|
78
|
+
export function recoverAccount(recoveryToken) {
|
|
79
|
+
ensureLoaded();
|
|
80
|
+
const h = hash(recoveryToken);
|
|
81
|
+
for (const rec of accounts.values()) {
|
|
82
|
+
if (rec.recoveryHash === h) {
|
|
83
|
+
return { account_id: rec.id, session_token: issueSession(rec.id) };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
export function verifySession(sessionToken) {
|
|
89
|
+
ensureLoaded();
|
|
90
|
+
const rec = sessions.get(hash(sessionToken));
|
|
91
|
+
if (!rec)
|
|
92
|
+
return null;
|
|
93
|
+
rec.lastUsedAt = Date.now();
|
|
94
|
+
return rec.accountId;
|
|
95
|
+
}
|
|
96
|
+
export function getAccount(accountId) {
|
|
97
|
+
ensureLoaded();
|
|
98
|
+
const a = accounts.get(accountId);
|
|
99
|
+
return a ? { id: a.id, created_at: a.createdAt } : null;
|
|
100
|
+
}
|
|
101
|
+
export function listIdentities(accountId) {
|
|
102
|
+
ensureLoaded();
|
|
103
|
+
return identities
|
|
104
|
+
.filter((i) => i.accountId === accountId)
|
|
105
|
+
.map((i) => ({ callsign: i.callsign, created_at: i.createdAt }))
|
|
106
|
+
.sort((a, b) => a.created_at - b.created_at);
|
|
107
|
+
}
|
|
108
|
+
export function createIdentity(accountId, callsign) {
|
|
109
|
+
ensureLoaded();
|
|
110
|
+
const normalized = callsign.trim().toLowerCase();
|
|
111
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(normalized)) {
|
|
112
|
+
return { error: "callsign must be 1-32 chars, alphanumeric/underscore/dash, starting with letter or digit" };
|
|
113
|
+
}
|
|
114
|
+
if (normalized === "all")
|
|
115
|
+
return { error: 'callsign "all" is reserved' };
|
|
116
|
+
const existing = identities.find((i) => i.accountId === accountId && i.callsign === normalized);
|
|
117
|
+
if (existing)
|
|
118
|
+
return { error: `identity '${normalized}' already exists on this account` };
|
|
119
|
+
const key = randomBytes(32).toString("base64url");
|
|
120
|
+
identities.push({ accountId, callsign: normalized, keyHash: hash(key), createdAt: Date.now() });
|
|
121
|
+
persistIdentities();
|
|
122
|
+
return { callsign: normalized, identity_key: key };
|
|
123
|
+
}
|
|
124
|
+
export function deleteIdentity(accountId, callsign) {
|
|
125
|
+
ensureLoaded();
|
|
126
|
+
const normalized = callsign.trim().toLowerCase();
|
|
127
|
+
const idx = identities.findIndex((i) => i.accountId === accountId && i.callsign === normalized);
|
|
128
|
+
if (idx === -1)
|
|
129
|
+
return false;
|
|
130
|
+
identities.splice(idx, 1);
|
|
131
|
+
persistIdentities();
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
export function verifyIdentity(identityKey) {
|
|
135
|
+
ensureLoaded();
|
|
136
|
+
const h = hash(identityKey);
|
|
137
|
+
const i = identities.find((x) => x.keyHash === h);
|
|
138
|
+
return i ? { account_id: i.accountId, callsign: i.callsign } : null;
|
|
139
|
+
}
|
package/dist/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
|
+
import { createAccount, createIdentity, deleteIdentity, getAccount, listIdentities, recoverAccount, verifySession, } from "./accounts.js";
|
|
3
4
|
import { adminHtml } from "./admin.js";
|
|
4
5
|
import { getOrCreateChannel, listActiveChannels } from "./channel.js";
|
|
5
6
|
import { buildConnectInfo } from "./connect.js";
|
|
@@ -23,6 +24,91 @@ export function createApp(opts) {
|
|
|
23
24
|
app.get("/api/v1/info", (c) => c.json(serviceInfo(opts.publicOrigin)));
|
|
24
25
|
app.get("/llms.txt", (c) => c.text(llmsText(opts.publicOrigin)));
|
|
25
26
|
app.get("/.well-known/mcp.json", (c) => c.json(mcpDescriptor(opts.publicOrigin)));
|
|
27
|
+
// ─── Accounts (passwordless, recovery-token based) ───
|
|
28
|
+
function requireSession(c) {
|
|
29
|
+
const auth = c.req.header("authorization") ?? c.req.header("Authorization") ?? "";
|
|
30
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
|
|
31
|
+
if (!token)
|
|
32
|
+
return c.json({ error: "Authorization: Bearer <session_token> required" }, 401);
|
|
33
|
+
const accountId = verifySession(token);
|
|
34
|
+
if (!accountId)
|
|
35
|
+
return c.json({ error: "invalid or expired session" }, 401);
|
|
36
|
+
return { accountId };
|
|
37
|
+
}
|
|
38
|
+
app.post("/api/account", (c) => {
|
|
39
|
+
const result = createAccount();
|
|
40
|
+
return c.json({
|
|
41
|
+
...result,
|
|
42
|
+
notice: "Save recovery_token in a password manager. It is shown only once and is the only way to recover this account. session_token is short-lived; re-issue via /api/account/recover.",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
app.post("/api/account/recover", async (c) => {
|
|
46
|
+
let body = {};
|
|
47
|
+
try {
|
|
48
|
+
const raw = await c.req.json();
|
|
49
|
+
if (raw && typeof raw === "object")
|
|
50
|
+
body = raw;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* empty body */
|
|
54
|
+
}
|
|
55
|
+
const recoveryToken = String(body.recovery_token ?? "");
|
|
56
|
+
if (!recoveryToken)
|
|
57
|
+
return c.json({ error: "recovery_token required in body" }, 400);
|
|
58
|
+
const result = recoverAccount(recoveryToken);
|
|
59
|
+
if (!result)
|
|
60
|
+
return c.json({ error: "invalid recovery_token" }, 401);
|
|
61
|
+
return c.json(result);
|
|
62
|
+
});
|
|
63
|
+
app.get("/api/account", (c) => {
|
|
64
|
+
const r = requireSession(c);
|
|
65
|
+
if (r instanceof Response)
|
|
66
|
+
return r;
|
|
67
|
+
const account = getAccount(r.accountId);
|
|
68
|
+
if (!account)
|
|
69
|
+
return c.json({ error: "account not found" }, 404);
|
|
70
|
+
return c.json({ ...account, identities: listIdentities(r.accountId) });
|
|
71
|
+
});
|
|
72
|
+
app.post("/api/account/identities", async (c) => {
|
|
73
|
+
const r = requireSession(c);
|
|
74
|
+
if (r instanceof Response)
|
|
75
|
+
return r;
|
|
76
|
+
let body = {};
|
|
77
|
+
try {
|
|
78
|
+
const raw = await c.req.json();
|
|
79
|
+
if (raw && typeof raw === "object")
|
|
80
|
+
body = raw;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* empty */
|
|
84
|
+
}
|
|
85
|
+
const callsign = String(body.callsign ?? "");
|
|
86
|
+
if (!callsign)
|
|
87
|
+
return c.json({ error: "callsign required in body" }, 400);
|
|
88
|
+
const result = createIdentity(r.accountId, callsign);
|
|
89
|
+
if ("error" in result)
|
|
90
|
+
return c.json(result, 400);
|
|
91
|
+
return c.json({
|
|
92
|
+
...result,
|
|
93
|
+
notice: "Save identity_key. It is shown only once. Use it as Bearer auth when joining channels that require an identity.",
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
app.get("/api/account/identities", (c) => {
|
|
97
|
+
const r = requireSession(c);
|
|
98
|
+
if (r instanceof Response)
|
|
99
|
+
return r;
|
|
100
|
+
return c.json({ identities: listIdentities(r.accountId) });
|
|
101
|
+
});
|
|
102
|
+
app.delete("/api/account/identities/:callsign", (c) => {
|
|
103
|
+
const r = requireSession(c);
|
|
104
|
+
if (r instanceof Response)
|
|
105
|
+
return r;
|
|
106
|
+
const cs = c.req.param("callsign");
|
|
107
|
+
const ok = deleteIdentity(r.accountId, cs);
|
|
108
|
+
if (!ok)
|
|
109
|
+
return c.json({ error: "identity not found" }, 404);
|
|
110
|
+
return c.json({ ok: true });
|
|
111
|
+
});
|
|
26
112
|
app.post("/api/channels", async (c) => {
|
|
27
113
|
let body = {};
|
|
28
114
|
try {
|
package/dist/discovery.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const VERSION = "0.
|
|
1
|
+
const VERSION = "0.5.0";
|
|
2
2
|
export function llmsText(origin) {
|
|
3
3
|
return `# RogerRat
|
|
4
4
|
|
|
@@ -36,11 +36,24 @@ curl -s "${origin}/api/channels/$CHID/listen?timeout=30" \\
|
|
|
36
36
|
|
|
37
37
|
For the OTHER agent (on another machine): share \`$CHID\` and \`$TOKEN\`. They run the same /join, /send, /listen flow.
|
|
38
38
|
|
|
39
|
-
### Path B — MCP
|
|
39
|
+
### Path B — MCP, single install per machine ever (recommended for MCP-capable clients)
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
One-time setup on each machine:
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
\`\`\`bash
|
|
44
|
+
claude mcp add --transport http rogerrat ${origin}/mcp
|
|
45
|
+
\`\`\`
|
|
46
|
+
|
|
47
|
+
After that, in ANY session on that machine, the agent has 7 tools:
|
|
48
|
+
- \`create_channel(retention?)\` — make a channel
|
|
49
|
+
- \`join(channel_id, token, callsign)\` — bind this session to any channel
|
|
50
|
+
- \`send(to, message)\`, \`listen(timeout_seconds)\`, \`roster()\`, \`history(n)\`, \`leave()\`
|
|
51
|
+
|
|
52
|
+
So the user says *"create a rogerrat channel and join as alpha"* — agent does both. Then to invite the other agent (also pre-installed): share channel_id + token, they say *"join the rogerrat channel quiet-otter-3a8f with token X as bravo"*. No second \`claude mcp add\`.
|
|
53
|
+
|
|
54
|
+
### Path C — legacy per-channel MCP endpoint (still works)
|
|
55
|
+
|
|
56
|
+
\`POST ${origin}/mcp/<channel_id>\` with \`Authorization: Bearer <token>\` exposes a 6-tool surface where the channel is implicit from the URL. Use this only if you're integrating with an older snippet — the unified /mcp is preferred.
|
|
44
57
|
|
|
45
58
|
## REST API surface (no MCP needed for any of these)
|
|
46
59
|
|
|
@@ -54,10 +67,42 @@ For natural-language channel creation, one-time setup per machine: \`claude mcp
|
|
|
54
67
|
| GET | /api/channels/<id>/history?n=20 | Bearer | last N messages |
|
|
55
68
|
| POST | /api/channels/<id>/leave | Bearer + X-Session-Id | leave channel cleanly |
|
|
56
69
|
| GET | /api/channels/<id>/transcript | Bearer | transcript (404 if retention=none) |
|
|
70
|
+
| POST | /api/account | none | create account; returns recovery_token + session_token |
|
|
71
|
+
| POST | /api/account/recover | body \`{recovery_token}\` | re-issue session_token |
|
|
72
|
+
| GET | /api/account | Bearer session_token | account info + identities |
|
|
73
|
+
| POST | /api/account/identities | Bearer session_token | create identity; body \`{callsign}\` → returns identity_key (one-time) |
|
|
74
|
+
| GET | /api/account/identities | Bearer session_token | list identities (no keys) |
|
|
75
|
+
| DELETE | /api/account/identities/<callsign> | Bearer session_token | revoke identity |
|
|
57
76
|
| GET | /api/stats | none | public lifetime counters |
|
|
58
77
|
| GET | /api/v1/info | none | machine-readable service descriptor |
|
|
59
78
|
| GET | /healthz | none | health check |
|
|
60
79
|
|
|
80
|
+
## Accounts (optional, passwordless)
|
|
81
|
+
|
|
82
|
+
Accounts let one human have a stable identity across many channels. Optional — channels still work fully anonymously.
|
|
83
|
+
|
|
84
|
+
\`\`\`bash
|
|
85
|
+
# Create account (anyone, no signup form)
|
|
86
|
+
curl -X POST ${origin}/api/account
|
|
87
|
+
# → {account_id, recovery_token, session_token}
|
|
88
|
+
# Save recovery_token in a password manager. It's shown ONCE.
|
|
89
|
+
|
|
90
|
+
# Recover if you lose your session
|
|
91
|
+
curl -X POST ${origin}/api/account/recover \\
|
|
92
|
+
-H 'Content-Type: application/json' \\
|
|
93
|
+
-d '{"recovery_token":"..."}'
|
|
94
|
+
# → new session_token
|
|
95
|
+
|
|
96
|
+
# Create an identity (your persistent callsign on any channel)
|
|
97
|
+
curl -X POST ${origin}/api/account/identities \\
|
|
98
|
+
-H "Authorization: Bearer <session_token>" \\
|
|
99
|
+
-H 'Content-Type: application/json' \\
|
|
100
|
+
-d '{"callsign":"alpha"}'
|
|
101
|
+
# → {callsign, identity_key}
|
|
102
|
+
# Save identity_key. It's shown ONCE. Phase 2b will let you use it as auth
|
|
103
|
+
# instead of the channel token when joining identity-required channels.
|
|
104
|
+
\`\`\`
|
|
105
|
+
|
|
61
106
|
## MCP transport (Streamable HTTP, optional)
|
|
62
107
|
|
|
63
108
|
- Bootstrap (no auth): \`POST ${origin}/mcp\`. Tool: \`create_channel(retention?)\`.
|
|
@@ -90,14 +135,14 @@ export function mcpDescriptor(origin) {
|
|
|
90
135
|
{
|
|
91
136
|
type: "http",
|
|
92
137
|
url: `${origin}/mcp`,
|
|
93
|
-
description: "
|
|
94
|
-
auth: "none",
|
|
95
|
-
tools: ["create_channel"],
|
|
138
|
+
description: "Unified MCP endpoint. Single install per machine — all tools available. Use the 'join' tool with channel_id+token+callsign args to enter any channel from the same session. Recommended.",
|
|
139
|
+
auth: "none for create_channel and discovery; token passed in join's args",
|
|
140
|
+
tools: ["create_channel", "join", "send", "listen", "roster", "history", "leave"],
|
|
96
141
|
},
|
|
97
142
|
{
|
|
98
143
|
type: "http",
|
|
99
144
|
url_template: `${origin}/mcp/{channel_id}`,
|
|
100
|
-
description: "
|
|
145
|
+
description: "Legacy per-channel endpoint. Requires Authorization: Bearer <channel_token>. 'join' takes only callsign because channel is in URL. Kept for backwards compat.",
|
|
101
146
|
auth: "bearer",
|
|
102
147
|
tools: ["join", "send", "listen", "roster", "history", "leave"],
|
|
103
148
|
},
|
package/dist/mcp.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { getOrCreateChannel } from "./channel.js";
|
|
3
3
|
import { buildConnectInfo } from "./connect.js";
|
|
4
4
|
import { recordJoin as statsRecordJoin, recordMessage as statsRecordMessage } from "./stats.js";
|
|
5
|
-
import { createChannel, getChannelRetention } from "./store.js";
|
|
5
|
+
import { channelExists, createChannel, getChannelRetention, verifyChannel } from "./store.js";
|
|
6
6
|
import { isRetention, recordJoin as transcriptRecordJoin, recordLeave as transcriptRecordLeave, recordMessage as transcriptRecordMessage, } from "./transcripts.js";
|
|
7
7
|
const PROTOCOL_VERSION = "2025-03-26";
|
|
8
8
|
const SERVER_INFO = { name: "rogerrat", version: "0.1.0" };
|
|
@@ -82,10 +82,10 @@ const CHANNEL_TOOLS = [
|
|
|
82
82
|
inputSchema: { type: "object", properties: {} },
|
|
83
83
|
},
|
|
84
84
|
];
|
|
85
|
-
const
|
|
85
|
+
const UNIFIED_TOOLS = [
|
|
86
86
|
{
|
|
87
87
|
name: "create_channel",
|
|
88
|
-
description: "Create a new RogerRat channel. Returns the channel id, join token, MCP URL, and connect snippets
|
|
88
|
+
description: "Create a new RogerRat channel. Returns the channel id, join token, MCP URL, and connect snippets. Optional retention: 'none' (default), 'metadata', 'prompts', 'full'.",
|
|
89
89
|
inputSchema: {
|
|
90
90
|
type: "object",
|
|
91
91
|
properties: {
|
|
@@ -97,6 +97,64 @@ const BOOTSTRAP_TOOLS = [
|
|
|
97
97
|
},
|
|
98
98
|
},
|
|
99
99
|
},
|
|
100
|
+
{
|
|
101
|
+
name: "join",
|
|
102
|
+
description: "Join a channel by id + token + callsign. After joining, this session is bound to that channel — subsequent send/listen/roster/history/leave operate on it. Call leave to detach (then you can join another channel in the same session). Returns roster, recent history, and operating instructions.",
|
|
103
|
+
inputSchema: {
|
|
104
|
+
type: "object",
|
|
105
|
+
properties: {
|
|
106
|
+
channel_id: { type: "string", description: "Channel id like 'quiet-otter-3a8f'." },
|
|
107
|
+
token: { type: "string", description: "Bearer token for that channel (received from create_channel)." },
|
|
108
|
+
callsign: {
|
|
109
|
+
type: "string",
|
|
110
|
+
description: "Your handle on the channel. 1-32 chars, alphanumeric/underscore/dash. Cannot be 'all'.",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
required: ["channel_id", "token", "callsign"],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "send",
|
|
118
|
+
description: "Send a message to another agent on the channel you joined, or to 'all' to broadcast. Requires a prior join() in this session.",
|
|
119
|
+
inputSchema: {
|
|
120
|
+
type: "object",
|
|
121
|
+
properties: {
|
|
122
|
+
to: { type: "string", description: "Recipient callsign, or 'all' for broadcast." },
|
|
123
|
+
message: { type: "string", description: "Message text. Max 8192 chars." },
|
|
124
|
+
},
|
|
125
|
+
required: ["to", "message"],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "listen",
|
|
130
|
+
description: "Long-poll for incoming messages on the channel you joined. Returns immediately if messages are pending; otherwise waits up to timeout_seconds (max 60). Returns empty list on timeout. Call again to keep the conversation alive.",
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: "object",
|
|
133
|
+
properties: {
|
|
134
|
+
timeout_seconds: { type: "number", description: "1-60, default 30.", minimum: 1, maximum: 60 },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "roster",
|
|
140
|
+
description: "List the callsigns of all agents currently on the channel you joined.",
|
|
141
|
+
inputSchema: { type: "object", properties: {} },
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "history",
|
|
145
|
+
description: "Return the last N messages on the channel you joined (default 20, max 100).",
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: {
|
|
149
|
+
n: { type: "number", description: "Number of messages, 1-100. Default 20.", minimum: 1, maximum: 100 },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "leave",
|
|
155
|
+
description: "Leave the current channel. After leaving you can join another in the same session.",
|
|
156
|
+
inputSchema: { type: "object", properties: {} },
|
|
157
|
+
},
|
|
100
158
|
];
|
|
101
159
|
const sessions = new Map();
|
|
102
160
|
function ok(id, result) {
|
|
@@ -174,10 +232,7 @@ async function callChannelTool(channel, sessionId, name, args) {
|
|
|
174
232
|
throw new Error(`unknown tool: ${name}`);
|
|
175
233
|
}
|
|
176
234
|
}
|
|
177
|
-
function
|
|
178
|
-
if (name !== "create_channel") {
|
|
179
|
-
throw new Error(`unknown tool in bootstrap mode: ${name}`);
|
|
180
|
-
}
|
|
235
|
+
function callCreateChannel(args, publicOrigin) {
|
|
181
236
|
const requested = typeof args.retention === "string" ? args.retention : "none";
|
|
182
237
|
if (!isRetention(requested)) {
|
|
183
238
|
throw new Error(`invalid retention: ${requested} (must be one of none|metadata|prompts|full)`);
|
|
@@ -189,20 +244,22 @@ function callBootstrapTool(name, args, publicOrigin) {
|
|
|
189
244
|
`Created channel: ${id}`,
|
|
190
245
|
`Retention: ${retention}${retention === "none" ? " (ephemeral, default)" : ""}`,
|
|
191
246
|
"",
|
|
192
|
-
`
|
|
193
|
-
`Token:
|
|
194
|
-
retention !== "none" ? `Transcript:
|
|
247
|
+
`Channel id: ${id}`,
|
|
248
|
+
`Token: ${token}`,
|
|
249
|
+
retention !== "none" ? `Transcript: ${publicOrigin}/api/channels/${id}/transcript (auth: Bearer ${token})` : "",
|
|
250
|
+
"",
|
|
251
|
+
"─── To join from THIS session ───",
|
|
252
|
+
`Call the join tool with: channel_id="${id}", token="${token}", callsign="<your-name>"`,
|
|
195
253
|
"",
|
|
196
|
-
"───
|
|
254
|
+
"─── To invite ANOTHER agent ───",
|
|
255
|
+
"If their AI client already has rogerrat installed (claude mcp add ... /mcp), they just call join with the channel_id+token above.",
|
|
256
|
+
"Otherwise share one of the connect snippets:",
|
|
197
257
|
"",
|
|
198
258
|
"Claude Code (one line):",
|
|
199
259
|
` ${info.connect.claude_code}`,
|
|
200
260
|
"",
|
|
201
|
-
"
|
|
202
|
-
|
|
203
|
-
"",
|
|
204
|
-
"Anthropic SDK (mcp_servers entry):",
|
|
205
|
-
JSON.stringify(info.connect.anthropic_sdk, null, 2),
|
|
261
|
+
"REST (any CLI with curl):",
|
|
262
|
+
` POST ${publicOrigin}/api/channels/${id}/join with Bearer ${token}`,
|
|
206
263
|
]
|
|
207
264
|
.filter(Boolean)
|
|
208
265
|
.join("\n");
|
|
@@ -211,16 +268,95 @@ function callBootstrapTool(name, args, publicOrigin) {
|
|
|
211
268
|
structuredContent: { ...info, retention },
|
|
212
269
|
};
|
|
213
270
|
}
|
|
271
|
+
async function callUnifiedTool(name, args, state, sessionId, publicOrigin) {
|
|
272
|
+
if (name === "create_channel") {
|
|
273
|
+
return callCreateChannel(args, publicOrigin);
|
|
274
|
+
}
|
|
275
|
+
if (name === "join") {
|
|
276
|
+
const channelId = String(args.channel_id ?? "");
|
|
277
|
+
const token = String(args.token ?? "");
|
|
278
|
+
const callsign = String(args.callsign ?? "");
|
|
279
|
+
if (!channelId || !token || !callsign) {
|
|
280
|
+
throw new Error("join requires channel_id, token, and callsign");
|
|
281
|
+
}
|
|
282
|
+
if (!channelExists(channelId))
|
|
283
|
+
throw new Error(`channel not found: ${channelId}`);
|
|
284
|
+
if (!verifyChannel(channelId, token))
|
|
285
|
+
throw new Error("invalid token for channel");
|
|
286
|
+
if (state.boundChannel && state.boundChannel !== channelId) {
|
|
287
|
+
const oldChannel = getOrCreateChannel(state.boundChannel);
|
|
288
|
+
oldChannel.leave(sessionId);
|
|
289
|
+
state.boundChannel = null;
|
|
290
|
+
}
|
|
291
|
+
const channel = getOrCreateChannel(channelId);
|
|
292
|
+
const { roster, history } = channel.join(sessionId, callsign);
|
|
293
|
+
statsRecordJoin();
|
|
294
|
+
transcriptRecordJoin(channelId, getChannelRetention(channelId), callsign);
|
|
295
|
+
state.boundChannel = channelId;
|
|
296
|
+
const body = [
|
|
297
|
+
`Joined channel ${channelId} as ${callsign}.`,
|
|
298
|
+
`Roster (${roster.length}): ${roster.join(", ")}`,
|
|
299
|
+
"",
|
|
300
|
+
`Recent history (${history.length}):`,
|
|
301
|
+
formatMessages(history),
|
|
302
|
+
"",
|
|
303
|
+
"─── Instructions ───",
|
|
304
|
+
LOOP_INSTRUCTIONS,
|
|
305
|
+
].join("\n");
|
|
306
|
+
return textContent(body);
|
|
307
|
+
}
|
|
308
|
+
if (!state.boundChannel) {
|
|
309
|
+
throw new Error("not joined to a channel; call 'join' with channel_id, token, callsign first");
|
|
310
|
+
}
|
|
311
|
+
const channel = getOrCreateChannel(state.boundChannel);
|
|
312
|
+
switch (name) {
|
|
313
|
+
case "send": {
|
|
314
|
+
const to = String(args.to ?? "");
|
|
315
|
+
const message = String(args.message ?? "");
|
|
316
|
+
const msg = channel.send(sessionId, to, message);
|
|
317
|
+
statsRecordMessage();
|
|
318
|
+
transcriptRecordMessage(channel.id, getChannelRetention(channel.id), msg);
|
|
319
|
+
return textContent(`sent #${msg.id} to ${msg.to}`);
|
|
320
|
+
}
|
|
321
|
+
case "listen": {
|
|
322
|
+
const seconds = typeof args.timeout_seconds === "number" ? args.timeout_seconds : 30;
|
|
323
|
+
const clamped = Math.max(1, Math.min(60, Math.floor(seconds)));
|
|
324
|
+
const msgs = await channel.listen(sessionId, clamped * 1000);
|
|
325
|
+
if (msgs.length === 0) {
|
|
326
|
+
return textContent(`(no messages — ${clamped}s timeout. call listen again to keep listening.)`);
|
|
327
|
+
}
|
|
328
|
+
return textContent(formatMessages(msgs));
|
|
329
|
+
}
|
|
330
|
+
case "roster": {
|
|
331
|
+
const r = channel.roster();
|
|
332
|
+
return textContent(r.length === 0 ? "(empty)" : r.join(", "));
|
|
333
|
+
}
|
|
334
|
+
case "history": {
|
|
335
|
+
const n = typeof args.n === "number" ? args.n : 20;
|
|
336
|
+
return textContent(formatMessages(channel.history(n)));
|
|
337
|
+
}
|
|
338
|
+
case "leave": {
|
|
339
|
+
const cs = channel.callsignOf(sessionId);
|
|
340
|
+
channel.leave(sessionId);
|
|
341
|
+
if (cs)
|
|
342
|
+
transcriptRecordLeave(channel.id, getChannelRetention(channel.id), cs);
|
|
343
|
+
state.boundChannel = null;
|
|
344
|
+
return textContent("left channel");
|
|
345
|
+
}
|
|
346
|
+
default:
|
|
347
|
+
throw new Error(`unknown tool: ${name}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
214
350
|
export async function handleMcpRequest(channelId, rawMessage, incomingSessionId, publicOrigin) {
|
|
215
351
|
const id = rawMessage.id ?? null;
|
|
216
352
|
const method = rawMessage.method;
|
|
217
353
|
const params = (rawMessage.params ?? {});
|
|
218
354
|
if (method === "initialize") {
|
|
219
355
|
const sessionId = incomingSessionId ?? randomUUID();
|
|
220
|
-
sessions.set(sessionId, { initialized: true, channelId });
|
|
356
|
+
sessions.set(sessionId, { initialized: true, channelId, boundChannel: null });
|
|
221
357
|
const instructions = channelId === null
|
|
222
|
-
? "Connected to the RogerRat
|
|
223
|
-
: `Connected to RogerRat channel '${channelId}'. Call the 'join' tool
|
|
358
|
+
? "Connected to the RogerRat hub. Tools: create_channel (make a new channel), join (channel_id+token+callsign to enter any channel), send/listen/roster/history/leave (operate on the joined channel). One session can join any channel by id+token — no extra installs per channel."
|
|
359
|
+
: `Connected to RogerRat channel '${channelId}'. Call the 'join' tool with a callsign to enter.`;
|
|
224
360
|
return {
|
|
225
361
|
status: 200,
|
|
226
362
|
sessionId,
|
|
@@ -247,7 +383,7 @@ export async function handleMcpRequest(channelId, rawMessage, incomingSessionId,
|
|
|
247
383
|
return { status: 200, body: err(id, -32600, "session belongs to a different endpoint") };
|
|
248
384
|
}
|
|
249
385
|
if (method === "tools/list") {
|
|
250
|
-
const tools = channelId === null ?
|
|
386
|
+
const tools = channelId === null ? UNIFIED_TOOLS : CHANNEL_TOOLS;
|
|
251
387
|
return { status: 200, body: ok(id, { tools }) };
|
|
252
388
|
}
|
|
253
389
|
if (method === "tools/call") {
|
|
@@ -255,7 +391,8 @@ export async function handleMcpRequest(channelId, rawMessage, incomingSessionId,
|
|
|
255
391
|
const args = (params.arguments ?? {});
|
|
256
392
|
try {
|
|
257
393
|
if (channelId === null) {
|
|
258
|
-
|
|
394
|
+
const result = await callUnifiedTool(name, args, state, sessionId, publicOrigin);
|
|
395
|
+
return { status: 200, body: ok(id, result) };
|
|
259
396
|
}
|
|
260
397
|
const channel = getOrCreateChannel(channelId);
|
|
261
398
|
const result = await callChannelTool(channel, sessionId, name, args);
|
|
@@ -272,8 +409,9 @@ export function closeSession(sessionId) {
|
|
|
272
409
|
const state = sessions.get(sessionId);
|
|
273
410
|
if (!state)
|
|
274
411
|
return false;
|
|
275
|
-
|
|
276
|
-
|
|
412
|
+
const channelId = state.channelId ?? state.boundChannel;
|
|
413
|
+
if (channelId !== null) {
|
|
414
|
+
const channel = getOrCreateChannel(channelId);
|
|
277
415
|
channel.leave(sessionId);
|
|
278
416
|
}
|
|
279
417
|
sessions.delete(sessionId);
|
package/package.json
CHANGED