From f5d5d76553085e3f0c16796023a7659993254044 Mon Sep 17 00:00:00 2001 From: syf2211 Date: Tue, 21 Jul 2026 12:08:17 +0000 Subject: [PATCH] feat(mcp): import servers from .mcp.json, .claude.json, .cursor/mcp.json Fixes #36 - Add external MCP config import with precedence merge - Expand ${VAR}, ${env:VAR}, ${workspaceFolder}, ${userHome} - Gate behind compat.importMcpConfigs (default on) - Show source in /mcp; fix disable for non-home configs - Add fixture-based unit tests --- README.md | 4 +- src/auth/credentials.ts | 2 + src/mcp/client.ts | 64 +++++++----- src/mcp/import.test.ts | 188 +++++++++++++++++++++++++++++++++++ src/mcp/import.ts | 215 ++++++++++++++++++++++++++++++++++++++++ src/screens/repl.ts | 24 ++++- 6 files changed, 466 insertions(+), 31 deletions(-) create mode 100644 src/mcp/import.test.ts create mode 100644 src/mcp/import.ts diff --git a/README.md b/README.md index bf341ac..6928c3d 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,7 @@ Full MCP client, both transports: - **stdio** — local servers, configured in `.klaatai/mcp.json`, built-in presets (filesystem, GitHub, Postgres, Puppeteer, Brave Search, Fetch, …) - **Streamable HTTP** — remote servers via `"url"` config or `/mcp add `; SSE and JSON responses, session management, and **OAuth 2.1** (discovery + dynamic client registration + PKCE browser flow) when the server requires auth — tokens cached in `~/.klaatai/mcp-oauth.json` -Manage live with `/mcp`. +Manage live with `/mcp`. On startup, KlaatCode can also **import MCP servers** from existing tool configs in the project (`.mcp.json`, `.claude.json`, `.cursor/mcp.json`) — native `.klaatai/mcp.json` always wins on name collisions. Disable with `"compat": { "importMcpConfigs": false }` in `config.json`. ### Git Integration @@ -457,6 +457,8 @@ klaatcode serve --port 4200 | `attentionOrder` | `on` / `off` | Arrange old history so the most relevant turns sit where models attend | | `maxSessionCost` | USD number | Hard session cost cap — pauses agent rounds when reached | | `phaseBudgets` | `on` / `off` | Per-phase token budgets; pause a stuck explore phase before it burns the budget | +| `compat.importClaudeSkills` | `true` / `false` | Discover skills from Claude Code directories (default: on) | +| `compat.importMcpConfigs` | `true` / `false` | Import MCP servers from `.mcp.json`, `.claude.json`, `.cursor/mcp.json` (default: on) | Full reference, incl. every config key: [klaatai.com/docs/configuration](https://klaatai.com/docs/configuration). diff --git a/src/auth/credentials.ts b/src/auth/credentials.ts index 6231c01..5af4cf8 100644 --- a/src/auth/credentials.ts +++ b/src/auth/credentials.ts @@ -67,6 +67,8 @@ export interface Config { compat?: { /** Discover skills from ~/.claude/skills and .claude/skills (default: true). */ importClaudeSkills?: boolean; + /** Import MCP servers from .mcp.json / .claude.json / .cursor/mcp.json (default: true). */ + importMcpConfigs?: boolean; }; } diff --git a/src/mcp/client.ts b/src/mcp/client.ts index ff57a94..02d19b6 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -34,11 +34,15 @@ */ import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import type { ToolDefinition } from "../api/client.js"; import { storedMcpToken, refreshMcpToken, authorizeMcpServer } from "./oauth.js"; +import { + loadImportedMcpServers, + mergeNativeMcpConfig, + type MCPLoadOptions, +} from "./import.js"; // ─── Config ─────────────────────────────────────────────────────────────────── @@ -57,46 +61,48 @@ export interface MCPServerConfig { description?: string; } +/** MCP server config plus optional source label (for imported servers). */ +export interface MCPServerEntry extends MCPServerConfig { + /** Config file the server was loaded from, e.g. ".cursor/mcp.json". */ + source?: string; +} + export interface MCPConfig { - servers: Record; + servers: Record; } +export type { MCPLoadOptions } from "./import.js"; + /** - * Load MCP config by merging project-level and user-level configs. - * Project-level takes precedence for server names that appear in both. + * Load MCP config by merging external imports and KlaatCode native configs. + * Precedence (highest wins): .klaatai/mcp.json > ~/.klaatai/mcp.json > + * .mcp.json > .claude.json > .cursor/mcp.json. */ -export function loadMCPConfig(projectRoot: string): MCPConfig { - const paths = [ - join(homedir(), ".klaatai", "mcp.json"), // user-level (loaded first, lower priority) - join(projectRoot, ".klaatai", "mcp.json"), // project-level (higher priority) - ]; - - const merged: MCPConfig = { servers: {} }; - for (const p of paths) { - if (existsSync(p)) { - try { - const raw = readFileSync(p, "utf-8"); - const cfg = JSON.parse(raw) as Partial; - if (cfg.servers && typeof cfg.servers === "object") { - Object.assign(merged.servers, cfg.servers); - } - } catch { /* ignore malformed JSON */ } - } - } +export function loadMCPConfig(projectRoot: string, opts?: MCPLoadOptions): MCPConfig { + const home = opts?.homeDir ?? homedir(); + const loadOpts = { + projectRoot, + homeDir: home, + importMcpConfigs: opts?.importMcpConfigs, + onLog: opts?.onLog, + }; + + const merged: Record = loadImportedMcpServers(loadOpts); + mergeNativeMcpConfig(merged, projectRoot, home, loadOpts.onLog); // Always inject process.cwd() as the allowed directory for the filesystem // MCP server, regardless of what path was saved in mcp.json. This ensures // Klaat Code always scopes the filesystem server to the current project // directory rather than wherever the config was first written (e.g. HOME). - for (const cfg of Object.values(merged.servers)) { + for (const cfg of Object.values(merged)) { const args = cfg.args ?? []; - const fsIdx = args.findIndex(a => a.includes("server-filesystem")); + const fsIdx = args.findIndex((a: string) => a.includes("server-filesystem")); if (fsIdx !== -1) { cfg.args = [...args.slice(0, fsIdx + 1), process.cwd()]; } } - return merged; + return { servers: merged }; } // ─── JSON-RPC types ─────────────────────────────────────────────────────────── @@ -156,6 +162,8 @@ interface PendingCall { export class MCPServerClient { readonly name: string; + /** Config file this server was loaded from (undefined for runtime /mcp add). */ + readonly source?: string; private _config: MCPServerConfig; private _proc: ChildProcess | null = null; private _buffer: string = ""; @@ -168,8 +176,9 @@ export class MCPServerClient { status: MCPStatus = "idle"; statusMessage: string = ""; - constructor(name: string, config: MCPServerConfig, onStatusChange?: () => void) { + constructor(name: string, config: MCPServerConfig, onStatusChange?: () => void, source?: string) { this.name = name; + this.source = source; this._config = config; this._onStatusChange = onStatusChange; } @@ -517,7 +526,8 @@ export class MCPManager { */ connect(config: MCPConfig): void { for (const [name, serverCfg] of Object.entries(config.servers)) { - const client = new MCPServerClient(name, serverCfg, this._onStatusChange); + const { source, ...cfg } = serverCfg; + const client = new MCPServerClient(name, cfg, this._onStatusChange, source); this._servers.set(name, client); // Fire-and-forget: errors set client.status = "error" void client.connect(); diff --git a/src/mcp/import.test.ts b/src/mcp/import.test.ts new file mode 100644 index 0000000..6743309 --- /dev/null +++ b/src/mcp/import.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadMCPConfig } from "./client.js"; +import { + expandMcpEnvRefs, + mapExternalMcpServer, + parseExternalMcpFile, +} from "./import.js"; + +describe("expandMcpEnvRefs", () => { + const ctx = { + projectRoot: "/workspace/proj", + homeDir: "/home/user", + env: { API_KEY: "secret", EMPTY: "" }, + }; + + test("expands ${VAR} and ${VAR:-default}", () => { + expect(expandMcpEnvRefs("key=${API_KEY}", ctx)).toBe("key=secret"); + expect(expandMcpEnvRefs("key=${MISSING:-fallback}", ctx)).toBe("key=fallback"); + expect(expandMcpEnvRefs("key=${EMPTY:-fallback}", ctx)).toBe("key=fallback"); + }); + + test("expands Cursor-style refs", () => { + expect(expandMcpEnvRefs("${workspaceFolder}/server.py", ctx)).toBe("/workspace/proj/server.py"); + expect(expandMcpEnvRefs("${userHome}/.config", ctx)).toBe("/home/user/.config"); + expect(expandMcpEnvRefs("${env:API_KEY}", ctx)).toBe("secret"); + }); +}); + +describe("parseExternalMcpFile", () => { + const ctx = { + projectRoot: "/workspace/proj", + homeDir: "/home/user", + env: { TOKEN: "tok" }, + }; + + test("maps stdio Claude entry", () => { + const servers = parseExternalMcpFile({ + mcpServers: { + playwright: { + type: "stdio", + command: "npx", + args: ["-y", "@playwright/mcp@latest"], + }, + }, + }, ctx); + expect(servers["playwright"]).toEqual({ + command: "npx", + args: ["-y", "@playwright/mcp@latest"], + }); + }); + + test("maps remote http entry with env expansion", () => { + const servers = parseExternalMcpFile({ + mcpServers: { + linear: { + type: "http", + url: "https://mcp.example.com", + headers: { Authorization: "Bearer ${TOKEN}" }, + }, + }, + }, ctx); + expect(servers["linear"]).toEqual({ + url: "https://mcp.example.com", + headers: { Authorization: "Bearer tok" }, + }); + }); + + test("maps Cursor remote entry without type", () => { + const servers = parseExternalMcpFile({ + mcpServers: { + remote: { + url: "https://api.example.com/mcp", + headers: { Authorization: "Bearer ${env:TOKEN}" }, + }, + }, + }, ctx); + expect(servers["remote"]?.url).toBe("https://api.example.com/mcp"); + }); +}); + +describe("loadMCPConfig imports", () => { + let projectRoot = ""; + let homeDir = ""; + + beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "klaat-mcp-project-")); + homeDir = mkdtempSync(join(tmpdir(), "klaat-mcp-home-")); + }); + + afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); + rmSync(homeDir, { recursive: true, force: true }); + }); + + test("imports from .cursor/mcp.json", () => { + mkdirSync(join(projectRoot, ".cursor"), { recursive: true }); + writeFileSync(join(projectRoot, ".cursor", "mcp.json"), JSON.stringify({ + mcpServers: { + linear: { url: "https://mcp.linear.app/mcp" }, + }, + })); + + const logs: string[] = []; + const cfg = loadMCPConfig(projectRoot, { homeDir, onLog: m => logs.push(m) }); + expect(cfg.servers["linear"]?.url).toBe("https://mcp.linear.app/mcp"); + expect(cfg.servers["linear"]?.source).toBe(".cursor/mcp.json"); + expect(logs).toContain('mcp: imported "linear" from .cursor/mcp.json'); + }); + + test("merges imports with precedence: .mcp.json over .cursor/mcp.json", () => { + mkdirSync(join(projectRoot, ".cursor"), { recursive: true }); + writeFileSync(join(projectRoot, ".cursor", "mcp.json"), JSON.stringify({ + mcpServers: { + shared: { command: "cursor-cmd" }, + cursorOnly: { command: "cursor-only" }, + }, + })); + writeFileSync(join(projectRoot, ".mcp.json"), JSON.stringify({ + mcpServers: { + shared: { command: "mcp-cmd" }, + mcpOnly: { command: "mcp-only" }, + }, + })); + + const logs: string[] = []; + const cfg = loadMCPConfig(projectRoot, { homeDir, onLog: m => logs.push(m) }); + expect(cfg.servers["shared"]?.command).toBe("mcp-cmd"); + expect(cfg.servers["cursorOnly"]?.command).toBe("cursor-only"); + expect(cfg.servers["mcpOnly"]?.command).toBe("mcp-only"); + expect(logs.some(l => l.includes('overridden by .mcp.json'))).toBe(true); + }); + + test("native KlaatCode config overrides imports", () => { + writeFileSync(join(projectRoot, ".mcp.json"), JSON.stringify({ + mcpServers: { imported: { command: "imported-cmd" } }, + })); + mkdirSync(join(projectRoot, ".klaatai"), { recursive: true }); + writeFileSync(join(projectRoot, ".klaatai", "mcp.json"), JSON.stringify({ + servers: { imported: { command: "native-cmd" } }, + })); + + const logs: string[] = []; + const cfg = loadMCPConfig(projectRoot, { homeDir, onLog: m => logs.push(m) }); + expect(cfg.servers["imported"]?.command).toBe("native-cmd"); + expect(cfg.servers["imported"]?.source).toBe(".klaatai/mcp.json"); + expect(logs.some(l => l.includes('overridden by .klaatai/mcp.json'))).toBe(true); + }); + + test("importMcpConfigs: false skips external files", () => { + writeFileSync(join(projectRoot, ".mcp.json"), JSON.stringify({ + mcpServers: { imported: { command: "imported-cmd" } }, + })); + + const cfg = loadMCPConfig(projectRoot, { homeDir, importMcpConfigs: false }); + expect(cfg.servers["imported"]).toBeUndefined(); + }); + + test("parses .claude.json mcpServers", () => { + writeFileSync(join(projectRoot, ".claude.json"), JSON.stringify({ + mcpServers: { + gh: { + type: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + }, + }, + })); + + const cfg = loadMCPConfig(projectRoot, { homeDir }); + expect(cfg.servers["gh"]?.command).toBe("npx"); + expect(cfg.servers["gh"]?.source).toBe(".claude.json"); + }); +}); + +describe("mapExternalMcpServer", () => { + const ctx = { + projectRoot: "/proj", + homeDir: "/home", + env: {}, + }; + + test("returns null when stdio entry has no command", () => { + expect(mapExternalMcpServer({ type: "stdio" }, ctx)).toBeNull(); + }); +}); diff --git a/src/mcp/import.ts b/src/mcp/import.ts new file mode 100644 index 0000000..9054035 --- /dev/null +++ b/src/mcp/import.ts @@ -0,0 +1,215 @@ +/** + * Import MCP server definitions from external tool configs + * (.mcp.json, .claude.json, .cursor/mcp.json). + */ + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { MCPServerConfig, MCPServerEntry } from "./client.js"; + +export interface MCPLoadOptions { + projectRoot?: string; + homeDir?: string; + /** When false, skip external config imports. Default: true. */ + importMcpConfigs?: boolean; + /** Receives lines like `mcp: imported "linear" from .cursor/mcp.json`. */ + onLog?: (msg: string) => void; +} + +interface ExpandContext { + projectRoot: string; + homeDir: string; + env: NodeJS.ProcessEnv; +} + +const REMOTE_TYPES = new Set(["http", "sse", "streamable-http"]); + +/** Expand ${VAR}, ${VAR:-default}, ${env:VAR}, ${workspaceFolder}, ${userHome}. */ +export function expandMcpEnvRefs(value: string, ctx: ExpandContext): string { + return value.replace(/\$\{([^}]+)\}/g, (_match, raw: string) => { + const key = raw.trim(); + const defaultMatch = /^([^:]+):-(.*)$/.exec(key); + if (defaultMatch) { + const envVal = ctx.env[defaultMatch[1]!]; + return envVal !== undefined && envVal !== "" ? envVal : defaultMatch[2]!; + } + if (key === "workspaceFolder") return ctx.projectRoot; + if (key === "userHome") return ctx.homeDir; + if (key.startsWith("env:")) return ctx.env[key.slice(4)] ?? ""; + return ctx.env[key] ?? ""; + }); +} + +function expandStringRecord( + record: Record | undefined, + ctx: ExpandContext, +): Record | undefined { + if (!record) return undefined; + const out: Record = {}; + for (const [k, v] of Object.entries(record)) { + out[k] = expandMcpEnvRefs(v, ctx); + } + return out; +} + +function expandStringArray(values: string[] | undefined, ctx: ExpandContext): string[] | undefined { + if (!values) return undefined; + return values.map(v => expandMcpEnvRefs(v, ctx)); +} + +/** Map an external mcpServers entry to KlaatCode's MCPServerConfig. */ +export function mapExternalMcpServer( + entry: Record, + ctx: ExpandContext, +): MCPServerConfig | null { + const type = typeof entry["type"] === "string" ? entry["type"].toLowerCase() : ""; + const url = typeof entry["url"] === "string" ? expandMcpEnvRefs(entry["url"], ctx) : undefined; + const isRemote = !!url || REMOTE_TYPES.has(type); + + if (isRemote) { + if (!url) return null; + const headers = expandStringRecord( + entry["headers"] as Record | undefined, + ctx, + ); + const description = typeof entry["description"] === "string" ? entry["description"] : undefined; + return { + url, + ...(headers && Object.keys(headers).length > 0 ? { headers } : {}), + ...(description ? { description } : {}), + }; + } + + const command = typeof entry["command"] === "string" + ? expandMcpEnvRefs(entry["command"], ctx) + : undefined; + if (!command) return null; + + const args = expandStringArray(entry["args"] as string[] | undefined, ctx); + const env = expandStringRecord(entry["env"] as Record | undefined, ctx); + const description = typeof entry["description"] === "string" ? entry["description"] : undefined; + + return { + command, + ...(args?.length ? { args } : {}), + ...(env && Object.keys(env).length > 0 ? { env } : {}), + ...(description ? { description } : {}), + }; +} + +/** Parse `mcpServers` from a JSON object (external tool format). */ +export function parseExternalMcpFile( + raw: unknown, + ctx: ExpandContext, +): Record { + if (!raw || typeof raw !== "object") return {}; + const mcpServers = (raw as { mcpServers?: unknown }).mcpServers; + if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers)) return {}; + + const out: Record = {}; + for (const [name, entry] of Object.entries(mcpServers)) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; + const mapped = mapExternalMcpServer(entry as Record, ctx); + if (mapped) out[name] = mapped; + } + return out; +} + +function readJsonFile(path: string): unknown | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as unknown; + } catch { + return null; + } +} + +function mergeImportedServers( + merged: Record, + servers: Record, + sourceLabel: string, + onLog?: (msg: string) => void, +): void { + for (const [name, cfg] of Object.entries(servers)) { + const existing = merged[name]; + if (existing) { + onLog?.(`mcp: skipped "${name}" from ${existing.source ?? "config"} (overridden by ${sourceLabel})`); + } else { + onLog?.(`mcp: imported "${name}" from ${sourceLabel}`); + } + merged[name] = { ...cfg, source: sourceLabel }; + } +} + +function mergeNativeServers( + merged: Record, + servers: Record, + sourceLabel: string, + onLog?: (msg: string) => void, +): void { + for (const [name, cfg] of Object.entries(servers)) { + const existing = merged[name]; + if (existing?.source && existing.source !== sourceLabel) { + onLog?.(`mcp: skipped "${name}" from ${existing.source} (overridden by ${sourceLabel})`); + } + merged[name] = { ...cfg, source: sourceLabel }; + } +} + +/** + * Load external MCP configs in precedence order (lowest first). + * Returns servers keyed by name with a `source` label on each entry. + */ +export function loadImportedMcpServers(opts: MCPLoadOptions & { projectRoot: string }): Record { + if (opts.importMcpConfigs === false) return {}; + + const home = opts.homeDir ?? homedir(); + const ctx: ExpandContext = { + projectRoot: opts.projectRoot, + homeDir: home, + env: process.env, + }; + const merged: Record = {}; + + const importSources: Array<{ path: string; label: string }> = [ + { path: join(opts.projectRoot, ".cursor", "mcp.json"), label: ".cursor/mcp.json" }, + { path: join(opts.projectRoot, ".claude.json"), label: ".claude.json" }, + { path: join(opts.projectRoot, ".mcp.json"), label: ".mcp.json" }, + ]; + + for (const { path, label } of importSources) { + const raw = readJsonFile(path); + if (!raw) continue; + const servers = parseExternalMcpFile(raw, ctx); + mergeImportedServers(merged, servers, label, opts.onLog); + } + + return merged; +} + +/** Merge native KlaatCode config paths over imported servers. */ +export function mergeNativeMcpConfig( + merged: Record, + projectRoot: string, + homeDir: string, + onLog?: (msg: string) => void, +): void { + const nativePaths: Array<{ path: string; label: string }> = [ + { path: join(homeDir, ".klaatai", "mcp.json"), label: "~/.klaatai/mcp.json" }, + { path: join(projectRoot, ".klaatai", "mcp.json"), label: ".klaatai/mcp.json" }, + ]; + + for (const { path, label } of nativePaths) { + const raw = readJsonFile(path); + if (!raw || typeof raw !== "object") continue; + const servers = (raw as { servers?: unknown }).servers; + if (!servers || typeof servers !== "object" || Array.isArray(servers)) continue; + mergeNativeServers( + merged, + servers as Record, + label, + onLog, + ); + } +} diff --git a/src/screens/repl.ts b/src/screens/repl.ts index e0b15c8..78dd457 100644 --- a/src/screens/repl.ts +++ b/src/screens/repl.ts @@ -530,7 +530,10 @@ export async function runREPL( chatLinesDirty = true; app.requestRender(); }); - const mcpConfig = loadMCPConfig(projectRoot); + const mcpConfig = loadMCPConfig(projectRoot, { + importMcpConfigs: config.compat?.importMcpConfigs !== false, + onLog: (msg: string) => { process.stderr.write(msg + "\n"); }, + }); if (Object.keys(mcpConfig.servers).length > 0) { mcpManager.connect(mcpConfig); } @@ -2585,8 +2588,10 @@ export async function runREPL( pushSystemMsg("Usage: `/mcp disable `", "error"); return true; } + const runtime = mcpManager.servers.find(s => s.name === serverName); + const fromHome = !runtime?.source || runtime.source === "~/.klaatai/mcp.json"; const mcpConfigPath2 = join(homedir(), ".klaatai", "mcp.json"); - if (existsSync(mcpConfigPath2)) { + if (fromHome && existsSync(mcpConfigPath2)) { try { const cfg = JSON.parse(readFileSync(mcpConfigPath2, "utf-8")) as { servers?: Record }; if (cfg.servers && serverName in cfg.servers) { @@ -2594,10 +2599,22 @@ export async function runREPL( writeFileSync(mcpConfigPath2, JSON.stringify(cfg, null, 2), "utf-8"); mcpManager.disconnectOne(serverName); pushSystemMsg(`Server **${serverName}** disabled and removed from \`~/.klaatai/mcp.json\`.`); + } else if (runtime?.source) { + mcpManager.disconnectOne(serverName); + pushSystemMsg( + `Server **${serverName}** disconnected for this session.\n\n` + + `It is loaded from \`${runtime.source}\` — edit that file to remove it permanently.`, + ); } else { pushSystemMsg(`No server named "${serverName}" found in config.`, "error"); } } catch { pushSystemMsg("Failed to update mcp.json.", "error"); } + } else if (runtime?.source) { + mcpManager.disconnectOne(serverName); + pushSystemMsg( + `Server **${serverName}** disconnected for this session.\n\n` + + `It is loaded from \`${runtime.source}\` — edit that file to remove it permanently.`, + ); } else { pushSystemMsg("No mcp.json config found.", "error"); } @@ -2619,7 +2636,8 @@ export async function runREPL( const icon = s.status === "connected" ? "●" : s.status === "error" ? "✗" : "○"; const toolList = s.tools.slice(0, 5).map(t => `\`${t.name}\``).join(", "); const more = s.tools.length > 5 ? ` + ${s.tools.length - 5} more` : ""; - return `${icon} **${s.name}** — ${s.status}: ${s.statusMessage}${s.status === "connected" ? `\n Tools: ${toolList}${more}` : ""}`; + const sourceHint = s.source ? ` *(from ${s.source})*` : ""; + return `${icon} **${s.name}**${sourceHint} — ${s.status}: ${s.statusMessage}${s.status === "connected" ? `\n Tools: ${toolList}${more}` : ""}`; }); pushSystemMsg(`**MCP Servers** (${servers.length}):\n\n${lines.join("\n\n")}\n\n\`/mcp enable \` to add more`); }