bosun 0.36.2 → 0.36.3

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,579 @@
1
+ /**
2
+ * mcp-registry.mjs — Centralized MCP Server Registry & Management
3
+ *
4
+ * Provides a curated catalog of popular MCP servers that can be installed
5
+ * with one click, plus helpers to resolve installed servers for injection
6
+ * into agent launches (Codex config.toml, Copilot CLI args, Claude env).
7
+ *
8
+ * Storage: installed servers are persisted as `type: "mcp"` entries in the
9
+ * existing library system (`.bosun/mcp-servers/*.json` + `library.json`).
10
+ *
11
+ * EXPORTS:
12
+ * CURATED_MCP_CATALOG — frozen array of known-good MCP server defs
13
+ * listCatalog() — read-only catalog query
14
+ * getCatalogEntry(id) — single catalog entry by ID
15
+ * installMcpServer() — one-click install from catalog or custom def
16
+ * uninstallMcpServer() — remove an installed server
17
+ * listInstalledMcpServers() — all installed MCP servers
18
+ * getInstalledMcpServer() — single installed server by ID
19
+ * resolveMcpServersForAgent() — resolve IDs to full configs for SDK injection
20
+ * buildCodexMcpToml() — convert configs → Codex TOML blocks
21
+ * buildCopilotMcpJson() — convert configs → Copilot MCP JSON
22
+ * buildClaudeMcpEnv() — convert configs → Claude MCP env format
23
+ */
24
+
25
+ import { resolve, join } from "node:path";
26
+ import { existsSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
27
+ import { randomUUID } from "node:crypto";
28
+
29
+ // Lazy-import library manager to avoid circular dependency at module load.
30
+ // Cached at module scope per AGENTS.md hard rules.
31
+ let _libManager = null;
32
+ async function getLibManager() {
33
+ if (!_libManager) {
34
+ _libManager = await import("./library-manager.mjs");
35
+ }
36
+ return _libManager;
37
+ }
38
+
39
+ // ── Constants ─────────────────────────────────────────────────────────────────
40
+
41
+ const TAG = "[mcp-registry]";
42
+
43
+ /**
44
+ * Curated catalog of popular, reliable MCP servers.
45
+ * Each entry describes how to launch or connect to the server.
46
+ *
47
+ * Transport types:
48
+ * "stdio" — launch via command+args, communicate over stdin/stdout
49
+ * "url" — connect to a remote HTTP/SSE endpoint
50
+ */
51
+ export const CURATED_MCP_CATALOG = Object.freeze([
52
+ // ── Official / Microsoft / GitHub ────────────────────────────────────────
53
+ {
54
+ id: "github",
55
+ name: "GitHub",
56
+ description: "GitHub API — repositories, issues, PRs, code search, actions",
57
+ transport: "stdio",
58
+ command: "npx",
59
+ args: ["-y", "@anthropic/mcp-github"],
60
+ env: { GITHUB_PERSONAL_ACCESS_TOKEN: "" },
61
+ tags: ["github", "vcs", "official"],
62
+ source: "catalog",
63
+ homepage: "https://github.com/anthropics/github-mcp-server",
64
+ },
65
+ {
66
+ id: "microsoft-docs",
67
+ name: "Microsoft Docs",
68
+ description: "Microsoft Learn documentation search and retrieval",
69
+ transport: "url",
70
+ url: "https://learn.microsoft.com/api/mcp",
71
+ tags: ["docs", "microsoft", "official"],
72
+ source: "catalog",
73
+ homepage: "https://learn.microsoft.com",
74
+ },
75
+ {
76
+ id: "context7",
77
+ name: "Context7",
78
+ description: "Up-to-date library documentation and code examples",
79
+ transport: "stdio",
80
+ command: "npx",
81
+ args: ["-y", "@upstash/context7-mcp"],
82
+ tags: ["docs", "libraries", "community"],
83
+ source: "catalog",
84
+ homepage: "https://github.com/nicepkg/context7",
85
+ },
86
+ // ── Official MCP Reference Servers ───────────────────────────────────────
87
+ {
88
+ id: "sequential-thinking",
89
+ name: "Sequential Thinking",
90
+ description: "Dynamic, reflective problem-solving through structured thought sequences",
91
+ transport: "stdio",
92
+ command: "npx",
93
+ args: ["-y", "@modelcontextprotocol/server-sequential-thinking"],
94
+ tags: ["reasoning", "official"],
95
+ source: "catalog",
96
+ homepage: "https://github.com/modelcontextprotocol/servers",
97
+ },
98
+ {
99
+ id: "filesystem",
100
+ name: "Filesystem",
101
+ description: "Secure file system operations with configurable access controls",
102
+ transport: "stdio",
103
+ command: "npx",
104
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
105
+ tags: ["filesystem", "official"],
106
+ source: "catalog",
107
+ homepage: "https://github.com/modelcontextprotocol/servers",
108
+ },
109
+ {
110
+ id: "fetch",
111
+ name: "Fetch",
112
+ description: "Web content fetching and conversion for efficient LLM usage",
113
+ transport: "stdio",
114
+ command: "npx",
115
+ args: ["-y", "@modelcontextprotocol/server-fetch"],
116
+ tags: ["web", "http", "official"],
117
+ source: "catalog",
118
+ homepage: "https://github.com/modelcontextprotocol/servers",
119
+ },
120
+ {
121
+ id: "memory",
122
+ name: "Memory",
123
+ description: "Knowledge graph-based persistent memory for entities and relations",
124
+ transport: "stdio",
125
+ command: "npx",
126
+ args: ["-y", "@modelcontextprotocol/server-memory"],
127
+ tags: ["memory", "knowledge-graph", "official"],
128
+ source: "catalog",
129
+ homepage: "https://github.com/modelcontextprotocol/servers",
130
+ },
131
+ {
132
+ id: "postgres",
133
+ name: "PostgreSQL",
134
+ description: "Read-only PostgreSQL database access with schema inspection",
135
+ transport: "stdio",
136
+ command: "npx",
137
+ args: ["-y", "@modelcontextprotocol/server-postgres"],
138
+ env: { POSTGRES_CONNECTION_STRING: "" },
139
+ tags: ["database", "postgres", "official"],
140
+ source: "catalog",
141
+ homepage: "https://github.com/modelcontextprotocol/servers",
142
+ },
143
+ // ── Browser / UI Testing ────────────────────────────────────────────────
144
+ {
145
+ id: "playwright",
146
+ name: "Playwright",
147
+ description: "Browser automation — navigate, interact, screenshot, test web apps",
148
+ transport: "stdio",
149
+ command: "npx",
150
+ args: ["-y", "@playwright/mcp@latest"],
151
+ tags: ["browser", "testing", "automation", "official"],
152
+ source: "catalog",
153
+ homepage: "https://github.com/nicholasrq/playwright-mcp",
154
+ },
155
+ {
156
+ id: "puppeteer",
157
+ name: "Puppeteer",
158
+ description: "Chrome automation via Puppeteer for browser control and screenshots",
159
+ transport: "stdio",
160
+ command: "npx",
161
+ args: ["-y", "@anthropic/mcp-puppeteer"],
162
+ tags: ["browser", "testing", "screenshot"],
163
+ source: "catalog",
164
+ homepage: "https://github.com/anthropics/puppeteer-mcp-server",
165
+ },
166
+ // ── Search / Knowledge ──────────────────────────────────────────────────
167
+ {
168
+ id: "brave-search",
169
+ name: "Brave Search",
170
+ description: "Web and local search using the Brave Search API",
171
+ transport: "stdio",
172
+ command: "npx",
173
+ args: ["-y", "@anthropic/mcp-brave-search"],
174
+ env: { BRAVE_API_KEY: "" },
175
+ tags: ["search", "web"],
176
+ source: "catalog",
177
+ homepage: "https://github.com/anthropics/brave-search-mcp-server",
178
+ },
179
+ {
180
+ id: "exa",
181
+ name: "Exa Search",
182
+ description: "Neural search engine for precise, up-to-date web results",
183
+ transport: "stdio",
184
+ command: "npx",
185
+ args: ["-y", "exa-mcp-server"],
186
+ env: { EXA_API_KEY: "" },
187
+ tags: ["search", "web", "ai"],
188
+ source: "catalog",
189
+ homepage: "https://github.com/nicepkg/exa-mcp-server",
190
+ },
191
+ // ── Productivity / Project Management ───────────────────────────────────
192
+ {
193
+ id: "linear",
194
+ name: "Linear",
195
+ description: "Linear project management — issues, projects, teams",
196
+ transport: "stdio",
197
+ command: "npx",
198
+ args: ["-y", "mcp-linear"],
199
+ env: { LINEAR_API_KEY: "" },
200
+ tags: ["project-management", "issues"],
201
+ source: "catalog",
202
+ homepage: "https://github.com/nicepkg/mcp-linear",
203
+ },
204
+ {
205
+ id: "slack",
206
+ name: "Slack",
207
+ description: "Slack workspace messaging, channels, and search",
208
+ transport: "stdio",
209
+ command: "npx",
210
+ args: ["-y", "@anthropic/mcp-slack"],
211
+ env: { SLACK_BOT_TOKEN: "", SLACK_TEAM_ID: "" },
212
+ tags: ["messaging", "slack"],
213
+ source: "catalog",
214
+ homepage: "https://github.com/anthropics/slack-mcp-server",
215
+ },
216
+ // ── Storage / Cloud ─────────────────────────────────────────────────────
217
+ {
218
+ id: "google-drive",
219
+ name: "Google Drive",
220
+ description: "Google Drive file search and retrieval",
221
+ transport: "stdio",
222
+ command: "npx",
223
+ args: ["-y", "@anthropic/mcp-google-drive"],
224
+ tags: ["cloud", "storage", "google"],
225
+ source: "catalog",
226
+ homepage: "https://github.com/anthropics/google-drive-mcp-server",
227
+ },
228
+ // ── Vibe Kanban (Bosun built-in) ────────────────────────────────────────
229
+ {
230
+ id: "vibe-kanban",
231
+ name: "Vibe Kanban",
232
+ description: "Bosun's integrated task and project management MCP server",
233
+ transport: "stdio",
234
+ command: "npx",
235
+ args: ["-y", "vibe-kanban@latest"],
236
+ env: { VK_BASE_URL: "http://127.0.0.1:54089" },
237
+ tags: ["kanban", "tasks", "bosun"],
238
+ source: "catalog",
239
+ homepage: "https://github.com/nicepkg/vibe-kanban",
240
+ },
241
+ ]);
242
+
243
+ // ── Catalog Queries ───────────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * List all catalog entries, optionally filtered by tags.
247
+ * @param {{ tags?: string[] }} [options]
248
+ * @returns {ReadonlyArray<Object>}
249
+ */
250
+ export function listCatalog({ tags } = {}) {
251
+ if (!tags || !tags.length) return CURATED_MCP_CATALOG;
252
+ const tagSet = new Set(tags.map((t) => t.toLowerCase()));
253
+ return CURATED_MCP_CATALOG.filter((entry) =>
254
+ entry.tags.some((t) => tagSet.has(t.toLowerCase())),
255
+ );
256
+ }
257
+
258
+ /**
259
+ * Get a single catalog entry by ID.
260
+ * @param {string} id
261
+ * @returns {Object|null}
262
+ */
263
+ export function getCatalogEntry(id) {
264
+ const normalized = String(id || "").trim().toLowerCase();
265
+ return CURATED_MCP_CATALOG.find((e) => e.id === normalized) || null;
266
+ }
267
+
268
+ // ── Install / Uninstall ───────────────────────────────────────────────────────
269
+
270
+ /**
271
+ * Install an MCP server from the catalog or a custom definition.
272
+ *
273
+ * Catalog install:
274
+ * installMcpServer(rootDir, "github")
275
+ *
276
+ * Custom install:
277
+ * installMcpServer(rootDir, { id: "my-server", name: "My Server", ... })
278
+ *
279
+ * @param {string} rootDir — workspace root
280
+ * @param {string|Object} catalogIdOrDef — catalog ID string or full server def
281
+ * @param {{ envOverrides?: Object }} [options]
282
+ * @returns {Promise<Object>} — the created library entry
283
+ */
284
+ export async function installMcpServer(rootDir, catalogIdOrDef, { envOverrides } = {}) {
285
+ const lib = await getLibManager();
286
+
287
+ let serverDef;
288
+ if (typeof catalogIdOrDef === "string") {
289
+ const catalogEntry = getCatalogEntry(catalogIdOrDef);
290
+ if (!catalogEntry) {
291
+ throw new Error(`${TAG} MCP server "${catalogIdOrDef}" not found in catalog. Use listCatalog() to see available servers.`);
292
+ }
293
+ // Clone catalog entry so we don't mutate the frozen original
294
+ serverDef = { ...catalogEntry };
295
+ } else if (catalogIdOrDef && typeof catalogIdOrDef === "object") {
296
+ serverDef = { ...catalogIdOrDef };
297
+ if (!serverDef.id) serverDef.id = serverDef.name ? slugify(serverDef.name) : `custom-${randomUUID().slice(0, 8)}`;
298
+ if (!serverDef.name) serverDef.name = serverDef.id;
299
+ serverDef.source = serverDef.source || "custom";
300
+ } else {
301
+ throw new Error(`${TAG} installMcpServer requires a catalog ID string or server definition object`);
302
+ }
303
+
304
+ // Apply environment overrides
305
+ if (envOverrides && typeof envOverrides === "object") {
306
+ serverDef.env = { ...(serverDef.env || {}), ...envOverrides };
307
+ }
308
+
309
+ // Persist via library manager as type "mcp"
310
+ const entry = lib.upsertEntry(rootDir, {
311
+ type: "mcp",
312
+ id: serverDef.id,
313
+ name: serverDef.name,
314
+ description: serverDef.description || "",
315
+ tags: serverDef.tags || [],
316
+ meta: {
317
+ transport: serverDef.transport || "stdio",
318
+ command: serverDef.command || null,
319
+ args: serverDef.args || [],
320
+ url: serverDef.url || null,
321
+ env: serverDef.env || {},
322
+ source: serverDef.source || "catalog",
323
+ homepage: serverDef.homepage || null,
324
+ },
325
+ }, serverDef);
326
+
327
+ console.log(`${TAG} Installed MCP server: ${entry.id} (${serverDef.transport})`);
328
+ return entry;
329
+ }
330
+
331
+ /**
332
+ * Uninstall an MCP server by ID.
333
+ * @param {string} rootDir
334
+ * @param {string} id
335
+ * @returns {Promise<boolean>}
336
+ */
337
+ export async function uninstallMcpServer(rootDir, id) {
338
+ const lib = await getLibManager();
339
+ const removed = lib.deleteEntry(rootDir, id, { deleteFile: true });
340
+ if (removed) {
341
+ console.log(`${TAG} Uninstalled MCP server: ${id}`);
342
+ }
343
+ return removed;
344
+ }
345
+
346
+ /**
347
+ * List all installed MCP servers.
348
+ * @param {string} rootDir
349
+ * @returns {Promise<Array<Object>>}
350
+ */
351
+ export async function listInstalledMcpServers(rootDir) {
352
+ const lib = await getLibManager();
353
+ return lib.listEntries(rootDir, { type: "mcp" });
354
+ }
355
+
356
+ /**
357
+ * Get a single installed MCP server by ID.
358
+ * @param {string} rootDir
359
+ * @param {string} id
360
+ * @returns {Promise<Object|null>}
361
+ */
362
+ export async function getInstalledMcpServer(rootDir, id) {
363
+ const lib = await getLibManager();
364
+ const entry = lib.getEntry(rootDir, id);
365
+ if (!entry || entry.type !== "mcp") return null;
366
+ // Merge entry metadata with file content to get full config
367
+ const content = lib.getEntryContent(rootDir, entry);
368
+ return { ...entry, serverConfig: content };
369
+ }
370
+
371
+ // ── Agent Launch Resolution ───────────────────────────────────────────────────
372
+
373
+ /**
374
+ * Resolve an array of MCP server IDs into full server configurations
375
+ * ready for injection into SDK adapters.
376
+ *
377
+ * @param {string} rootDir
378
+ * @param {string[]} mcpServerIds — array of installed server IDs
379
+ * @param {{ defaultServers?: string[], catalogOverrides?: Object }} [options]
380
+ * @returns {Promise<Array<Object>>} — resolved server configs
381
+ */
382
+ export async function resolveMcpServersForAgent(rootDir, mcpServerIds = [], options = {}) {
383
+ const { defaultServers = [], catalogOverrides = {} } = options;
384
+
385
+ // Merge requested IDs with defaults (deduplicate)
386
+ const allIds = [...new Set([...defaultServers, ...mcpServerIds])];
387
+ if (!allIds.length) return [];
388
+
389
+ const lib = await getLibManager();
390
+ const resolved = [];
391
+
392
+ for (const id of allIds) {
393
+ const entry = lib.getEntry(rootDir, id);
394
+ if (entry && entry.type === "mcp") {
395
+ const content = lib.getEntryContent(rootDir, entry);
396
+ const config = content || entry.meta || {};
397
+ // Apply per-server catalog overrides (e.g. env vars)
398
+ if (catalogOverrides[id]) {
399
+ config.env = { ...(config.env || {}), ...catalogOverrides[id] };
400
+ }
401
+ resolved.push({
402
+ id: entry.id,
403
+ name: entry.name,
404
+ transport: config.transport || entry.meta?.transport || "stdio",
405
+ command: config.command || entry.meta?.command || null,
406
+ args: config.args || entry.meta?.args || [],
407
+ url: config.url || entry.meta?.url || null,
408
+ env: config.env || entry.meta?.env || {},
409
+ });
410
+ } else {
411
+ // Check catalog as fallback (auto-install from catalog)
412
+ const catalogEntry = getCatalogEntry(id);
413
+ if (catalogEntry) {
414
+ const config = { ...catalogEntry };
415
+ if (catalogOverrides[id]) {
416
+ config.env = { ...(config.env || {}), ...catalogOverrides[id] };
417
+ }
418
+ resolved.push({
419
+ id: config.id,
420
+ name: config.name,
421
+ transport: config.transport,
422
+ command: config.command || null,
423
+ args: config.args || [],
424
+ url: config.url || null,
425
+ env: config.env || {},
426
+ });
427
+ } else {
428
+ console.warn(`${TAG} MCP server "${id}" not found (installed or catalog), skipping`);
429
+ }
430
+ }
431
+ }
432
+
433
+ return resolved;
434
+ }
435
+
436
+ // ── SDK-Specific Format Builders ──────────────────────────────────────────────
437
+
438
+ /**
439
+ * Convert resolved MCP server configs into Codex CLI config.toml format.
440
+ *
441
+ * Example output:
442
+ * [mcp_servers.github]
443
+ * command = "npx"
444
+ * args = ["-y", "@anthropic/mcp-github"]
445
+ * [mcp_servers.github.env]
446
+ * GITHUB_PERSONAL_ACCESS_TOKEN = "..."
447
+ *
448
+ * @param {Array<Object>} servers — resolved server configs
449
+ * @returns {string} — TOML string
450
+ */
451
+ export function buildCodexMcpToml(servers) {
452
+ if (!servers || !servers.length) return "";
453
+
454
+ const lines = [];
455
+ for (const srv of servers) {
456
+ const safeId = String(srv.id).replace(/[^a-zA-Z0-9_-]/g, "_");
457
+
458
+ if (srv.transport === "url" && srv.url) {
459
+ lines.push("");
460
+ lines.push(`[mcp_servers.${safeId}]`);
461
+ lines.push(`url = ${toTomlString(srv.url)}`);
462
+ } else if (srv.command) {
463
+ lines.push("");
464
+ lines.push(`[mcp_servers.${safeId}]`);
465
+ lines.push(`command = ${toTomlString(srv.command)}`);
466
+ if (srv.args && srv.args.length) {
467
+ const argsStr = srv.args.map((a) => toTomlString(a)).join(", ");
468
+ lines.push(`args = [${argsStr}]`);
469
+ }
470
+ } else {
471
+ continue; // Skip servers without command or url
472
+ }
473
+
474
+ // Write env block if present (skip empty-value keys)
475
+ const envEntries = Object.entries(srv.env || {}).filter(
476
+ ([, v]) => v != null && String(v).trim() !== "",
477
+ );
478
+ if (envEntries.length) {
479
+ lines.push(`[mcp_servers.${safeId}.env]`);
480
+ for (const [key, value] of envEntries) {
481
+ lines.push(`${key} = ${toTomlString(String(value))}`);
482
+ }
483
+ }
484
+ }
485
+
486
+ return lines.join("\n");
487
+ }
488
+
489
+ /**
490
+ * Convert resolved MCP server configs into Copilot SDK MCP JSON config.
491
+ * Written as a temp file and passed via --additional-mcp-config.
492
+ *
493
+ * @param {Array<Object>} servers — resolved server configs
494
+ * @returns {Object} — { mcpServers: { [id]: { command, args, env? } | { url } } }
495
+ */
496
+ export function buildCopilotMcpJson(servers) {
497
+ if (!servers || !servers.length) return { mcpServers: {} };
498
+
499
+ const mcpServers = {};
500
+ for (const srv of servers) {
501
+ if (srv.transport === "url" && srv.url) {
502
+ mcpServers[srv.id] = { url: srv.url };
503
+ } else if (srv.command) {
504
+ const entry = { command: srv.command };
505
+ if (srv.args && srv.args.length) entry.args = srv.args;
506
+ const envEntries = Object.entries(srv.env || {}).filter(
507
+ ([, v]) => v != null && String(v).trim() !== "",
508
+ );
509
+ if (envEntries.length) entry.env = Object.fromEntries(envEntries);
510
+ mcpServers[srv.id] = entry;
511
+ }
512
+ }
513
+
514
+ return { mcpServers };
515
+ }
516
+
517
+ /**
518
+ * Convert resolved MCP server configs into Claude Code MCP environment format.
519
+ * Claude Code uses CLAUDE_MCP_SERVERS env var (JSON) or .claude/mcp_servers.json.
520
+ *
521
+ * @param {Array<Object>} servers — resolved server configs
522
+ * @returns {{ envVar: string, fileContent: Object }} — env var value + file format
523
+ */
524
+ export function buildClaudeMcpEnv(servers) {
525
+ if (!servers || !servers.length) {
526
+ return { envVar: "", fileContent: { mcpServers: {} } };
527
+ }
528
+
529
+ const mcpServers = {};
530
+ for (const srv of servers) {
531
+ if (srv.transport === "url" && srv.url) {
532
+ mcpServers[srv.id] = { type: "url", url: srv.url };
533
+ } else if (srv.command) {
534
+ const entry = { type: "stdio", command: srv.command };
535
+ if (srv.args && srv.args.length) entry.args = srv.args;
536
+ const envEntries = Object.entries(srv.env || {}).filter(
537
+ ([, v]) => v != null && String(v).trim() !== "",
538
+ );
539
+ if (envEntries.length) entry.env = Object.fromEntries(envEntries);
540
+ mcpServers[srv.id] = entry;
541
+ }
542
+ }
543
+
544
+ return {
545
+ envVar: JSON.stringify({ mcpServers }),
546
+ fileContent: { mcpServers },
547
+ };
548
+ }
549
+
550
+ /**
551
+ * Write a temporary MCP config file for Copilot --additional-mcp-config.
552
+ * Returns the path to the temp file.
553
+ *
554
+ * @param {string} rootDir
555
+ * @param {Array<Object>} servers
556
+ * @returns {string} — path to temp MCP config JSON
557
+ */
558
+ export function writeTempCopilotMcpConfig(rootDir, servers) {
559
+ const mcpConfig = buildCopilotMcpJson(servers);
560
+ const dir = resolve(rootDir, ".bosun", ".tmp");
561
+ mkdirSync(dir, { recursive: true });
562
+ const filePath = resolve(dir, `mcp-config-${Date.now()}.json`);
563
+ writeFileSync(filePath, JSON.stringify(mcpConfig, null, 2), "utf8");
564
+ return filePath;
565
+ }
566
+
567
+ // ── Helpers ───────────────────────────────────────────────────────────────────
568
+
569
+ function toTomlString(value) {
570
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
571
+ }
572
+
573
+ function slugify(name) {
574
+ return String(name || "")
575
+ .toLowerCase()
576
+ .trim()
577
+ .replace(/[^a-z0-9]+/g, "-")
578
+ .replace(/^-|-$/g, "");
579
+ }