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:
parent
b9ff5d5052
commit
515fe0295b
6 changed files with 382 additions and 1 deletions
247
src/resources/extensions/gsd/commands-mcp-status.ts
Normal file
247
src/resources/extensions/gsd/commands-mcp-status.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
103
src/resources/extensions/gsd/tests/mcp-status.test.ts
Normal file
103
src/resources/extensions/gsd/tests/mcp-status.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue