@eamonpluto/agentboard 2.2.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.
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agentboard-mcp — zero-dependency stdio MCP server for agent-board (DM-only).
4
+ *
5
+ * One artifact for every MCP-capable harness (Claude Code, Codex, Antigravity,
6
+ * opencode, grok-build): expose the DM bus as tools so sending/reading mail is
7
+ * "just a tool call" with no CLI wrapper needed.
8
+ *
9
+ * Claude: claude mcp add --scope project agentboard -- node ./bin/agentboard-mcp.js
10
+ * Codex: codex mcp add agentboard -- node ./bin/agentboard-mcp.js
11
+ * (or [mcp_servers.agentboard] in config.toml)
12
+ * Antigravity: .agents/mcp_config.json { mcpServers: { agentboard: { command: "node", args: [...] } } }
13
+ * grok: grok mcp add --scope project agentboard -- node ./bin/agentboard-mcp.js
14
+ * opencode: { "mcp": { "agentboard": { "type": "local", "command": ["node", "./bin/agentboard-mcp.js"] } } }
15
+ *
16
+ * Board resolution: AGENTBOARD_DIR env wins, else <cwd>/.agentboard (harnesses
17
+ * launch stdio servers with cwd = project root, so this just works).
18
+ *
19
+ * Protocol: MCP over newline-delimited JSON-RPC on stdio. Methods handled:
20
+ * initialize, notifications/initialized, tools/list, tools/call, ping.
21
+ * Version negotiation: echo the client's protocolVersion when it is a known
22
+ * MCP revision, else fall back to 2024-11-05.
23
+ */
24
+
25
+ import fs from "node:fs";
26
+ import os from "node:os";
27
+ import path from "node:path";
28
+ import crypto from "node:crypto";
29
+ import readline from "node:readline";
30
+
31
+ const MAX_BODY_CHARS = 8000;
32
+ const SERVER_VERSION = "2.1.0";
33
+ const KNOWN_PROTOCOL_VERSIONS = new Set(["2024-11-05", "2025-03-26", "2025-06-18"]);
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // board (mirrors bin/agentboard.js layout v2; no import to stay dep-free)
37
+ // ---------------------------------------------------------------------------
38
+
39
+ function boardRoot() {
40
+ if (process.env.AGENTBOARD_DIR) return path.resolve(process.env.AGENTBOARD_DIR);
41
+ return path.join(process.cwd(), ".agentboard");
42
+ }
43
+
44
+ function dirs(root) {
45
+ return {
46
+ root,
47
+ agents: path.join(root, "agents"),
48
+ dm: path.join(root, "dm"),
49
+ delivered: path.join(root, "delivered"),
50
+ };
51
+ }
52
+
53
+ function ensureBoard(root) {
54
+ const d = dirs(root);
55
+ for (const p of [d.root, d.agents, d.dm, d.delivered]) fs.mkdirSync(p, { recursive: true });
56
+ const meta = path.join(d.root, "board.json");
57
+ if (!fs.existsSync(meta)) {
58
+ fs.writeFileSync(
59
+ meta,
60
+ JSON.stringify({ name: "board", version: 2, createdAt: new Date().toISOString() }, null, 2) + "\n"
61
+ );
62
+ }
63
+ return d;
64
+ }
65
+
66
+ function readJson(p) {
67
+ return JSON.parse(fs.readFileSync(p, "utf8"));
68
+ }
69
+
70
+ function writeJson(p, obj) {
71
+ const tmp = p + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
72
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
73
+ fs.renameSync(tmp, p);
74
+ }
75
+
76
+ function cleanName(name, what) {
77
+ if (name === undefined || name === null || String(name).trim() === "")
78
+ throw new Error(`missing ${what}`);
79
+ const c = String(name).trim().replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 40);
80
+ if (!c) throw new Error(`invalid ${what}`);
81
+ return c;
82
+ }
83
+
84
+ function newId(prefix) {
85
+ const t = new Date();
86
+ const stamp =
87
+ t.getUTCFullYear().toString().slice(2) +
88
+ String(t.getUTCMonth() + 1).padStart(2, "0") +
89
+ String(t.getUTCDate()).padStart(2, "0") +
90
+ "-" +
91
+ String(t.getUTCHours()).padStart(2, "0") +
92
+ String(t.getUTCMinutes()).padStart(2, "0") +
93
+ String(t.getUTCSeconds()).padStart(2, "0");
94
+ return `${prefix}-${stamp}-${crypto.randomBytes(3).toString("hex")}`;
95
+ }
96
+
97
+ function listDMs(d, recipient) {
98
+ const dir = path.join(d.dm, recipient);
99
+ if (!fs.existsSync(dir)) return [];
100
+ return fs
101
+ .readdirSync(dir)
102
+ .filter((f) => f.endsWith(".json"))
103
+ .sort()
104
+ .map((f) => {
105
+ try {
106
+ return readJson(path.join(dir, f));
107
+ } catch {
108
+ return null;
109
+ }
110
+ })
111
+ .filter((x) => x && x.id && x.from)
112
+ .sort((a, b) => String(a.at).localeCompare(String(b.at)) || String(a.id).localeCompare(String(b.id)));
113
+ }
114
+
115
+ function touchAgent(d, name, sessionId) {
116
+ const p = path.join(d.agents, `${name}.json`);
117
+ const now = new Date().toISOString();
118
+ let prev = null;
119
+ try {
120
+ prev = readJson(p);
121
+ } catch {}
122
+ writeJson(p, {
123
+ name,
124
+ firstSeen: (prev && prev.firstSeen) || now,
125
+ lastSeen: now,
126
+ sessionId: sessionId || (prev && prev.sessionId) || undefined,
127
+ lastDir: process.cwd(),
128
+ });
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // tools
133
+ // ---------------------------------------------------------------------------
134
+
135
+ const TOOLS = [
136
+ {
137
+ name: "dm_send",
138
+ description:
139
+ "Send a direct message to another AI agent via agent-board. Fire-and-forget like Slack: the peer reads it via dm_inbox (or gets it pushed by their harness hook). Use whenever you want to coordinate, share a finding, or ask a peer.",
140
+ inputSchema: {
141
+ type: "object",
142
+ properties: {
143
+ from: { type: "string", description: "Your stable agent name, e.g. alice. Keep it constant for the session." },
144
+ to: { type: "string", description: "Recipient agent name, e.g. bob." },
145
+ body: { type: "string", description: "Message text, 1..8000 chars." },
146
+ },
147
+ required: ["from", "to", "body"],
148
+ },
149
+ },
150
+ {
151
+ name: "dm_inbox",
152
+ description:
153
+ "Read your direct messages on agent-board. No mark-read side effects; page with after. Poll this often when your harness has no push hook.",
154
+ inputSchema: {
155
+ type: "object",
156
+ properties: {
157
+ agent: { type: "string", description: "Your agent name." },
158
+ limit: { type: "number", description: "Max messages, newest last. Default 20." },
159
+ after: { type: "string", description: "Only messages after this message id." },
160
+ },
161
+ required: ["agent"],
162
+ },
163
+ },
164
+ {
165
+ name: "dm_agents",
166
+ description: "List agents known to this agent-board (registered names for addressing DMs).",
167
+ inputSchema: { type: "object", properties: {} },
168
+ },
169
+ {
170
+ name: "dm_register",
171
+ description:
172
+ "Register your agent name on this agent-board (optionally binding a harness session id for push routing). Do this once per session.",
173
+ inputSchema: {
174
+ type: "object",
175
+ properties: {
176
+ agent: { type: "string", description: "Your stable agent name." },
177
+ session: { type: "string", description: "Optional harness session/conversation id for push routing." },
178
+ },
179
+ required: ["agent"],
180
+ },
181
+ },
182
+ ];
183
+
184
+ function toolResult(text) {
185
+ return { content: [{ type: "text", text }] };
186
+ }
187
+
188
+ function callTool(name, args) {
189
+ const d = ensureBoard(boardRoot());
190
+ const a = args && typeof args === "object" ? args : {};
191
+ switch (name) {
192
+ case "dm_send": {
193
+ const from = cleanName(a.from, "from");
194
+ const to = cleanName(a.to, "to");
195
+ const body = String(a.body ?? "").trim();
196
+ if (!body) throw new Error("empty body");
197
+ if (body.length > MAX_BODY_CHARS) throw new Error(`body too large (max ${MAX_BODY_CHARS} chars)`);
198
+ touchAgent(d, from);
199
+ const id = newId("msg");
200
+ const msg = { id, from, to, body, at: new Date().toISOString() };
201
+ fs.mkdirSync(path.join(d.dm, to), { recursive: true });
202
+ writeJson(path.join(d.dm, to, `${id}.json`), msg);
203
+ return toolResult(`sent ${id} -> ${to}`);
204
+ }
205
+ case "dm_inbox": {
206
+ const agent = cleanName(a.agent, "agent");
207
+ let items = listDMs(d, agent);
208
+ if (a.after !== undefined && a.after !== null && String(a.after) !== "") {
209
+ const idx = items.findIndex((m) => m.id === String(a.after));
210
+ if (idx !== -1) items = items.slice(idx + 1);
211
+ }
212
+ const limit = a.limit === undefined || a.limit === null ? 20 : Number(a.limit);
213
+ if (!(limit >= 0)) throw new Error("limit must be a non-negative number");
214
+ items = items.slice(-limit);
215
+ if (items.length === 0) return toolResult(`no messages for ${agent}`);
216
+ return toolResult(items.map((m) => `[${m.id}] from ${m.from} @ ${m.at}\n${m.body}`).join("\n\n"));
217
+ }
218
+ case "dm_agents": {
219
+ const dir = d.agents;
220
+ if (!fs.existsSync(dir)) return toolResult("no agents registered");
221
+ const names = fs
222
+ .readdirSync(dir)
223
+ .filter((f) => f.endsWith(".json"))
224
+ .map((f) => {
225
+ try {
226
+ return readJson(path.join(dir, f)).name;
227
+ } catch {
228
+ return null;
229
+ }
230
+ })
231
+ .filter(Boolean)
232
+ .sort();
233
+ if (names.length === 0) return toolResult("no agents registered (dm_register -- your name)");
234
+ return toolResult(names.join("\n"));
235
+ }
236
+ case "dm_register": {
237
+ const agent = cleanName(a.agent, "agent");
238
+ const session = a.session === undefined || a.session === null ? undefined : String(a.session);
239
+ touchAgent(d, agent, session || undefined);
240
+ return toolResult(`registered ${agent}${session ? ` (session ${session})` : ""}`);
241
+ }
242
+ default:
243
+ throw new Error(`unknown tool "${name}"`);
244
+ }
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // JSON-RPC over stdio (newline-delimited)
249
+ // ---------------------------------------------------------------------------
250
+
251
+ function reply(id, result) {
252
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
253
+ }
254
+
255
+ function replyError(id, code, message) {
256
+ const payload = { jsonrpc: "2.0", id, error: { code, message } };
257
+ process.stdout.write(JSON.stringify(payload) + "\n");
258
+ }
259
+
260
+ function handleMessage(msg) {
261
+ if (!msg || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") return;
262
+ const id = msg.id;
263
+ try {
264
+ switch (msg.method) {
265
+ case "initialize": {
266
+ const asked = msg.params && msg.params.protocolVersion;
267
+ const version = KNOWN_PROTOCOL_VERSIONS.has(asked) ? asked : "2024-11-05";
268
+ if (id === undefined) return;
269
+ reply(id, {
270
+ protocolVersion: version,
271
+ capabilities: { tools: {} },
272
+ serverInfo: { name: "agentboard", version: SERVER_VERSION },
273
+ });
274
+ return;
275
+ }
276
+ case "ping": {
277
+ if (id === undefined) return;
278
+ reply(id, {});
279
+ return;
280
+ }
281
+ case "tools/list": {
282
+ if (id === undefined) return;
283
+ reply(id, { tools: TOOLS });
284
+ return;
285
+ }
286
+ case "tools/call": {
287
+ if (id === undefined) return;
288
+ const p = msg.params || {};
289
+ try {
290
+ reply(id, callTool(p.name, p.arguments));
291
+ } catch (e) {
292
+ reply(id, { content: [{ type: "text", text: `error: ${e && e.message ? e.message : String(e)}` }], isError: true });
293
+ }
294
+ return;
295
+ }
296
+ default: {
297
+ // notifications (no id) are acked by silence; unknown requests get an error
298
+ if (id === undefined) return;
299
+ replyError(id, -32601, `method not found: ${msg.method}`);
300
+ }
301
+ }
302
+ } catch (e) {
303
+ if (id !== undefined) replyError(id, -32603, String((e && e.message) || e));
304
+ }
305
+ }
306
+
307
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
308
+ rl.on("line", (line) => {
309
+ if (!line.trim()) return;
310
+ let msg = null;
311
+ try {
312
+ msg = JSON.parse(line);
313
+ } catch {
314
+ return; // ignore malformed lines on stdio
315
+ }
316
+ handleMessage(msg);
317
+ });