feat(gsd): add /gsd mcp command for MCP server status and connectivity (#2362)

Adds a new `/gsd mcp` slash command that shows configured MCP servers,
their connection status, and available tools. Supports two subcommands:
- `/gsd mcp status` (default) — overview of all servers
- `/gsd mcp check <server>` — detailed info for a specific server

Exports a `getConnectionStatus()` helper from the mcp-client extension
so the command can query live connection state.

Fixes #1489

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-25 01:18:31 -04:00 committed by GitHub
parent b9ff5d5052
commit 515fe0295b
6 changed files with 382 additions and 1 deletions

View file

@ -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 <srv> 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<string>();
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<string, unknown>;
const mcpServers = (data.mcpServers ?? data.servers) as
| Record<string, Record<string, unknown>>
| 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 <server> 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 <server>]`.
*/
export async function handleMcpStatus(
args: string,
ctx: ExtensionCommandContext,
): Promise<void> {
const trimmed = args.trim().toLowerCase();
const configs = readMcpConfigs();
// /gsd mcp check <server>
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<string, unknown>;
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<string, unknown>;
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 <server>]\n\n" +
" status Show all MCP server statuses (default)\n" +
" check <server> Detailed status for a specific server",
"warning",
);
}

View file

@ -15,7 +15,7 @@ export interface GsdCommandDefinition {
type CompletionMap = Record<string, readonly GsdCommandDefinition[]>;
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 <server>)" },
{ 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" },

View file

@ -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 <server>]",
"",
"MAINTENANCE",
" /gsd doctor Diagnose and repair .gsd/ state [audit|fix|heal] [scope]",

View file

@ -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);

View file

@ -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);
});
});

View file

@ -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) {