Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/cli/commands/chat.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -203,13 +203,18 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
// 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) {
let label = "anon";
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
Expand Down
13 changes: 12 additions & 1 deletion src/cli/commands/run.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -76,13 +82,18 @@ export async function runCommand(opts: RunOptions): Promise<void> {
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) {
let label = "anon";
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
Expand Down
5 changes: 4 additions & 1 deletion src/cli/ui/mcp-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<McpLifecycleEvent["state"], { glyph: string; label: string }> = {
handshake: { glyph: "↻", label: "handshake…" },
connected: { glyph: "✓", label: "connected" },
failed: { glyph: "✖", label: "failed" },
disabled: { glyph: "○", label: "disabled" },
};

const NAME_COL = 22;
Expand All @@ -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`);
Expand Down
51 changes: 50 additions & 1 deletion src/cli/ui/slash/handlers/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { readConfig, writeConfig } from "../../../../config.js";
import type { SlashHandler } from "../dispatch.js";
import { appendSection } from "../helpers.js";

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:
Expand Down Expand Up @@ -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<string> },
): { info: string } {
const name = rawName?.trim();
if (!name) {
return {
info: `usage: /mcp ${action} <name> · pick a name shown in /mcp (anonymous servers can't be named-toggled).`,
};
}
const known = new Set<string>([
...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<string, SlashHandler> = { mcp };
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`. */
mcpDisabled?: string[];
session?: string | null;
setupCompleted?: boolean;
search?: boolean;
Expand Down
6 changes: 6 additions & 0 deletions tests/mcp-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
80 changes: 79 additions & 1 deletion tests/slash.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 <name> 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 <name> 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 <name>/);
});

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.
Expand Down
Loading