diff --git a/src/resources/extensions/gsd/commands-mcp-status.ts b/src/resources/extensions/gsd/commands-mcp-status.ts new file mode 100644 index 000000000..560e58d03 --- /dev/null +++ b/src/resources/extensions/gsd/commands-mcp-status.ts @@ -0,0 +1,247 @@ +/** + * MCP Status — `/gsd mcp` command handler. + * + * Shows configured MCP servers, their connection status, and available tools. + * + * Subcommands: + * /gsd mcp — Overview of all servers (alias: /gsd mcp status) + * /gsd mcp status — Same as bare /gsd mcp + * /gsd mcp check — Detailed status for a specific server + */ + +import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface McpServerStatus { + name: string; + transport: "stdio" | "http" | "unknown"; + connected: boolean; + toolCount: number; + error: string | undefined; +} + +export interface McpServerDetail extends McpServerStatus { + tools: string[]; +} + +// ─── Config reader (standalone — does not import mcp-client internals) ────── + +interface McpServerRawConfig { + name: string; + transport: "stdio" | "http" | "unknown"; + command?: string; + args?: string[]; + url?: string; +} + +function readMcpConfigs(): McpServerRawConfig[] { + const servers: McpServerRawConfig[] = []; + const seen = new Set(); + const configPaths = [ + join(process.cwd(), ".mcp.json"), + join(process.cwd(), ".gsd", "mcp.json"), + ]; + + for (const configPath of configPaths) { + try { + if (!existsSync(configPath)) continue; + const raw = readFileSync(configPath, "utf-8"); + const data = JSON.parse(raw) as Record; + const mcpServers = (data.mcpServers ?? data.servers) as + | Record> + | undefined; + if (!mcpServers || typeof mcpServers !== "object") continue; + + for (const [name, config] of Object.entries(mcpServers)) { + if (seen.has(name)) continue; + seen.add(name); + + const hasCommand = typeof config.command === "string"; + const hasUrl = typeof config.url === "string"; + const transport: McpServerRawConfig["transport"] = hasCommand + ? "stdio" + : hasUrl + ? "http" + : "unknown"; + + servers.push({ + name, + transport, + ...(hasCommand && { + command: config.command as string, + args: Array.isArray(config.args) ? (config.args as string[]) : undefined, + }), + ...(hasUrl && { url: config.url as string }), + }); + } + } catch { + // Non-fatal — config file may not exist or be malformed + } + } + + return servers; +} + +// ─── Formatters (exported for testing) ────────────────────────────────────── + +export function formatMcpStatusReport(servers: McpServerStatus[]): string { + if (servers.length === 0) { + return [ + "No MCP servers configured.", + "", + "Add servers to .mcp.json or .gsd/mcp.json to enable MCP integrations.", + "See: https://modelcontextprotocol.io/quickstart", + ].join("\n"); + } + + const lines: string[] = [`MCP Server Status — ${servers.length} server(s)\n`]; + + for (const s of servers) { + const icon = s.error ? "✗" : s.connected ? "✓" : "○"; + const status = s.error + ? `error: ${s.error}` + : s.connected + ? `connected — ${s.toolCount} tools` + : "disconnected"; + lines.push(` ${icon} ${s.name} (${s.transport}) — ${status}`); + } + + lines.push(""); + lines.push("Use /gsd mcp check for details on a specific server."); + lines.push("Use mcp_discover to connect and list tools for a server."); + + return lines.join("\n"); +} + +export function formatMcpServerDetail(server: McpServerDetail): string { + const lines: string[] = [`MCP Server: ${server.name}\n`]; + + lines.push(` Transport: ${server.transport}`); + + if (server.error) { + lines.push(` Status: error`); + lines.push(` Error: ${server.error}`); + } else if (server.connected) { + lines.push(` Status: connected`); + lines.push(` Tools: ${server.toolCount}`); + if (server.tools.length > 0) { + lines.push(""); + lines.push(" Available tools:"); + for (const tool of server.tools) { + lines.push(` - ${tool}`); + } + } + } else { + lines.push(` Status: disconnected`); + lines.push(""); + lines.push(` Run mcp_discover("${server.name}") to connect and list tools.`); + } + + return lines.join("\n"); +} + +// ─── Command handler ──────────────────────────────────────────────────────── + +/** + * Handle `/gsd mcp [status|check ]`. + */ +export async function handleMcpStatus( + args: string, + ctx: ExtensionCommandContext, +): Promise { + const trimmed = args.trim().toLowerCase(); + const configs = readMcpConfigs(); + + // /gsd mcp check + if (trimmed.startsWith("check ")) { + const serverName = args.trim().slice("check ".length).trim(); + const config = configs.find((c) => c.name === serverName); + if (!config) { + const available = configs.map((c) => c.name).join(", ") || "(none)"; + ctx.ui.notify( + `Unknown MCP server: "${serverName}"\n\nAvailable: ${available}`, + "warning", + ); + return; + } + + // Try to get connection/tool info from the mcp-client module if available + let connected = false; + let toolNames: string[] = []; + let error: string | undefined; + try { + const mcpClient = await import("../mcp-client/index.js"); + // Access the module's connection state if exported; fall back gracefully + const mod = mcpClient as Record; + if (typeof mod.getConnectionStatus === "function") { + const status = (mod.getConnectionStatus as (name: string) => { connected: boolean; tools: string[]; error?: string })(serverName); + connected = status.connected; + toolNames = status.tools; + error = status.error; + } + } catch { + // mcp-client may not expose status helpers — that's fine + } + + ctx.ui.notify( + formatMcpServerDetail({ + name: config.name, + transport: config.transport, + connected, + toolCount: toolNames.length, + tools: toolNames, + error, + }), + "info", + ); + return; + } + + // /gsd mcp or /gsd mcp status + if (!trimmed || trimmed === "status") { + // Build status for each server + const statuses: McpServerStatus[] = []; + + for (const config of configs) { + let connected = false; + let toolCount = 0; + let error: string | undefined; + + try { + const mcpClient = await import("../mcp-client/index.js"); + const mod = mcpClient as Record; + if (typeof mod.getConnectionStatus === "function") { + const status = (mod.getConnectionStatus as (name: string) => { connected: boolean; tools: string[]; error?: string })(config.name); + connected = status.connected; + toolCount = status.tools.length; + error = status.error; + } + } catch { + // Fall back to unknown state + } + + statuses.push({ + name: config.name, + transport: config.transport, + connected, + toolCount, + error, + }); + } + + ctx.ui.notify(formatMcpStatusReport(statuses), "info"); + return; + } + + // Unknown subcommand + ctx.ui.notify( + "Usage: /gsd mcp [status|check ]\n\n" + + " status Show all MCP server statuses (default)\n" + + " check Detailed status for a specific server", + "warning", + ); +} diff --git a/src/resources/extensions/gsd/commands/catalog.ts b/src/resources/extensions/gsd/commands/catalog.ts index 9a106b90c..2c8d1224a 100644 --- a/src/resources/extensions/gsd/commands/catalog.ts +++ b/src/resources/extensions/gsd/commands/catalog.ts @@ -15,7 +15,7 @@ export interface GsdCommandDefinition { type CompletionMap = Record; export const GSD_COMMAND_DESCRIPTION = - "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast"; + "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp"; export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "help", desc: "Categorized command reference with descriptions" }, @@ -68,6 +68,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "templates", desc: "List available workflow templates" }, { cmd: "extensions", desc: "Manage extensions (list, enable, disable, info)" }, { cmd: "fast", desc: "Toggle OpenAI service tier (on/off/flex/status)" }, + { cmd: "mcp", desc: "MCP server status and connectivity check (status, check )" }, { cmd: "workflow", desc: "Custom workflow lifecycle (new, run, list, validate, pause, resume)" }, ]; @@ -187,6 +188,10 @@ const NESTED_COMPLETIONS: CompletionMap = { { cmd: "flex", desc: "Flex tier (0.5x cost, slower)" }, { cmd: "status", desc: "Show current service tier setting" }, ], + mcp: [ + { cmd: "status", desc: "Show all MCP server statuses (default)" }, + { cmd: "check", desc: "Detailed status for a specific server" }, + ], doctor: [ { cmd: "fix", desc: "Auto-fix detected issues" }, { cmd: "heal", desc: "AI-driven deep healing" }, diff --git a/src/resources/extensions/gsd/commands/handlers/core.ts b/src/resources/extensions/gsd/commands/handlers/core.ts index 3028f72c5..c37def77c 100644 --- a/src/resources/extensions/gsd/commands/handlers/core.ts +++ b/src/resources/extensions/gsd/commands/handlers/core.ts @@ -53,6 +53,7 @@ export function showHelp(ctx: ExtensionCommandContext): void { " /gsd hooks Show post-unit hook configuration", " /gsd extensions Manage extensions [list|enable|disable|info]", " /gsd fast Toggle OpenAI service tier [on|off|flex|status]", + " /gsd mcp MCP server status and connectivity [status|check ]", "", "MAINTENANCE", " /gsd doctor Diagnose and repair .gsd/ state [audit|fix|heal] [scope]", diff --git a/src/resources/extensions/gsd/commands/handlers/ops.ts b/src/resources/extensions/gsd/commands/handlers/ops.ts index 564d112d0..d632a2ad9 100644 --- a/src/resources/extensions/gsd/commands/handlers/ops.ts +++ b/src/resources/extensions/gsd/commands/handlers/ops.ts @@ -191,6 +191,11 @@ Examples: await handleFast(trimmed.replace(/^fast\s*/, "").trim(), ctx); return true; } + if (trimmed === "mcp" || trimmed.startsWith("mcp ")) { + const { handleMcpStatus } = await import("../../commands-mcp-status.js"); + await handleMcpStatus(trimmed.replace(/^mcp\s*/, "").trim(), ctx); + return true; + } if (trimmed === "extensions" || trimmed.startsWith("extensions ")) { const { handleExtensions } = await import("../../commands-extensions.js"); await handleExtensions(trimmed.replace(/^extensions\s*/, "").trim(), ctx); diff --git a/src/resources/extensions/gsd/tests/mcp-status.test.ts b/src/resources/extensions/gsd/tests/mcp-status.test.ts new file mode 100644 index 000000000..97258fb2b --- /dev/null +++ b/src/resources/extensions/gsd/tests/mcp-status.test.ts @@ -0,0 +1,103 @@ +import test, { describe } from "node:test"; +import assert from "node:assert/strict"; + +import { + formatMcpStatusReport, + formatMcpServerDetail, + type McpServerStatus, +} from "../commands-mcp-status.ts"; + +// ─── formatMcpStatusReport ────────────────────────────────────────────────── + +describe("formatMcpStatusReport", () => { + test("returns no-servers message when list is empty", () => { + const result = formatMcpStatusReport([]); + assert.match(result, /no mcp servers configured/i); + }); + + test("lists all servers with connection status", () => { + const servers: McpServerStatus[] = [ + { name: "railway", transport: "stdio", connected: true, toolCount: 5, error: undefined }, + { name: "linear", transport: "http", connected: false, toolCount: 0, error: undefined }, + ]; + const result = formatMcpStatusReport(servers); + assert.match(result, /railway/); + assert.match(result, /linear/); + assert.match(result, /connected/i); + assert.match(result, /disconnected/i); + assert.match(result, /5 tools/); + }); + + test("shows error state for servers with errors", () => { + const servers: McpServerStatus[] = [ + { name: "broken", transport: "stdio", connected: false, toolCount: 0, error: "Connection refused" }, + ]; + const result = formatMcpStatusReport(servers); + assert.match(result, /error/i); + assert.match(result, /Connection refused/); + }); + + test("includes server count in header", () => { + const servers: McpServerStatus[] = [ + { name: "a", transport: "stdio", connected: true, toolCount: 3, error: undefined }, + { name: "b", transport: "http", connected: true, toolCount: 2, error: undefined }, + ]; + const result = formatMcpStatusReport(servers); + assert.match(result, /2/); + }); +}); + +// ─── formatMcpServerDetail ────────────────────────────────────────────────── + +describe("formatMcpServerDetail", () => { + test("shows server name and transport", () => { + const result = formatMcpServerDetail({ + name: "railway", + transport: "stdio", + connected: true, + toolCount: 3, + tools: ["railway_list_projects", "railway_deploy", "railway_logs"], + error: undefined, + }); + assert.match(result, /railway/); + assert.match(result, /stdio/); + }); + + test("lists individual tools when available", () => { + const result = formatMcpServerDetail({ + name: "railway", + transport: "stdio", + connected: true, + toolCount: 2, + tools: ["railway_list_projects", "railway_deploy"], + error: undefined, + }); + assert.match(result, /railway_list_projects/); + assert.match(result, /railway_deploy/); + }); + + test("shows error message for failed servers", () => { + const result = formatMcpServerDetail({ + name: "broken", + transport: "stdio", + connected: false, + toolCount: 0, + tools: [], + error: "spawn ENOENT", + }); + assert.match(result, /error/i); + assert.match(result, /spawn ENOENT/); + }); + + test("shows disconnected status with no tools", () => { + const result = formatMcpServerDetail({ + name: "offline", + transport: "http", + connected: false, + toolCount: 0, + tools: [], + error: undefined, + }); + assert.match(result, /disconnected/i); + }); +}); diff --git a/src/resources/extensions/mcp-client/index.ts b/src/resources/extensions/mcp-client/index.ts index 2113540ff..38d001aa1 100644 --- a/src/resources/extensions/mcp-client/index.ts +++ b/src/resources/extensions/mcp-client/index.ts @@ -213,6 +213,26 @@ function formatToolList(serverName: string, tools: McpToolSchema[]): string { return lines.join("\n"); } +// ─── Status helper (consumed by /gsd mcp) ───────────────────────────────────── + +/** + * Return the live connection status for a named MCP server. + * Safe to call even when the server has never been connected. + */ +export function getConnectionStatus(name: string): { + connected: boolean; + tools: string[]; + error?: string; +} { + const conn = connections.get(name); + const cached = toolCache.get(name); + return { + connected: !!conn, + tools: cached ? cached.map((t) => t.name) : [], + error: undefined, + }; +} + // ─── Extension ──────────────────────────────────────────────────────────────── export default function (pi: ExtensionAPI) {