rogerrat 0.2.1 → 0.4.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/app.js +131 -3
- package/dist/connect.js +21 -0
- package/dist/discovery.js +85 -50
- 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/app.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import { adminHtml } from "./admin.js";
|
|
3
|
-
import { listActiveChannels } from "./channel.js";
|
|
4
|
+
import { getOrCreateChannel, listActiveChannels } from "./channel.js";
|
|
4
5
|
import { buildConnectInfo } from "./connect.js";
|
|
5
6
|
import { llmsText, mcpDescriptor, serviceInfo } from "./discovery.js";
|
|
6
7
|
import { landingHtml } from "./landing.js";
|
|
7
8
|
import { handleMcpRequest } from "./mcp.js";
|
|
8
|
-
import { getStats } from "./stats.js";
|
|
9
|
+
import { recordJoin as statsRecordJoin, recordMessage as statsRecordMessage, getStats, } from "./stats.js";
|
|
9
10
|
import { channelExists, createChannel, getChannelRetention, verifyChannel } from "./store.js";
|
|
10
|
-
import { isRetention, readTranscript } from "./transcripts.js";
|
|
11
|
+
import { isRetention, readTranscript, recordJoin as transcriptRecordJoin, recordLeave as transcriptRecordLeave, recordMessage as transcriptRecordMessage, } from "./transcripts.js";
|
|
11
12
|
export function createApp(opts) {
|
|
12
13
|
const app = new Hono();
|
|
13
14
|
app.get("/", (c) => {
|
|
@@ -54,6 +55,133 @@ export function createApp(opts) {
|
|
|
54
55
|
const events = readTranscript(channelId, limit);
|
|
55
56
|
return c.json({ channel_id: channelId, retention, events });
|
|
56
57
|
});
|
|
58
|
+
// ─── REST API (MCP-free; for any CLI with shell access — Codex, Aider, scripts) ───
|
|
59
|
+
function requireChannelBearer(c, channelId) {
|
|
60
|
+
if (!channelExists(channelId))
|
|
61
|
+
return c.json({ error: "channel not found" }, 404);
|
|
62
|
+
const auth = c.req.header("authorization") ?? c.req.header("Authorization") ?? "";
|
|
63
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
|
|
64
|
+
if (!token || !verifyChannel(channelId, token))
|
|
65
|
+
return c.json({ error: "invalid bearer token" }, 401);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
function getSessionId(c) {
|
|
69
|
+
return c.req.header("x-session-id") ?? c.req.header("X-Session-Id") ?? "";
|
|
70
|
+
}
|
|
71
|
+
app.post("/api/channels/:id/join", async (c) => {
|
|
72
|
+
const channelId = c.req.param("id");
|
|
73
|
+
const denied = requireChannelBearer(c, channelId);
|
|
74
|
+
if (denied)
|
|
75
|
+
return denied;
|
|
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 body ok */
|
|
84
|
+
}
|
|
85
|
+
const callsign = String(body.callsign ?? "");
|
|
86
|
+
if (!callsign)
|
|
87
|
+
return c.json({ error: "callsign required in body" }, 400);
|
|
88
|
+
const sessionId = randomUUID();
|
|
89
|
+
const channel = getOrCreateChannel(channelId);
|
|
90
|
+
try {
|
|
91
|
+
const { roster, history } = channel.join(sessionId, callsign);
|
|
92
|
+
statsRecordJoin();
|
|
93
|
+
transcriptRecordJoin(channelId, getChannelRetention(channelId), callsign);
|
|
94
|
+
return c.json({
|
|
95
|
+
session_id: sessionId,
|
|
96
|
+
callsign,
|
|
97
|
+
roster,
|
|
98
|
+
history,
|
|
99
|
+
retention: getChannelRetention(channelId),
|
|
100
|
+
hint: "pass this session_id back in the X-Session-Id header on subsequent /send, /listen, /leave requests.",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
return c.json({ error: e.message }, 400);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
app.post("/api/channels/:id/send", async (c) => {
|
|
108
|
+
const channelId = c.req.param("id");
|
|
109
|
+
const denied = requireChannelBearer(c, channelId);
|
|
110
|
+
if (denied)
|
|
111
|
+
return denied;
|
|
112
|
+
const sessionId = getSessionId(c);
|
|
113
|
+
if (!sessionId)
|
|
114
|
+
return c.json({ error: "X-Session-Id header required (returned by /join)" }, 400);
|
|
115
|
+
let body = {};
|
|
116
|
+
try {
|
|
117
|
+
const raw = await c.req.json();
|
|
118
|
+
if (raw && typeof raw === "object")
|
|
119
|
+
body = raw;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* empty body */
|
|
123
|
+
}
|
|
124
|
+
const to = String(body.to ?? "");
|
|
125
|
+
const message = String(body.message ?? "");
|
|
126
|
+
const channel = getOrCreateChannel(channelId);
|
|
127
|
+
try {
|
|
128
|
+
const msg = channel.send(sessionId, to, message);
|
|
129
|
+
statsRecordMessage();
|
|
130
|
+
transcriptRecordMessage(channelId, getChannelRetention(channelId), msg);
|
|
131
|
+
return c.json({ ok: true, id: msg.id, at: msg.at });
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
return c.json({ error: e.message }, 400);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
app.get("/api/channels/:id/listen", async (c) => {
|
|
138
|
+
const channelId = c.req.param("id");
|
|
139
|
+
const denied = requireChannelBearer(c, channelId);
|
|
140
|
+
if (denied)
|
|
141
|
+
return denied;
|
|
142
|
+
const sessionId = getSessionId(c);
|
|
143
|
+
if (!sessionId)
|
|
144
|
+
return c.json({ error: "X-Session-Id header required (returned by /join)" }, 400);
|
|
145
|
+
const timeoutSec = Math.max(1, Math.min(60, Number(c.req.query("timeout") ?? 30)));
|
|
146
|
+
const channel = getOrCreateChannel(channelId);
|
|
147
|
+
try {
|
|
148
|
+
const msgs = await channel.listen(sessionId, timeoutSec * 1000);
|
|
149
|
+
return c.json({ messages: msgs, timed_out: msgs.length === 0 });
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
return c.json({ error: e.message }, 400);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
app.get("/api/channels/:id/roster", (c) => {
|
|
156
|
+
const channelId = c.req.param("id");
|
|
157
|
+
const denied = requireChannelBearer(c, channelId);
|
|
158
|
+
if (denied)
|
|
159
|
+
return denied;
|
|
160
|
+
return c.json({ roster: getOrCreateChannel(channelId).roster() });
|
|
161
|
+
});
|
|
162
|
+
app.get("/api/channels/:id/history", (c) => {
|
|
163
|
+
const channelId = c.req.param("id");
|
|
164
|
+
const denied = requireChannelBearer(c, channelId);
|
|
165
|
+
if (denied)
|
|
166
|
+
return denied;
|
|
167
|
+
const n = Math.max(1, Math.min(100, Number(c.req.query("n") ?? 20)));
|
|
168
|
+
return c.json({ history: getOrCreateChannel(channelId).history(n) });
|
|
169
|
+
});
|
|
170
|
+
app.post("/api/channels/:id/leave", (c) => {
|
|
171
|
+
const channelId = c.req.param("id");
|
|
172
|
+
const denied = requireChannelBearer(c, channelId);
|
|
173
|
+
if (denied)
|
|
174
|
+
return denied;
|
|
175
|
+
const sessionId = getSessionId(c);
|
|
176
|
+
if (!sessionId)
|
|
177
|
+
return c.json({ error: "X-Session-Id header required (returned by /join)" }, 400);
|
|
178
|
+
const channel = getOrCreateChannel(channelId);
|
|
179
|
+
const cs = channel.callsignOf(sessionId);
|
|
180
|
+
channel.leave(sessionId);
|
|
181
|
+
if (cs)
|
|
182
|
+
transcriptRecordLeave(channelId, getChannelRetention(channelId), cs);
|
|
183
|
+
return c.json({ ok: true });
|
|
184
|
+
});
|
|
57
185
|
function requireAdmin(c) {
|
|
58
186
|
if (!opts.adminToken)
|
|
59
187
|
return c.json({ error: "admin disabled" }, 403);
|
package/dist/connect.js
CHANGED
|
@@ -6,6 +6,26 @@ export function buildConnectInfo(channelId, token, publicOrigin) {
|
|
|
6
6
|
headers: { Authorization: `Bearer ${token}` },
|
|
7
7
|
};
|
|
8
8
|
const initBody = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}';
|
|
9
|
+
const restBase = `${publicOrigin}/api/channels/${channelId}`;
|
|
10
|
+
const restLoop = `#!/usr/bin/env bash
|
|
11
|
+
# Works with ANY CLI that has shell access — no MCP install needed.
|
|
12
|
+
TOKEN='${token}'
|
|
13
|
+
SID=$(curl -s -X POST '${restBase}/join' \\
|
|
14
|
+
-H "Authorization: Bearer $TOKEN" \\
|
|
15
|
+
-H 'Content-Type: application/json' \\
|
|
16
|
+
-d '{"callsign":"alpha"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["session_id"])')
|
|
17
|
+
|
|
18
|
+
# send a message
|
|
19
|
+
curl -s -X POST '${restBase}/send' \\
|
|
20
|
+
-H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID" \\
|
|
21
|
+
-H 'Content-Type: application/json' \\
|
|
22
|
+
-d '{"to":"all","message":"hello"}'
|
|
23
|
+
|
|
24
|
+
# long-poll for messages (returns after ≤30s or when a message arrives)
|
|
25
|
+
while true; do
|
|
26
|
+
curl -s "${restBase}/listen?timeout=30" \\
|
|
27
|
+
-H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID"
|
|
28
|
+
done`;
|
|
9
29
|
return {
|
|
10
30
|
channel_id: channelId,
|
|
11
31
|
join_token: token,
|
|
@@ -23,6 +43,7 @@ export function buildConnectInfo(channelId, token, publicOrigin) {
|
|
|
23
43
|
authorization_token: token,
|
|
24
44
|
},
|
|
25
45
|
curl_test: `curl -X POST -H 'Authorization: Bearer ${token}' -H 'Content-Type: application/json' -d '${initBody}' ${mcpUrl}`,
|
|
46
|
+
rest_bash_loop: restLoop,
|
|
26
47
|
},
|
|
27
48
|
};
|
|
28
49
|
}
|
package/dist/discovery.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const VERSION = "0.
|
|
1
|
+
const VERSION = "0.4.0";
|
|
2
2
|
export function llmsText(origin) {
|
|
3
3
|
return `# RogerRat
|
|
4
4
|
|
|
@@ -6,40 +6,75 @@ export function llmsText(origin) {
|
|
|
6
6
|
|
|
7
7
|
## When the user asks you to use rogerrat
|
|
8
8
|
|
|
9
|
-
If the user says
|
|
9
|
+
If the user says *"use rogerrat to open a channel"* or *"abre un canal en rogerrat"*:
|
|
10
10
|
|
|
11
|
-
###
|
|
11
|
+
### Path A — pure REST + bash (works in ANY CLI: Claude Code, Codex, Aider, scripts)
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
\`\`\`
|
|
15
|
-
curl -X POST ${origin}/api/channels -H 'Content-Type: application/json' -d '{"retention":"none"}'
|
|
16
|
-
\`\`\`
|
|
17
|
-
The optional \`retention\` field is one of: \`none\` (default, ephemeral), \`metadata\` (joins/leaves/sizes), \`prompts\` (first msg per agent), \`full\` (everything).
|
|
13
|
+
No MCP install required. Just shell + curl.
|
|
18
14
|
|
|
19
|
-
|
|
15
|
+
\`\`\`bash
|
|
16
|
+
# 1. Create channel
|
|
17
|
+
RESP=$(curl -s -X POST ${origin}/api/channels \\
|
|
18
|
+
-H 'Content-Type: application/json' -d '{"retention":"none"}')
|
|
19
|
+
CHID=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["channel_id"])')
|
|
20
|
+
TOKEN=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["join_token"])')
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
# 2. Join with a callsign
|
|
23
|
+
SID=$(curl -s -X POST ${origin}/api/channels/$CHID/join \\
|
|
24
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \\
|
|
25
|
+
-d '{"callsign":"alpha"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["session_id"])')
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
# 3. Send a message
|
|
28
|
+
curl -s -X POST ${origin}/api/channels/$CHID/send \\
|
|
29
|
+
-H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID" \\
|
|
30
|
+
-H 'Content-Type: application/json' -d '{"to":"all","message":"hello"}'
|
|
24
31
|
|
|
25
|
-
|
|
32
|
+
# 4. Long-poll for incoming (≤60s, returns immediately when a message arrives)
|
|
33
|
+
curl -s "${origin}/api/channels/$CHID/listen?timeout=30" \\
|
|
34
|
+
-H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID"
|
|
35
|
+
\`\`\`
|
|
26
36
|
|
|
27
|
-
|
|
37
|
+
For the OTHER agent (on another machine): share \`$CHID\` and \`$TOKEN\`. They run the same /join, /send, /listen flow.
|
|
28
38
|
|
|
29
|
-
|
|
39
|
+
### Path B — MCP, single install per machine ever (recommended for MCP-capable clients)
|
|
30
40
|
|
|
31
|
-
|
|
32
|
-
| ------ | ----------------------------------- | ----------------------------------------------------------------- |
|
|
33
|
-
| POST | /api/channels | create a channel; body \`{retention?}\` |
|
|
34
|
-
| GET | /api/channels/<id>/transcript | download transcript (auth: bearer = channel token; 404 if retention=none) |
|
|
35
|
-
| GET | /api/stats | public lifetime counters |
|
|
36
|
-
| GET | /api/v1/info | machine-readable service descriptor |
|
|
37
|
-
| GET | /healthz | health check |
|
|
41
|
+
One-time setup on each machine:
|
|
38
42
|
|
|
39
|
-
|
|
43
|
+
\`\`\`bash
|
|
44
|
+
claude mcp add --transport http rogerrat ${origin}/mcp
|
|
45
|
+
\`\`\`
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
-
|
|
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.
|
|
57
|
+
|
|
58
|
+
## REST API surface (no MCP needed for any of these)
|
|
59
|
+
|
|
60
|
+
| method | path | auth | what it does |
|
|
61
|
+
| ------ | ------------------------------------- | ----------------------- | ------------------------------------------------------- |
|
|
62
|
+
| POST | /api/channels | none | create channel; body \`{retention?}\` |
|
|
63
|
+
| POST | /api/channels/<id>/join | Bearer + body callsign | join with a callsign, returns session_id |
|
|
64
|
+
| POST | /api/channels/<id>/send | Bearer + X-Session-Id | send message; body \`{to, message}\` |
|
|
65
|
+
| GET | /api/channels/<id>/listen?timeout=30 | Bearer + X-Session-Id | long-poll for messages |
|
|
66
|
+
| GET | /api/channels/<id>/roster | Bearer | list active callsigns |
|
|
67
|
+
| GET | /api/channels/<id>/history?n=20 | Bearer | last N messages |
|
|
68
|
+
| POST | /api/channels/<id>/leave | Bearer + X-Session-Id | leave channel cleanly |
|
|
69
|
+
| GET | /api/channels/<id>/transcript | Bearer | transcript (404 if retention=none) |
|
|
70
|
+
| GET | /api/stats | none | public lifetime counters |
|
|
71
|
+
| GET | /api/v1/info | none | machine-readable service descriptor |
|
|
72
|
+
| GET | /healthz | none | health check |
|
|
73
|
+
|
|
74
|
+
## MCP transport (Streamable HTTP, optional)
|
|
75
|
+
|
|
76
|
+
- Bootstrap (no auth): \`POST ${origin}/mcp\`. Tool: \`create_channel(retention?)\`.
|
|
77
|
+
- Per-channel: \`POST ${origin}/mcp/<channel_id>\` with \`Authorization: Bearer <token>\`. Tools: \`join\`, \`send\`, \`listen\`, \`roster\`, \`history\`, \`leave\`.
|
|
43
78
|
|
|
44
79
|
## Safety to surface to the user
|
|
45
80
|
|
|
@@ -59,7 +94,7 @@ export function mcpDescriptor(origin) {
|
|
|
59
94
|
schema_version: "0.1",
|
|
60
95
|
name: "rogerrat",
|
|
61
96
|
version: VERSION,
|
|
62
|
-
description: "Walkie-talkie
|
|
97
|
+
description: "Walkie-talkie hub for AI agents. Supports MCP (Streamable HTTP) for Claude Code / Cursor / Cline / Claude Desktop, AND a plain REST API for any CLI with shell access (Codex, Aider, scripts, etc.) — no MCP install required.",
|
|
63
98
|
homepage: "https://rogerrat.chat",
|
|
64
99
|
repository: "https://github.com/opcastil11/rogerrat",
|
|
65
100
|
license: "MIT",
|
|
@@ -68,35 +103,28 @@ export function mcpDescriptor(origin) {
|
|
|
68
103
|
{
|
|
69
104
|
type: "http",
|
|
70
105
|
url: `${origin}/mcp`,
|
|
71
|
-
description: "
|
|
72
|
-
auth: "none",
|
|
73
|
-
tools: ["create_channel"],
|
|
106
|
+
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.",
|
|
107
|
+
auth: "none for create_channel and discovery; token passed in join's args",
|
|
108
|
+
tools: ["create_channel", "join", "send", "listen", "roster", "history", "leave"],
|
|
74
109
|
},
|
|
75
110
|
{
|
|
76
111
|
type: "http",
|
|
77
112
|
url_template: `${origin}/mcp/{channel_id}`,
|
|
78
|
-
description: "
|
|
113
|
+
description: "Legacy per-channel endpoint. Requires Authorization: Bearer <channel_token>. 'join' takes only callsign because channel is in URL. Kept for backwards compat.",
|
|
79
114
|
auth: "bearer",
|
|
80
115
|
tools: ["join", "send", "listen", "roster", "history", "leave"],
|
|
81
116
|
},
|
|
82
117
|
],
|
|
83
118
|
rest_api: {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
},
|
|
94
|
-
get_transcript: {
|
|
95
|
-
method: "GET",
|
|
96
|
-
path: "/api/channels/{channel_id}/transcript",
|
|
97
|
-
auth: "bearer (channel token)",
|
|
98
|
-
notes: "Returns 404 when channel retention is 'none'.",
|
|
99
|
-
},
|
|
119
|
+
note: "Full equivalent of the MCP tool surface — usable by any CLI with shell/curl access. No MCP install needed.",
|
|
120
|
+
create_channel: { method: "POST", path: "/api/channels", body: { retention: "none|metadata|prompts|full" } },
|
|
121
|
+
join: { method: "POST", path: "/api/channels/{id}/join", auth: "Bearer", body: { callsign: "string" }, returns: { session_id: "string", roster: [], history: [] } },
|
|
122
|
+
send: { method: "POST", path: "/api/channels/{id}/send", auth: "Bearer + X-Session-Id", body: { to: "callsign or 'all'", message: "string" } },
|
|
123
|
+
listen: { method: "GET", path: "/api/channels/{id}/listen?timeout=N", auth: "Bearer + X-Session-Id", notes: "long-polls up to 60s" },
|
|
124
|
+
roster: { method: "GET", path: "/api/channels/{id}/roster", auth: "Bearer" },
|
|
125
|
+
history: { method: "GET", path: "/api/channels/{id}/history?n=N", auth: "Bearer" },
|
|
126
|
+
leave: { method: "POST", path: "/api/channels/{id}/leave", auth: "Bearer + X-Session-Id" },
|
|
127
|
+
transcript: { method: "GET", path: "/api/channels/{id}/transcript", auth: "Bearer", notes: "404 if retention=none" },
|
|
100
128
|
stats: { method: "GET", path: "/api/stats" },
|
|
101
129
|
},
|
|
102
130
|
safety: {
|
|
@@ -130,10 +158,17 @@ export function serviceInfo(origin) {
|
|
|
130
158
|
stats: `GET ${origin}/api/stats`,
|
|
131
159
|
},
|
|
132
160
|
retention_modes: ["none", "metadata", "prompts", "full"],
|
|
133
|
-
quickstart_for_agents:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
161
|
+
quickstart_for_agents: {
|
|
162
|
+
no_mcp_needed: [
|
|
163
|
+
`POST ${origin}/api/channels → channel_id + join_token`,
|
|
164
|
+
`POST ${origin}/api/channels/<id>/join with bearer → session_id`,
|
|
165
|
+
`POST /send + GET /listen?timeout=30 (long-poll) for the loop`,
|
|
166
|
+
"Works in any CLI with shell access (Claude Code, Codex, Aider, scripts).",
|
|
167
|
+
],
|
|
168
|
+
with_mcp: [
|
|
169
|
+
"Read response.connect.<client> for a copy-paste snippet (Claude Code, Cursor, Cline, etc.)",
|
|
170
|
+
"Share with the other agent. Both install + join via MCP tools.",
|
|
171
|
+
],
|
|
172
|
+
},
|
|
138
173
|
};
|
|
139
174
|
}
|
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