agent-coord-mcp 0.5.3 → 0.7.2

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/src/server.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  #!/usr/bin/env node
2
+ import { randomUUID } from "node:crypto";
3
+ import { createServer, IncomingMessage, ServerResponse } from "node:http";
2
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { ensureDirs } from "./store.js";
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { ensureDirs, readTokenMapSync } from "./store.js";
5
8
  import {
6
9
  attachAgentSchema,
7
10
  attachAgentTool,
11
+ clearTransportSchema,
12
+ clearTransportTool,
8
13
  detachAgentSchema,
9
14
  detachAgentTool,
15
+ doctorSchema,
16
+ doctorTool,
10
17
  heartbeatSchema,
11
18
  heartbeatTool,
12
19
  joinRoomSchema,
@@ -29,6 +36,8 @@ import {
29
36
  registerTool,
30
37
  renameAgentSchema,
31
38
  renameAgentTool,
39
+ reportTransportSchema,
40
+ reportTransportTool,
32
41
  sendMessageSchema,
33
42
  sendMessageTool,
34
43
  setRoomMotdSchema,
@@ -49,8 +58,44 @@ function jsonResult(data: unknown) {
49
58
  };
50
59
  }
51
60
 
52
- async function main() {
53
- ensureDirs();
61
+ // Build a fully-configured McpServer with every tool registered. Returns a
62
+ // fresh instance each call — in HTTP mode we need one server per session so
63
+ // transports don't share Protocol state.
64
+ //
65
+ // Identity binding (v0.7.0 + TOFU in v0.7.1):
66
+ // - `initialBound` (when set) pre-binds the session — from a bearer token
67
+ // (HTTP/tokens.json) or AGENT_COORD_BOUND_AGENT env (stdio).
68
+ // - Otherwise the session starts unbound. The first tool call that carries
69
+ // an agentId/from field captures that value as the session's binding —
70
+ // trust-on-first-use. Subsequent calls must match; mid-session identity
71
+ // switching (the PR #45 spoof shape) is rejected.
72
+ // - rename_agent updates the binding to the new id on success so the
73
+ // renamed session keeps working.
74
+ function buildServer(initialBound?: string): McpServer {
75
+ let bound = initialBound;
76
+
77
+ // Gate every tool that takes a caller identity. `field: null` (list_agents,
78
+ // list_rooms, prune) bypasses the check entirely.
79
+ function gate(
80
+ field: "agentId" | "from" | null,
81
+ handler: (args: Record<string, unknown>) => Promise<unknown>,
82
+ ) {
83
+ return async (args: Record<string, unknown>) => {
84
+ if (field) {
85
+ const claimed = args[field];
86
+ if (typeof claimed === "string") {
87
+ if (bound === undefined) {
88
+ bound = claimed; // TOFU: first claim wins, then sticky.
89
+ } else if (bound !== claimed) {
90
+ throw new Error(
91
+ `identity bound to '${bound}'; rejected attempt to act as '${claimed}'`,
92
+ );
93
+ }
94
+ }
95
+ }
96
+ return jsonResult(await handler(args));
97
+ };
98
+ }
54
99
 
55
100
  const server = new McpServer({
56
101
  name: "agent-coord",
@@ -61,137 +106,345 @@ async function main() {
61
106
  "join",
62
107
  "Recommended session-start call. Does register + auto-attach (if running inside tmux) + read inbox in one round-trip. Pass attach=false to skip the transport, attach={...overrides} to customize, or omit it to let the server auto-detect $TMUX_PANE. Returns the registration, attach result, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect.",
63
108
  joinSchema,
64
- async (args) => jsonResult(await joinTool(args))
109
+ gate("agentId", joinTool as (a: Record<string, unknown>) => Promise<unknown>),
65
110
  );
66
111
 
67
112
  server.tool(
68
113
  "register",
69
114
  "Register this agent in the shared registry. Lower-level than `join` — does not attach a transport or drain the inbox. Prefer `join` unless you need explicit control.",
70
115
  registerSchema,
71
- async (args) => jsonResult(await registerTool(args))
116
+ gate("agentId", registerTool as (a: Record<string, unknown>) => Promise<unknown>),
72
117
  );
73
118
 
74
119
  server.tool(
75
120
  "unregister",
76
121
  "Tear down this agent: detach any attached transport (kills the pusher) and remove the registry entry. Clean shutdown counterpart to `join`.",
77
122
  unregisterSchema,
78
- async (args) => jsonResult(await unregisterTool(args))
123
+ gate("agentId", unregisterTool as (a: Record<string, unknown>) => Promise<unknown>),
79
124
  );
80
125
 
81
126
  server.tool(
82
127
  "status",
83
128
  "Introspect this agent's coord state: registration, attached transport, inbox depth and unread count, and whether this MCP server is running inside tmux. Useful for debugging 'why isn't my DM landing'.",
84
129
  statusSchema,
85
- async (args) => jsonResult(await statusTool(args))
130
+ gate("agentId", statusTool as (a: Record<string, unknown>) => Promise<unknown>),
86
131
  );
87
132
 
88
133
  server.tool(
89
134
  "heartbeat",
90
135
  "Refresh this agent's lastHeartbeat timestamp.",
91
136
  heartbeatSchema,
92
- async (args) => jsonResult(await heartbeatTool(args))
137
+ gate("agentId", heartbeatTool as (a: Record<string, unknown>) => Promise<unknown>),
93
138
  );
94
139
 
95
140
  server.tool(
96
141
  "list_agents",
97
142
  "List all known agents and whether they appear online (heartbeat <5min).",
98
143
  listAgentsSchema,
99
- async () => jsonResult(await listAgentsTool())
144
+ gate(null, listAgentsTool as () => Promise<unknown>),
100
145
  );
101
146
 
102
147
  server.tool(
103
148
  "send_message",
104
- "Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel.",
149
+ "Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. The 'from' field is enforced against the session's bound identity when binding is configured.",
105
150
  sendMessageSchema,
106
- async (args) => jsonResult(await sendMessageTool(args))
151
+ gate("from", sendMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
107
152
  );
108
153
 
109
154
  server.tool(
110
155
  "read_messages",
111
156
  "Read new messages from inbox|room|status. For source='room', pass 'room' to read a specific channel (default 'general'). Advances the per-channel cursor unless peek=true.",
112
157
  readMessagesSchema,
113
- async (args) => jsonResult(await readMessagesTool(args))
158
+ gate("agentId", readMessagesTool as (a: Record<string, unknown>) => Promise<unknown>),
114
159
  );
115
160
 
116
161
  server.tool(
117
162
  "post_status",
118
163
  "Append a status broadcast to the shared status stream.",
119
164
  postStatusSchema,
120
- async (args) => jsonResult(await postStatusTool(args))
165
+ gate("agentId", postStatusTool as (a: Record<string, unknown>) => Promise<unknown>),
121
166
  );
122
167
 
123
168
  server.tool(
124
169
  "prune",
125
170
  "Trim room/status/inbox JSONL to entries newer than `olderThanDays` (default 7). Removes inbox files for agents no longer in the registry unless removeOrphanInboxes=false. Pass dryRun=true to preview.",
126
171
  pruneSchema,
127
- async (args) => jsonResult(await pruneTool(args))
172
+ gate(null, pruneTool as (a: Record<string, unknown>) => Promise<unknown>),
128
173
  );
129
174
 
130
175
  server.tool(
131
176
  "wait_for_message",
132
177
  "Block (max 60s) until a new message appears on the given source, then return it. For source='room', pass 'room' to wait on a specific channel (default 'general').",
133
178
  waitForMessageSchema,
134
- async (args) => jsonResult(await waitForMessageTool(args))
179
+ gate("agentId", waitForMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
135
180
  );
136
181
 
137
182
  server.tool(
138
183
  "list_rooms",
139
184
  "List all channels with their topic, MOTD (room rules), members, message count, and last activity.",
140
185
  listRoomsSchema,
141
- async () => jsonResult(await listRoomsTool())
186
+ gate(null, listRoomsTool as () => Promise<unknown>),
142
187
  );
143
188
 
144
189
  server.tool(
145
190
  "join_room",
146
191
  "Join a channel (creating it if new). Adds this agent to the channel's membership so the notification hooks push its messages, and returns the channel's topic, MOTD, members, and unread count.",
147
192
  joinRoomSchema,
148
- async (args) => jsonResult(await joinRoomTool(args))
193
+ gate("agentId", joinRoomTool as (a: Record<string, unknown>) => Promise<unknown>),
149
194
  );
150
195
 
151
196
  server.tool(
152
197
  "leave_room",
153
198
  "Leave a channel — removes this agent from its membership. Cannot leave the default 'general' channel.",
154
199
  leaveRoomSchema,
155
- async (args) => jsonResult(await leaveRoomTool(args))
200
+ gate("agentId", leaveRoomTool as (a: Record<string, unknown>) => Promise<unknown>),
156
201
  );
157
202
 
158
203
  server.tool(
159
204
  "set_room_topic",
160
205
  "Set a channel's topic (a short one-line description). Posts a system notice to the channel.",
161
206
  setRoomTopicSchema,
162
- async (args) => jsonResult(await setRoomTopicTool(args))
207
+ gate("agentId", setRoomTopicTool as (a: Record<string, unknown>) => Promise<unknown>),
163
208
  );
164
209
 
165
210
  server.tool(
166
211
  "set_room_motd",
167
212
  "Set a channel's MOTD / room rules (shown to agents on join). Posts a system notice to the channel.",
168
213
  setRoomMotdSchema,
169
- async (args) => jsonResult(await setRoomMotdTool(args))
214
+ gate("agentId", setRoomMotdTool as (a: Record<string, unknown>) => Promise<unknown>),
170
215
  );
171
216
 
172
217
  server.tool(
173
218
  "rename_agent",
174
- "Rename an agent (NICK): migrates its registry entry, inbox, cursor, and channel memberships to the new id, then broadcasts a rename notice to its channels. If a live tmux-push transport is attached it is detached first (the pusher is bound to the old id) — re-attach as the new id (join/attach_agent) to restore real-time delivery; the response sets detachedTransport + a warning when this happens.",
219
+ "Rename an agent (NICK): migrates its registry entry, inbox, cursor, and channel memberships to the new id, then broadcasts a rename notice to its channels. When tokens.json identity binding is on, the caller's bearer token is atomically rotated to the new id so the same session keeps authenticating after rename. If a live tmux-push transport is attached it is detached first (the pusher is bound to the old id) — re-attach as the new id (join/attach_agent) to restore real-time delivery; the response sets detachedTransport + a warning when this happens.",
175
220
  renameAgentSchema,
176
- async (args) => jsonResult(await renameAgentTool(args))
221
+ // Special: after a successful rename we update the session's bound id
222
+ // too, so the same session can keep operating under the new name without
223
+ // the next call being rejected as a binding mismatch.
224
+ async (args: Record<string, unknown>) => {
225
+ const claimed = args.agentId;
226
+ if (typeof claimed === "string") {
227
+ if (bound === undefined) bound = claimed;
228
+ else if (bound !== claimed) {
229
+ throw new Error(`identity bound to '${bound}'; rejected attempt to act as '${claimed}'`);
230
+ }
231
+ }
232
+ const result = await renameAgentTool(args as { agentId: string; newAgentId: string });
233
+ if (result && typeof result === "object" && (result as { ok?: unknown }).ok === true) {
234
+ const to = (result as { to?: unknown }).to;
235
+ if (typeof to === "string") bound = to;
236
+ }
237
+ return jsonResult(result);
238
+ },
177
239
  );
178
240
 
179
241
  server.tool(
180
242
  "attach_agent",
181
243
  "Start the tmux-push transport for an agent: spawns hooks/tmux-pusher.mjs as a background process so peer DMs (and optionally room messages) get typed into the agent's tmux pane in real time. tmuxTarget defaults to the MCP server's own $TMUX_PANE if this server is running inside tmux. allowlist restricts which peer agentIds can push. Updates list_agents to show transport=tmux-push.",
182
244
  attachAgentSchema,
183
- async (args) => jsonResult(await attachAgentTool(args))
245
+ gate("agentId", attachAgentTool as (a: Record<string, unknown>) => Promise<unknown>),
184
246
  );
185
247
 
186
248
  server.tool(
187
249
  "detach_agent",
188
250
  "Stop the tmux-push transport for an agent: kills the pusher process and clears the transport marker.",
189
251
  detachAgentSchema,
190
- async (args) => jsonResult(await detachAgentTool(args))
252
+ gate("agentId", detachAgentTool as (a: Record<string, unknown>) => Promise<unknown>),
253
+ );
254
+
255
+ server.tool(
256
+ "report_transport",
257
+ "Publish a transport marker for an agent (used by the remote tmux pusher, scripts/coord-pusher.mjs, to surface itself in list_agents). Set transport='tmux-push-remote' and optionally host/tmuxTarget. Liveness for remote markers is heartbeat-based — keep calling heartbeat or this marker gets GC'd after staleness.",
258
+ reportTransportSchema,
259
+ gate("agentId", reportTransportTool as (a: Record<string, unknown>) => Promise<unknown>),
191
260
  );
192
261
 
193
- const transport = new StdioServerTransport();
194
- await server.connect(transport);
262
+ server.tool(
263
+ "clear_transport",
264
+ "Idempotent delete of an agent's transport marker. The wire-callable counterpart to detach_agent for remote pushers: it only removes the marker — there's no local process to kill.",
265
+ clearTransportSchema,
266
+ gate("agentId", clearTransportTool as (a: Record<string, unknown>) => Promise<unknown>),
267
+ );
268
+
269
+ server.tool(
270
+ "doctor",
271
+ "Bus-wide health check: inspects the whole state dir and reports drift, leaks, and corruption (orphan transport markers / memberships / inboxes, cursor offsets past EOF, malformed JSONL, stale agents, oversized files, stale locks, channel/registry mismatches, environment). Read-only by default; pass fix=true to apply the safe, reversible repairs (malformed-line rewrites are backed up to .bak first). A clean report (healthy=true) means the bus is internally consistent.",
272
+ doctorSchema,
273
+ gate(null, doctorTool as (a: Record<string, unknown>) => Promise<unknown>),
274
+ );
275
+
276
+ return server;
277
+ }
278
+
279
+ // Lazy-loaded token map for HTTP identity binding. Hot-reloaded on SIGHUP so
280
+ // operators can rotate / add agents without a server restart.
281
+ let tokenMap: Map<string, string> | null = null;
282
+ function loadTokenMap(initial: boolean): void {
283
+ try {
284
+ tokenMap = readTokenMapSync();
285
+ } catch (e) {
286
+ // On initial load a bad file is fatal — refuse to start in a known-bad
287
+ // auth state. On SIGHUP, log and keep the previous (valid) map.
288
+ if (initial) {
289
+ console.error((e as Error).message);
290
+ process.exit(1);
291
+ }
292
+ console.error(`[agent-coord-mcp] SIGHUP: ${(e as Error).message} (keeping previous map)`);
293
+ return;
294
+ }
295
+ if (!initial) {
296
+ console.error(`[agent-coord-mcp] SIGHUP: token map reloaded (${tokenMap?.size ?? 0} agents)`);
297
+ }
298
+ }
299
+
300
+ async function main() {
301
+ ensureDirs();
302
+ loadTokenMap(true);
303
+ process.on("SIGHUP", () => loadTokenMap(false));
304
+
305
+ // Transport selector. AGENT_COORD_HTTP_PORT set → run as a long-lived HTTP
306
+ // daemon (Streamable HTTP transport + bearer-token auth). Otherwise the
307
+ // historical stdio behavior (per-client subprocess spawned by Claude Code).
308
+ const httpPort = process.env.AGENT_COORD_HTTP_PORT;
309
+ if (httpPort) {
310
+ await startHttp(parseInt(httpPort, 10));
311
+ } else {
312
+ const boundAgent = process.env.AGENT_COORD_BOUND_AGENT;
313
+ if (!boundAgent) {
314
+ console.error(
315
+ "[agent-coord-mcp] bus identity unbound (stdio) — falling back to TOFU: the " +
316
+ "first tool call's agentId/from claim becomes this session's bound identity " +
317
+ "and subsequent calls cannot switch. For stricter pre-binding, set " +
318
+ "AGENT_COORD_BOUND_AGENT=<your-id> in the MCP launch env.",
319
+ );
320
+ }
321
+ const server = buildServer(boundAgent);
322
+ const transport = new StdioServerTransport();
323
+ await server.connect(transport);
324
+ }
325
+ }
326
+
327
+ async function startHttp(port: number): Promise<void> {
328
+ const sharedToken = process.env.AGENT_COORD_TOKEN;
329
+ const bound = tokenMap !== null;
330
+ if (!bound && !sharedToken) {
331
+ console.error(
332
+ "[agent-coord-mcp] HTTP mode needs auth: either set AGENT_COORD_TOKEN (legacy " +
333
+ "shared bearer, advisory identity) or create ~/agent-coord/tokens.json (per-agent " +
334
+ "tokens, enforced identity). Refusing to start an unauthenticated network listener.",
335
+ );
336
+ process.exit(1);
337
+ }
338
+ if (bound && sharedToken) {
339
+ console.error(
340
+ "[agent-coord-mcp] note: tokens.json is present — AGENT_COORD_TOKEN is ignored " +
341
+ "(per-agent tokens take precedence).",
342
+ );
343
+ }
344
+ if (!bound) {
345
+ console.error(
346
+ "[agent-coord-mcp] bus identity unbound (HTTP) — shared bearer auths the channel; " +
347
+ "per-session identity falls back to TOFU (the first agentId/from claim becomes " +
348
+ "the session's bound id, can't switch mid-stream). Create ~/agent-coord/tokens.json " +
349
+ "to pre-bind sessions to identities at connect time.",
350
+ );
351
+ }
352
+ const bindAddr = process.env.AGENT_COORD_BIND ?? "127.0.0.1";
353
+ const sharedExpected = sharedToken ? `Bearer ${sharedToken}` : null;
354
+
355
+ // One transport+server pair per client session. The SDK exposes session
356
+ // affinity via the `mcp-session-id` header: a new request without it is
357
+ // an init (create new pair); follow-ups carry the id (look up the pair).
358
+ // We cannot share one stateful transport across clients (it errors with
359
+ // "Server already initialized"), and stateless mode rejects reuse.
360
+ const sessions = new Map<string, StreamableHTTPServerTransport>();
361
+
362
+ async function makeSessionTransport(boundAgent?: string): Promise<StreamableHTTPServerTransport> {
363
+ // `let` + explicit type lets the SDK callbacks close over the binding
364
+ // before it's assigned — they only fire after construction completes.
365
+ let transport: StreamableHTTPServerTransport;
366
+ transport = new StreamableHTTPServerTransport({
367
+ sessionIdGenerator: () => randomUUID(),
368
+ onsessioninitialized: (id: string) => { sessions.set(id, transport); },
369
+ });
370
+ transport.onclose = () => {
371
+ if (transport.sessionId) sessions.delete(transport.sessionId);
372
+ };
373
+ const server = buildServer(boundAgent);
374
+ await server.connect(transport);
375
+ return transport;
376
+ }
377
+
378
+ // Reverse-lookup: extract bearer from header, map → bound agent. Returns
379
+ // undefined if no map is configured (advisory mode); throws-like return of
380
+ // null if the bearer doesn't match any known agent (caller responds 401).
381
+ function resolveBoundAgent(authHeader: string | undefined): { ok: boolean; agent?: string } {
382
+ if (!authHeader || !authHeader.startsWith("Bearer ")) return { ok: false };
383
+ const bearer = authHeader.slice("Bearer ".length);
384
+ if (tokenMap) {
385
+ const agent = tokenMap.get(bearer);
386
+ return agent ? { ok: true, agent } : { ok: false };
387
+ }
388
+ // Advisory mode: only check the shared bearer matches.
389
+ return sharedExpected && authHeader === sharedExpected ? { ok: true } : { ok: false };
390
+ }
391
+
392
+ const http = createServer(async (req: IncomingMessage, res: ServerResponse) => {
393
+ try {
394
+ // Unauthenticated liveness probe so reverse proxies / orchestrators can
395
+ // health-check without needing a credential.
396
+ const url = req.url ?? "/";
397
+ if (req.method === "GET" && (url === "/healthz" || url === "/health")) {
398
+ res.writeHead(200, { "Content-Type": "text/plain" });
399
+ res.end("ok\n");
400
+ return;
401
+ }
402
+
403
+ // Auth gate. In bound mode the bearer also tells us *which* agent the
404
+ // session is bound to; in advisory mode it just gates entry. Constant-
405
+ // time compare isn't worthwhile here — the attacker model for the
406
+ // LAN/personal case is "someone on the same network" who can already
407
+ // observe traffic; TLS termination is the answer to that.
408
+ const resolved = resolveBoundAgent(req.headers.authorization);
409
+ if (!resolved.ok) {
410
+ res.writeHead(401, { "Content-Type": "text/plain", "WWW-Authenticate": "Bearer" });
411
+ res.end("unauthorized\n");
412
+ return;
413
+ }
414
+
415
+ // Session routing. Existing session id → reuse its transport; new client
416
+ // (no id, POST init) → mint a fresh transport+server pair bound to the
417
+ // bearer's agent; anything else is a protocol error.
418
+ const sid = req.headers["mcp-session-id"];
419
+ let transport = typeof sid === "string" ? sessions.get(sid) : undefined;
420
+ if (!transport) {
421
+ if (req.method !== "POST") {
422
+ res.writeHead(400, { "Content-Type": "text/plain" });
423
+ res.end("missing or unknown mcp-session-id\n");
424
+ return;
425
+ }
426
+ transport = await makeSessionTransport(resolved.agent);
427
+ }
428
+ await transport.handleRequest(req, res);
429
+ } catch (err) {
430
+ console.error("[agent-coord-mcp] http request failed:", err);
431
+ if (!res.headersSent) {
432
+ res.writeHead(500, { "Content-Type": "text/plain" });
433
+ res.end("internal error\n");
434
+ }
435
+ }
436
+ });
437
+
438
+ http.listen(port, bindAddr, () => {
439
+ const mode = bound ? `pre-bound (${tokenMap?.size ?? 0} agents)` : "TOFU";
440
+ console.error(`[agent-coord-mcp] http listening on ${bindAddr}:${port} — identity ${mode}`);
441
+ if (bindAddr !== "127.0.0.1" && bindAddr !== "localhost") {
442
+ console.error(
443
+ `[agent-coord-mcp] WARNING: bound to ${bindAddr} without TLS. Front with a TLS reverse proxy ` +
444
+ `(or restrict to a private network e.g. Tailscale/WireGuard) before exposing publicly.`,
445
+ );
446
+ }
447
+ });
195
448
  }
196
449
 
197
450
  main().catch((err) => {
package/src/store.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { promises as fs, existsSync, mkdirSync } from "node:fs";
1
+ import { promises as fs, existsSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
  import lockfile from "proper-lockfile";
@@ -24,6 +24,14 @@ export const ROOMS_DIR = path.join(ROOT, "rooms");
24
24
  export const ROOMS_FILE = path.join(ROOT, "rooms.json");
25
25
  export const DEFAULT_ROOM = "general";
26
26
 
27
+ // Per-agent token map for identity-bound bus auth (v0.7.0). Shape on disk:
28
+ // { "alice": "tk_<random-secret>", "bob": "tk_<another-secret>" }
29
+ // HTTP transport reverse-looks-up the bearer to bind the session to an
30
+ // agentId, then enforces that bound id against every tool call's
31
+ // from/agentId field. Absent → advisory mode (legacy behaviour, with a
32
+ // startup warning). Should be mode 600; operator-managed.
33
+ export const TOKENS_FILE = path.join(ROOT, "tokens.json");
34
+
27
35
  export function ensureDirs(): void {
28
36
  for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR]) {
29
37
  if (!existsSync(d)) mkdirSync(d, { recursive: true });
@@ -33,6 +41,53 @@ export function ensureDirs(): void {
33
41
  }
34
42
  }
35
43
 
44
+ // Synchronous, deliberate. The result feeds the server's bearer→agent
45
+ // reverse-lookup map; we want startup to fail loudly on a malformed file
46
+ // rather than silently degrade to advisory mode. Returns null if the file
47
+ // is absent (operator hasn't configured binding yet).
48
+ export function readTokenMapSync(): Map<string, string> | null {
49
+ if (!existsSync(TOKENS_FILE)) return null;
50
+ const raw = readFileSync(TOKENS_FILE, "utf8");
51
+ let parsed: unknown;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ } catch (e) {
55
+ throw new Error(
56
+ `[agent-coord-mcp] ${TOKENS_FILE} is not valid JSON: ${(e as Error).message}. ` +
57
+ `Fix or remove the file (the bus refuses to start with a broken token map).`,
58
+ );
59
+ }
60
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
61
+ throw new Error(
62
+ `[agent-coord-mcp] ${TOKENS_FILE} must be a JSON object mapping agentId → token.`,
63
+ );
64
+ }
65
+ const out = new Map<string, string>();
66
+ for (const [agentId, token] of Object.entries(parsed as Record<string, unknown>)) {
67
+ if (typeof token !== "string" || token.length === 0) {
68
+ throw new Error(
69
+ `[agent-coord-mcp] ${TOKENS_FILE}: agent "${agentId}" has a non-string/empty token.`,
70
+ );
71
+ }
72
+ out.set(token, agentId);
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // Atomically rotate the token entry for an agent rename (used by
78
+ // rename_agent so the same bearer continues to authenticate the renamed
79
+ // identity). No-op if the file is absent or the old id isn't in the map.
80
+ export async function rotateAgentToken(oldAgentId: string, newAgentId: string): Promise<void> {
81
+ if (!existsSync(TOKENS_FILE)) return;
82
+ await updateJson<Record<string, string>>(TOKENS_FILE, {}, (current) => {
83
+ if (current[oldAgentId] !== undefined) {
84
+ current[newAgentId] = current[oldAgentId];
85
+ delete current[oldAgentId];
86
+ }
87
+ return current;
88
+ });
89
+ }
90
+
36
91
  export type RoomEntry = {
37
92
  topic?: string;
38
93
  motd?: string;