diff --git a/src/cli/commands/chat.tsx b/src/cli/commands/chat.tsx index 139a506..40174e9 100644 --- a/src/cli/commands/chat.tsx +++ b/src/cli/commands/chat.tsx @@ -1,6 +1,6 @@ import { render } from "ink"; import React, { useState } from "react"; -import { loadApiKey, searchEnabled } from "../../config.js"; +import { loadApiKey, readConfig, searchEnabled } from "../../config.js"; import { loadDotenv } from "../../env.js"; import { McpClient } from "../../mcp/client.js"; import { type InspectionReport, inspectMcpServer } from "../../mcp/inspect.js"; @@ -203,6 +203,7 @@ export async function chatCommand(opts: ChatOptions): Promise { // the loop runs as a bare chat. let tools: ToolRegistry | undefined = opts.seedTools; + const disabledNames = new Set(readConfig().mcpDisabled ?? []); if (requestedSpecs.length > 0) { if (!tools) tools = new ToolRegistry(); for (const raw of requestedSpecs) { @@ -210,6 +211,10 @@ export async function chatCommand(opts: ChatOptions): Promise { try { const spec = parseMcpSpec(raw); label = spec.name ?? "anon"; + if (spec.name && disabledNames.has(spec.name)) { + process.stderr.write(`${formatMcpLifecycleEvent({ state: "disabled", name: label })}\n`); + continue; + } process.stderr.write(`${formatMcpLifecycleEvent({ state: "handshake", name: label })}\n`); const t0 = Date.now(); const prefix = spec.name diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts index eebcdc7..98c56c4 100644 --- a/src/cli/commands/run.ts +++ b/src/cli/commands/run.ts @@ -1,7 +1,13 @@ import type { WriteStream } from "node:fs"; import { stdin, stdout } from "node:process"; import { createInterface } from "node:readline/promises"; -import { defaultConfigPath, isPlausibleKey, loadApiKey, saveApiKey } from "../../config.js"; +import { + defaultConfigPath, + isPlausibleKey, + loadApiKey, + readConfig, + saveApiKey, +} from "../../config.js"; import { loadDotenv } from "../../env.js"; import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../../index.js"; import { McpClient } from "../../mcp/client.js"; @@ -76,6 +82,7 @@ export async function runCommand(opts: RunOptions): Promise { const clients: McpClient[] = []; let tools: ToolRegistry | undefined; let successCount = 0; + const disabledNames = new Set(readConfig().mcpDisabled ?? []); if (requestedSpecs.length > 0) { tools = new ToolRegistry(); for (const raw of requestedSpecs) { @@ -83,6 +90,10 @@ export async function runCommand(opts: RunOptions): Promise { try { const spec = parseMcpSpec(raw); label = spec.name ?? "anon"; + if (spec.name && disabledNames.has(spec.name)) { + process.stderr.write(`${formatMcpLifecycleEvent({ state: "disabled", name: label })}\n`); + continue; + } process.stderr.write(`${formatMcpLifecycleEvent({ state: "handshake", name: label })}\n`); const t0 = Date.now(); const prefix = spec.name diff --git a/src/cli/ui/mcp-lifecycle.ts b/src/cli/ui/mcp-lifecycle.ts index 57c865d..a1b9953 100644 --- a/src/cli/ui/mcp-lifecycle.ts +++ b/src/cli/ui/mcp-lifecycle.ts @@ -10,12 +10,14 @@ export type McpLifecycleEvent = prompts?: number; ms: number; } - | { state: "failed"; name: string; reason: string }; + | { state: "failed"; name: string; reason: string } + | { state: "disabled"; name: string }; const STATE: Record = { handshake: { glyph: "↻", label: "handshake…" }, connected: { glyph: "✓", label: "connected" }, failed: { glyph: "✖", label: "failed" }, + disabled: { glyph: "○", label: "disabled" }, }; const NAME_COL = 22; @@ -32,6 +34,7 @@ export function formatMcpLifecycleEvent(ev: McpLifecycleEvent): string { function describeDetail(ev: McpLifecycleEvent): string { if (ev.state === "handshake") return "initialise → tools/list → resources/list"; if (ev.state === "failed") return ev.reason; + if (ev.state === "disabled") return `via /mcp disable ${ev.name}`; const parts: string[] = [`${ev.tools} tools`]; if (ev.resources && ev.resources > 0) parts.push(`${ev.resources} resources`); if (ev.prompts && ev.prompts > 0) parts.push(`${ev.prompts} prompts`); diff --git a/src/cli/ui/slash/handlers/mcp.ts b/src/cli/ui/slash/handlers/mcp.ts index 0d478a6..4aafe07 100644 --- a/src/cli/ui/slash/handlers/mcp.ts +++ b/src/cli/ui/slash/handlers/mcp.ts @@ -1,3 +1,4 @@ +import { readConfig, writeConfig } from "../../../../config.js"; import type { SlashHandler } from "../dispatch.js"; import { appendSection } from "../helpers.js"; @@ -5,9 +6,13 @@ const mcp: SlashHandler = (args, loop, ctx) => { const servers = ctx.mcpServers ?? []; const specs = ctx.mcpSpecs ?? []; const toolSpecs = loop.prefix.toolSpecs ?? []; + const sub = args[0]; + if (sub === "disable" || sub === "enable") { + return toggleDisabled(sub, args[1], { servers, specs }); + } // `/mcp text` (or non-TTY) falls through to the printed-card path. The // default `/mcp` opens the interactive browser modal. - const wantsTextDump = args[0] === "text"; + const wantsTextDump = sub === "text"; if (servers.length === 0 && specs.length === 0 && toolSpecs.length === 0) { return { info: @@ -74,4 +79,48 @@ function healthBadge(elapsedMs: number): string { return `✗ very slow · ${elapsedMs}ms`; } +function toggleDisabled( + action: "disable" | "enable", + rawName: string | undefined, + ctx: { servers: ReadonlyArray<{ label: string }>; specs: ReadonlyArray }, +): { info: string } { + const name = rawName?.trim(); + if (!name) { + return { + info: `usage: /mcp ${action} · pick a name shown in /mcp (anonymous servers can't be named-toggled).`, + }; + } + const known = new Set([ + ...ctx.servers.map((s) => s.label), + ...ctx.specs.map((spec) => parseLabelFromSpec(spec)).filter((n): n is string => n !== null), + ]); + if (!known.has(name)) { + const list = [...known].sort().join(", ") || "(none)"; + return { info: `unknown MCP server "${name}". Known: ${list}.` }; + } + const cfg = readConfig(); + const current = new Set(cfg.mcpDisabled ?? []); + if (action === "disable") { + if (current.has(name)) { + return { info: `▸ ${name} is already disabled — restart to apply, or /mcp enable ${name}.` }; + } + current.add(name); + writeConfig({ ...cfg, mcpDisabled: [...current].sort() }); + return { + info: `▸ ${name} disabled — takes effect on next launch. /mcp enable ${name} to revert.`, + }; + } + if (!current.has(name)) { + return { info: `▸ ${name} is not disabled.` }; + } + current.delete(name); + writeConfig({ ...cfg, mcpDisabled: current.size > 0 ? [...current].sort() : undefined }); + return { info: `▸ ${name} re-enabled — takes effect on next launch.` }; +} + +function parseLabelFromSpec(spec: string): string | null { + const match = spec.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)=/); + return match ? (match[1] ?? null) : null; +} + export const handlers: Record = { mcp }; diff --git a/src/config.ts b/src/config.ts index 72985e7..9fa10e4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,6 +26,8 @@ export interface ReasonixConfig { reasoningEffort?: ReasoningEffort; /** Stored as `--mcp`-format strings so one parser handles both flag and config. */ mcp?: string[]; + /** Names of servers in `mcp` to skip on bridge — see `/mcp disable `. */ + mcpDisabled?: string[]; session?: string | null; setupCompleted?: boolean; search?: boolean; diff --git a/tests/mcp-lifecycle.test.ts b/tests/mcp-lifecycle.test.ts index ca2a421..c0ade2a 100644 --- a/tests/mcp-lifecycle.test.ts +++ b/tests/mcp-lifecycle.test.ts @@ -27,6 +27,12 @@ describe("formatMcpLifecycleEvent", () => { ); }); + it("renders the disabled state", () => { + expect(formatMcpLifecycleEvent({ state: "disabled", name: "linear" })).toBe( + "⌘ MCP · linear ○ disabled via /mcp disable linear", + ); + }); + it("renders the failed state", () => { expect( formatMcpLifecycleEvent({ diff --git a/tests/slash.test.ts b/tests/slash.test.ts index 6288c26..fdb1732 100644 --- a/tests/slash.test.ts +++ b/tests/slash.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -794,6 +794,84 @@ describe("handleSlash", () => { expect(r.info).toMatch(/server-filesystem|fs/); }); + describe("/mcp disable / enable", () => { + let tempHome: string; + let originalHome: string | undefined; + let originalUserProfile: string | undefined; + + beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), "reasonix-mcp-toggle-")); + originalHome = process.env.HOME; + originalUserProfile = process.env.USERPROFILE; + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + }); + afterEach(() => { + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + rmSync(tempHome, { recursive: true, force: true }); + }); + + it("/mcp disable persists the name into config.mcpDisabled", () => { + const r = handleSlash("mcp", ["disable", "notion"], makeLoop(), { + mcpSpecs: ["notion=npx -y @scope/notion", "linear=npx -y @scope/linear"], + }); + expect(r.info).toMatch(/notion disabled/); + expect(r.info).toMatch(/next launch/); + const cfgPath = join(tempHome, ".reasonix", "config.json"); + const cfg = JSON.parse(readFileSync(cfgPath, "utf8")); + expect(cfg.mcpDisabled).toEqual(["notion"]); + }); + + it("/mcp enable removes from disabled and clears the array when empty", () => { + const cfgPath = join(tempHome, ".reasonix", "config.json"); + mkdirSync(join(tempHome, ".reasonix"), { recursive: true }); + writeFileSync(cfgPath, JSON.stringify({ mcpDisabled: ["notion", "linear"] })); + const r = handleSlash("mcp", ["enable", "notion"], makeLoop(), { + mcpSpecs: ["notion=npx -y @scope/notion", "linear=npx -y @scope/linear"], + }); + expect(r.info).toMatch(/notion re-enabled/); + const cfg = JSON.parse(readFileSync(cfgPath, "utf8")); + expect(cfg.mcpDisabled).toEqual(["linear"]); + }); + + it("/mcp enable removes the array entirely when last entry clears", () => { + const cfgPath = join(tempHome, ".reasonix", "config.json"); + mkdirSync(join(tempHome, ".reasonix"), { recursive: true }); + writeFileSync(cfgPath, JSON.stringify({ mcpDisabled: ["notion"] })); + handleSlash("mcp", ["enable", "notion"], makeLoop(), { + mcpSpecs: ["notion=npx -y @scope/notion"], + }); + const cfg = JSON.parse(readFileSync(cfgPath, "utf8")); + expect(cfg.mcpDisabled).toBeUndefined(); + }); + + it("/mcp disable rejects unknown names with the list of known ones", () => { + const r = handleSlash("mcp", ["disable", "ghost"], makeLoop(), { + mcpSpecs: ["notion=cmd", "linear=cmd"], + }); + expect(r.info).toMatch(/unknown MCP server "ghost"/); + expect(r.info).toMatch(/Known: linear, notion/); + }); + + it("/mcp disable with no arg shows usage", () => { + const r = handleSlash("mcp", ["disable"], makeLoop(), { + mcpSpecs: ["notion=cmd"], + }); + expect(r.info).toMatch(/usage: \/mcp disable /); + }); + + it("/mcp disable on already-disabled is idempotent", () => { + const cfgPath = join(tempHome, ".reasonix", "config.json"); + mkdirSync(join(tempHome, ".reasonix"), { recursive: true }); + writeFileSync(cfgPath, JSON.stringify({ mcpDisabled: ["notion"] })); + const r = handleSlash("mcp", ["disable", "notion"], makeLoop(), { + mcpSpecs: ["notion=cmd"], + }); + expect(r.info).toMatch(/already disabled/); + }); + }); + it("/status shows ctx / session / mcp / pending lines with rich detail", () => { const loop = makeLoop(); // Make it look like one turn ran so lastPromptTokens > 0.