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/README.md +97 -8
- package/dist/server.js +236 -25
- package/dist/server.js.map +1 -1
- package/dist/store.js +50 -1
- package/dist/store.js.map +1 -1
- package/dist/tools.js +424 -6
- package/dist/tools.js.map +1 -1
- package/package.json +4 -3
- package/scripts/coord-pusher.mjs +288 -0
- package/src/server.ts +279 -26
- package/src/store.ts +56 -1
- package/src/tools.ts +444 -5
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 {
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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;
|