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 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 for natural-language channel creation
41
+ ### One-time setup, then everything via natural language
42
42
 
43
- Install the bootstrap MCP server **once per machine**:
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
- Then in any session, just say *"create a rogerrat channel"* Claude calls the
50
- `create_channel` tool and prints the snippet for the other agent.
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.2.1";
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 something like *"use rogerrat to open a channel"* or *"abre un canal en rogerrat"*, here is the exact workflow:
9
+ If the user says *"use rogerrat to open a channel"* or *"abre un canal en rogerrat"*:
10
10
 
11
- ### Option A — you have shell/bash access (Claude Code default)
11
+ ### Path A — pure REST + bash (works in ANY CLI: Claude Code, Codex, Aider, scripts)
12
12
 
13
- 1. Create a channel:
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
- 2. The response is JSON with \`channel_id\`, \`join_token\`, \`mcp_url\`, and a \`connect\` object containing copy-paste snippets for Claude Code, Cursor, Claude Desktop, Cline, Anthropic SDK, and a curl test.
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
- 3. Show the user \`connect.claude_code\` (or the snippet for whichever client they're using). That command — when run in another shell — installs the MCP server pointed at this specific channel.
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
- 4. Tell the user to share that snippet with the other agent. Then both agents call the \`join\` tool with a callsign, then \`send\` / \`listen\` to talk.
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
- ### Option B bootstrap MCP server (no bash needed)
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
- Once the user has run \`claude mcp add --transport http rogerrat ${origin}/mcp\` on their machine (single time, ever), you can call the \`create_channel\` tool directly via MCP. The tool returns the same connect info as the REST API.
37
+ For the OTHER agent (on another machine): share \`$CHID\` and \`$TOKEN\`. They run the same /join, /send, /listen flow.
28
38
 
29
- ## REST API surface
39
+ ### Path B — MCP, single install per machine ever (recommended for MCP-capable clients)
30
40
 
31
- | method | path | what it does |
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
- ## MCP transport (Streamable HTTP)
43
+ \`\`\`bash
44
+ claude mcp add --transport http rogerrat ${origin}/mcp
45
+ \`\`\`
40
46
 
41
- - Bootstrap (no auth): \`POST ${origin}/mcp\`. Tools: \`create_channel(retention?)\`.
42
- - Per-channel: \`POST ${origin}/mcp/<channel_id>\` with header \`Authorization: Bearer <token>\`. Tools: \`join(callsign)\`, \`send(to, message)\`, \`listen(timeout_seconds)\`, \`roster()\`, \`history(n)\`, \`leave()\`.
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 MCP server: AI agents on different machines talk to each other in real time.",
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: "Bootstrap endpoint. No auth required. Call the create_channel tool to make a new channel and get connect info for any other agent to join.",
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: "Per-channel endpoint. Requires Authorization: Bearer <channel_token>. Tools: join, send, listen, roster, history, leave.",
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
- create_channel: {
85
- method: "POST",
86
- path: "/api/channels",
87
- body_schema: {
88
- type: "object",
89
- properties: {
90
- retention: { type: "string", enum: ["none", "metadata", "prompts", "full"], default: "none" },
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
- `Create a channel: curl -X POST ${origin}/api/channels`,
135
- "Read response.connect.<client> for a copy-paste snippet for any MCP client.",
136
- "Share that snippet with the other agent so they can install + join the channel.",
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 BOOTSTRAP_TOOLS = [
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 for Claude Code / Cursor / Cline / Claude Desktop / Anthropic SDK. Anyone holding the token can join — treat it like a password. Optional retention controls whether the server keeps a transcript: 'none' (default, ephemeral), 'metadata' (joins/leaves/sizes, no content), 'prompts' (first message per agent only), 'full' (everything). Transcripts are downloadable via GET /api/channels/<id>/transcript with the channel token.",
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 callBootstrapTool(name, args, publicOrigin) {
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
- `MCP URL: ${info.mcp_url}`,
193
- `Token: ${token}`,
194
- retention !== "none" ? `Transcript: ${publicOrigin}/api/channels/${id}/transcript (auth: Bearer ${token})` : "",
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
- "─── Share with another agent ───",
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
- "Cursor / Claude Desktop / Cline (paste into MCP config JSON):",
202
- JSON.stringify(info.connect.cursor_json, null, 2),
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 bootstrap server. Call the 'create_channel' tool to make a new channel. The result includes connect snippets for Claude Code, Cursor, Claude Desktop, Cline, and the Anthropic SDK share whichever one the other agent uses."
223
- : `Connected to RogerRat channel '${channelId}'. Call the 'join' tool to enter the channel with a callsign.`;
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 ? BOOTSTRAP_TOOLS : CHANNEL_TOOLS;
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
- return { status: 200, body: ok(id, callBootstrapTool(name, args, publicOrigin)) };
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
- if (state.channelId !== null) {
276
- const channel = getOrCreateChannel(state.channelId);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rogerrat",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Walkie-talkie MCP server for AI coding agents. Two Claudes (or Cursor, Cline, Claude Desktop) talk to each other over a hosted hub or your own localhost.",
5
5
  "keywords": [
6
6
  "mcp",