diff --git a/README.md b/README.md index 28e4369..1fe2efb 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ Or search for **Patchloom** in the Extensions view (`Ctrl+Shift+X` / `Cmd+Shift+ 1. Install the [Patchloom CLI](https://github.com/patchloom/patchloom/releases) (or run **Patchloom: Install Patchloom** from the command palette) 2. Open a project and run **Patchloom: Setup Workspace** +

+ Setup Workspace demo +

+ The extension finds the CLI automatically. If it's not on `PATH`, point `patchloom.path` to it in settings. --- @@ -52,7 +56,17 @@ Run `Patchloom: Setup Workspace` to walk through everything your project needs: ### Status bar -The status bar shows binary readiness and CLI version at a glance. Click it to see full diagnostics. +The status bar shows MCP and binary readiness at a glance: + +- **$(plug) Patchloom MCP** when the MCP server is configured +- **$(check) Patchloom** when the binary is ready but MCP is not yet set up +- **$(warning) Patchloom** when the binary is missing or needs an upgrade + +Click it to see full diagnostics, including per-editor MCP configuration status (VS Code, Cursor, Windsurf). + +### Verify MCP Server + +`Patchloom: Verify MCP Server` spawns `patchloom mcp-server`, sends a JSON-RPC `initialize` handshake, and confirms the server responds correctly. Reports the server name and version on success, or a diagnostic error on failure. ### Quick actions @@ -92,6 +106,7 @@ The extension detects outdated CLI builds and warns with upgrade guidance. It re | `Patchloom: Batch Apply` | Open a JSON batch plan and execute all operations atomically | | `Patchloom: Show Output` | Open the Patchloom output channel for CLI logs and diagnostics | | `Patchloom: Show Status` | Display binary readiness, version, compatibility, and workspace state | +| `Patchloom: Verify MCP Server` | Spawn the MCP server and verify it responds to a JSON-RPC initialize request | | `Patchloom: Install Patchloom` | Download and install the Patchloom CLI with checksum verification | | `Patchloom: Update Patchloom` | Update a managed Patchloom install to the latest release | | `Patchloom: Reinstall Patchloom` | Re-download and reinstall the managed Patchloom binary | diff --git a/images/setup-workspace-demo.gif b/images/setup-workspace-demo.gif new file mode 100644 index 0000000..d63ad5f Binary files /dev/null and b/images/setup-workspace-demo.gif differ diff --git a/package.json b/package.json index d5b59f9..97bc8ef 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ "onCommand:patchloom.showOutput", "onCommand:patchloom.installBinary", "onCommand:patchloom.updateBinary", - "onCommand:patchloom.reinstallBinary" + "onCommand:patchloom.reinstallBinary", + "onCommand:patchloom.verifyMcp" ], "contributes": { "commands": [ @@ -114,6 +115,11 @@ "command": "patchloom.reinstallBinary", "title": "Reinstall Patchloom", "category": "Patchloom" + }, + { + "command": "patchloom.verifyMcp", + "title": "Verify MCP Server", + "category": "Patchloom" } ], "configuration": { diff --git a/src/commands/setupWorkspace.ts b/src/commands/setupWorkspace.ts index 7663362..9d79fd1 100644 --- a/src/commands/setupWorkspace.ts +++ b/src/commands/setupWorkspace.ts @@ -37,32 +37,34 @@ export async function setupWorkspace(): Promise { if (readiness.hasAgentsFile === false) { const choice = await vscode.window.showInformationMessage( - "AGENTS.md is missing for this workspace. Create it now from patchloom agent-rules?", - "Initialize Project" + "Step 1/2: AGENTS.md is missing for this workspace. Create it now from patchloom agent-rules?", + "Initialize Project", + "Skip" ); if (choice === "Initialize Project") { await vscode.commands.executeCommand("patchloom.initializeProject"); } - return; } if (readiness.hasMcpConfig === false) { const choice = await vscode.window.showInformationMessage( - "Patchloom MCP config is missing. Configure supported editors now?", - "Configure MCP" + `${readiness.hasAgentsFile === false ? "Step 2/2: " : ""}Patchloom MCP config is missing. Configure supported editors now?`, + "Configure MCP", + "Skip" ); if (choice === "Configure MCP") { await vscode.commands.executeCommand("patchloom.configureMcp"); } - return; } - const environment = describeWorkspaceEnvironment(vscode.env.remoteName); - const environmentSuffix = environment.note ? ` ${environment.note}` : ""; - const workspaceTarget = readiness.workspaceName ? ` for ${readiness.workspaceName}` : ""; - await vscode.window.showInformationMessage( - `Patchloom workspace setup looks good${workspaceTarget}. Binary, AGENTS.md, and MCP config are already in place.${environmentSuffix}` - ); + if (readiness.hasAgentsFile !== false && readiness.hasMcpConfig !== false) { + const environment = describeWorkspaceEnvironment(vscode.env.remoteName); + const environmentSuffix = environment.note ? ` ${environment.note}` : ""; + const workspaceTarget = readiness.workspaceName ? ` for ${readiness.workspaceName}` : ""; + await vscode.window.showInformationMessage( + `Patchloom workspace setup looks good${workspaceTarget}. Binary, AGENTS.md, and MCP config are already in place.${environmentSuffix}` + ); + } } export async function openPatchloomSettings(): Promise { diff --git a/src/commands/verifyMcp.ts b/src/commands/verifyMcp.ts new file mode 100644 index 0000000..d734ded --- /dev/null +++ b/src/commands/verifyMcp.ts @@ -0,0 +1,182 @@ +import { spawn } from "node:child_process"; +import { patchloomNeedsUpgrade, resolvePatchloomStatus } from "../binary/patchloom.js"; +import { getPatchloomLog } from "../logging/outputChannel.js"; + +export interface VerifyMcpInputs { + readonly binaryPath: string; + readonly spawnProcess?: typeof spawnMcpServer; +} + +export interface VerifyMcpResult { + readonly ok: boolean; + readonly serverName?: string; + readonly serverVersion?: string; + readonly message: string; +} + +export async function verifyMcp(): Promise { + const vscode = await import("vscode"); + const status = await resolvePatchloomStatus(); + if (!status.ready || !status.binaryPath) { + await vscode.window.showWarningMessage(status.message); + return; + } + + if (patchloomNeedsUpgrade(status)) { + await vscode.window.showWarningMessage( + `${status.compatibilityMessage}\n\nUpgrade Patchloom before verifying the MCP server.` + ); + return; + } + + const result = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Patchloom", + cancellable: false + }, + async (progress) => { + progress.report({ message: "Verifying MCP server..." }); + return verifyMcpServer({ binaryPath: status.binaryPath! }); + } + ); + + const log = getPatchloomLog(); + log?.log(`MCP verify: ${result.message}`); + + if (result.ok) { + await vscode.window.showInformationMessage(result.message); + } else { + await vscode.window.showErrorMessage(result.message); + } +} + +export async function verifyMcpServer(inputs: VerifyMcpInputs): Promise { + const spawnFn = inputs.spawnProcess ?? spawnMcpServer; + try { + return await spawnFn(inputs.binaryPath); + } catch (error) { + return { + ok: false, + message: `MCP server failed to start: ${error instanceof Error ? error.message : String(error)}` + }; + } +} + +function spawnMcpServer(binaryPath: string): Promise { + return new Promise((resolve) => { + const child = spawn(binaryPath, ["mcp-server"], { + stdio: ["pipe", "pipe", "pipe"], + timeout: 10_000, + windowsHide: true + }); + + let stdout = ""; + let stderr = ""; + let resolved = false; + + const finish = (result: VerifyMcpResult): void => { + if (resolved) { + return; + } + resolved = true; + child.kill(); + resolve(result); + }; + + const timer = setTimeout(() => { + finish({ + ok: false, + message: "MCP server did not respond within 10 seconds." + }); + }, 10_000); + + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + const result = parseInitializeResponse(stdout); + if (result) { + clearTimeout(timer); + finish(result); + } + }); + + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.on("error", (error) => { + clearTimeout(timer); + finish({ + ok: false, + message: `MCP server process error: ${error.message}` + }); + }); + + child.on("close", (code) => { + clearTimeout(timer); + if (!resolved) { + finish({ + ok: false, + message: `MCP server exited with code ${code ?? "unknown"}. ${stderr.trim()}` + }); + } + }); + + const request = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "patchloom-vscode-verify", version: "1.0.0" } + } + }); + child.stdin.write(request + "\n"); + child.stdin.end(); + }); +} + +export function parseInitializeResponse(data: string): VerifyMcpResult | undefined { + for (const line of data.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + continue; + } + if (!parsed || typeof parsed !== "object") { + continue; + } + const response = parsed as Record; + if (response.jsonrpc !== "2.0") { + continue; + } + + if (response.error) { + const error = response.error as Record; + return { + ok: false, + message: `MCP server returned error: ${error.message ?? JSON.stringify(error)}` + }; + } + + if (response.result && typeof response.result === "object") { + const result = response.result as Record; + const serverInfo = result.serverInfo as Record | undefined; + return { + ok: true, + serverName: serverInfo?.name as string | undefined, + serverVersion: serverInfo?.version as string | undefined, + message: serverInfo + ? `MCP server verified: ${serverInfo.name} ${serverInfo.version ?? ""}`.trim() + : "MCP server responded successfully." + }; + } + } + return undefined; +} diff --git a/src/extension.ts b/src/extension.ts index 22be6d7..ea395ac 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,7 @@ import { installPatchloom, updatePatchloom, reinstallPatchloom } from "./command import { runQuickAction } from "./commands/quickActions.js"; import { setupWorkspace, openPatchloomReleases, openPatchloomSettings } from "./commands/setupWorkspace.js"; import { showStatus } from "./commands/showStatus.js"; +import { verifyMcp } from "./commands/verifyMcp.js"; import { setManagedInstallRoot } from "./install/managed.js"; import { createPatchloomLog, getPatchloomLog, setPatchloomLog } from "./logging/outputChannel.js"; import { disposeStatusBar, refreshStatusBar } from "./status/statusBar.js"; @@ -29,6 +30,7 @@ export function activate(context: vscode.ExtensionContext): void { vscode.commands.registerCommand("patchloom.installBinary", installPatchloom), vscode.commands.registerCommand("patchloom.updateBinary", updatePatchloom), vscode.commands.registerCommand("patchloom.reinstallBinary", reinstallPatchloom), + vscode.commands.registerCommand("patchloom.verifyMcp", verifyMcp), new vscode.Disposable(disposeStatusBar), vscode.workspace.onDidChangeConfiguration((event) => { if (event.affectsConfiguration("patchloom")) { diff --git a/src/status/details.ts b/src/status/details.ts index cdca296..eb846b6 100644 --- a/src/status/details.ts +++ b/src/status/details.ts @@ -4,6 +4,7 @@ import { patchloomNeedsUpgrade, PatchloomStatus } from "../binary/patchloom.js"; +import type { McpTargetStatus } from "../mcp/config.js"; import { WorkspaceReadiness } from "../workspace/readiness.js"; export interface SetupAction { @@ -38,9 +39,7 @@ export function buildStatusDetails(status: PatchloomStatus, workspaceReadiness?: workspaceReadiness?.hasAgentsFile === undefined ? undefined : `AGENTS.md: ${workspaceReadiness.hasAgentsFile ? "present" : "missing"}`, - workspaceReadiness?.hasMcpConfig === undefined - ? undefined - : `MCP config: ${workspaceReadiness.hasMcpConfig ? "present" : "missing"}` + ...formatMcpTargetDetails(workspaceReadiness?.mcpTargets) ].filter((line): line is string => Boolean(line)).join("\n"); } @@ -80,4 +79,15 @@ export function preferredStatusAction(status: PatchloomStatus, workspaceReadines } return undefined; +} + +function formatMcpTargetDetails(targets?: readonly McpTargetStatus[]): string[] { + if (!targets || targets.length === 0) { + return ["MCP config: no targets available"]; + } + + return targets.map((target) => { + const icon = target.configured ? "\u2713" : "\u2717"; + return `MCP ${target.label}: ${icon} ${target.configured ? "configured" : "not configured"}`; + }); } \ No newline at end of file diff --git a/src/status/statusBar.ts b/src/status/statusBar.ts index 73e459f..fbe0533 100644 --- a/src/status/statusBar.ts +++ b/src/status/statusBar.ts @@ -21,9 +21,11 @@ export async function refreshStatusBar(): Promise { const workspaceReadiness = await inspectWorkspaceReadiness(); const action = preferredStatusAction(status, workspaceReadiness); - statusBarItem.text = status.ready && !patchloomNeedsUpgrade(status) - ? "$(check) Patchloom" - : "$(warning) Patchloom"; + statusBarItem.text = !status.ready || patchloomNeedsUpgrade(status) + ? "$(warning) Patchloom" + : workspaceReadiness?.hasMcpConfig + ? "$(plug) Patchloom MCP" + : "$(check) Patchloom"; statusBarItem.command = action?.command ?? "patchloom.showStatus"; statusBarItem.tooltip = buildStatusDetails(status, workspaceReadiness); statusBarItem.show(); diff --git a/src/workspace/readiness.ts b/src/workspace/readiness.ts index bf59272..821d6d6 100644 --- a/src/workspace/readiness.ts +++ b/src/workspace/readiness.ts @@ -1,5 +1,5 @@ import type * as VSCode from "vscode"; -import { inspectMcpTargets } from "../mcp/config.js"; +import { inspectMcpTargets, type McpTargetStatus } from "../mcp/config.js"; export type WorkspaceEnvironmentSupport = "supported" | "limited" | "unverified"; @@ -16,6 +16,7 @@ export interface WorkspaceReadiness { readonly hasWorkspace: boolean; readonly hasAgentsFile?: boolean; readonly hasMcpConfig?: boolean; + readonly mcpTargets?: readonly McpTargetStatus[]; readonly workspaceCount: number; readonly environmentLabel: string; readonly environmentSupport: WorkspaceEnvironmentSupport; @@ -40,11 +41,13 @@ export async function inspectWorkspaceReadiness(options: WorkspaceReadinessOptio }); const workspaceCount = vscode.workspace.workspaceFolders?.length ?? 0; if (!folder) { + const targets = await inspectMcpTargets({ + includeUserTarget: environment.supportsUserMcpConfig + }); return { hasWorkspace: false, - hasMcpConfig: (await inspectMcpTargets({ - includeUserTarget: environment.supportsUserMcpConfig - })).some((target) => target.configured), + hasMcpConfig: targets.some((target) => target.configured), + mcpTargets: targets, workspaceCount, environmentLabel: environment.label, environmentSupport: environment.support, @@ -52,7 +55,7 @@ export async function inspectWorkspaceReadiness(options: WorkspaceReadinessOptio }; } - const mcpTargets = await inspectMcpTargets({ + const targets = await inspectMcpTargets({ workspaceFolderPath: folder.uri.fsPath, includeUserTarget: environment.supportsUserMcpConfig }); @@ -61,7 +64,8 @@ export async function inspectWorkspaceReadiness(options: WorkspaceReadinessOptio workspaceName: folder.name, hasWorkspace: true, hasAgentsFile: await fileExists(vscode.Uri.joinPath(folder.uri, "AGENTS.md")), - hasMcpConfig: mcpTargets.some((target) => target.configured), + hasMcpConfig: targets.some((target) => target.configured), + mcpTargets: targets, workspaceCount, environmentLabel: environment.label, environmentSupport: environment.support, diff --git a/test/suite/index.ts b/test/suite/index.ts index 2dc409d..c98473a 100644 --- a/test/suite/index.ts +++ b/test/suite/index.ts @@ -13,7 +13,8 @@ const EXPECTED_COMMANDS = [ "patchloom.showStatus", "patchloom.installBinary", "patchloom.updateBinary", - "patchloom.reinstallBinary" + "patchloom.reinstallBinary", + "patchloom.verifyMcp" ]; export async function run(): Promise { diff --git a/test/unit/verifyMcp.test.ts b/test/unit/verifyMcp.test.ts new file mode 100644 index 0000000..7123541 --- /dev/null +++ b/test/unit/verifyMcp.test.ts @@ -0,0 +1,256 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseInitializeResponse, verifyMcpServer } from "../../src/commands/verifyMcp.js"; +import { buildStatusDetails } from "../../src/status/details.js"; + +// --- parseInitializeResponse --- + +test("parseInitializeResponse extracts server info from valid response", () => { + const data = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + protocolVersion: "2024-11-05", + serverInfo: { name: "patchloom", version: "0.2.0" }, + capabilities: { tools: {} } + } + }); + + const result = parseInitializeResponse(data); + assert.ok(result); + assert.equal(result.ok, true); + assert.equal(result.serverName, "patchloom"); + assert.equal(result.serverVersion, "0.2.0"); + assert.match(result.message, /patchloom/); + assert.match(result.message, /0\.2\.0/); +}); + +test("parseInitializeResponse handles response without serverInfo", () => { + const data = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + protocolVersion: "2024-11-05", + capabilities: {} + } + }); + + const result = parseInitializeResponse(data); + assert.ok(result); + assert.equal(result.ok, true); + assert.equal(result.serverName, undefined); + assert.match(result.message, /responded successfully/); +}); + +test("parseInitializeResponse detects JSON-RPC error response", () => { + const data = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + error: { code: -32600, message: "Invalid Request" } + }); + + const result = parseInitializeResponse(data); + assert.ok(result); + assert.equal(result.ok, false); + assert.match(result.message, /Invalid Request/); +}); + +test("parseInitializeResponse returns undefined for empty string", () => { + assert.equal(parseInitializeResponse(""), undefined); +}); + +test("parseInitializeResponse returns undefined for non-JSON lines", () => { + assert.equal(parseInitializeResponse("not json\nalso not json\n"), undefined); +}); + +test("parseInitializeResponse skips non-jsonrpc lines", () => { + const data = '{"status":"ok"}\n' + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { protocolVersion: "2024-11-05", capabilities: {} } + }); + + const result = parseInitializeResponse(data); + assert.ok(result); + assert.equal(result.ok, true); +}); + +test("parseInitializeResponse handles multi-line output with blank lines", () => { + const data = "\n\n" + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + protocolVersion: "2024-11-05", + serverInfo: { name: "patchloom", version: "0.1.0" }, + capabilities: {} + } + }) + "\n"; + + const result = parseInitializeResponse(data); + assert.ok(result); + assert.equal(result.ok, true); + assert.equal(result.serverName, "patchloom"); +}); + +// --- verifyMcpServer with injected spawnProcess --- + +test("verifyMcpServer returns success from injected spawn", async () => { + const result = await verifyMcpServer({ + binaryPath: "/usr/local/bin/patchloom", + spawnProcess: async () => ({ + ok: true, + serverName: "patchloom", + serverVersion: "0.2.0", + message: "MCP server verified: patchloom 0.2.0" + }) + }); + + assert.equal(result.ok, true); + assert.equal(result.serverName, "patchloom"); +}); + +test("verifyMcpServer returns failure from injected spawn", async () => { + const result = await verifyMcpServer({ + binaryPath: "/usr/local/bin/patchloom", + spawnProcess: async () => ({ + ok: false, + message: "MCP server exited with code 1. binary not found" + }) + }); + + assert.equal(result.ok, false); + assert.match(result.message, /exited with code 1/); +}); + +test("verifyMcpServer catches thrown errors from spawn", async () => { + const result = await verifyMcpServer({ + binaryPath: "/nonexistent/patchloom", + spawnProcess: async () => { + throw new Error("ENOENT: spawn failed"); + } + }); + + assert.equal(result.ok, false); + assert.match(result.message, /ENOENT/); +}); + +test("verifyMcpServer catches non-Error thrown values", async () => { + const result = await verifyMcpServer({ + binaryPath: "/nonexistent/patchloom", + spawnProcess: async () => { + throw "string error"; + } + }); + + assert.equal(result.ok, false); + assert.match(result.message, /string error/); +}); + +// --- buildStatusDetails with per-editor MCP targets --- + +test("buildStatusDetails shows per-editor MCP breakdown", () => { + const details = buildStatusDetails( + { + ready: true, + source: "path", + message: "Using Patchloom from PATH.", + binaryPath: "/usr/local/bin/patchloom", + version: "patchloom 0.1.0" + }, + { + hasWorkspace: true, + workspaceName: "demo", + hasAgentsFile: true, + hasMcpConfig: true, + mcpTargets: [ + { kind: "vscode-workspace", label: "VS Code workspace", filePath: "/demo/.vscode/mcp.json", exists: true, configured: true }, + { kind: "cursor-workspace", label: "Cursor workspace", filePath: "/demo/.cursor/mcp.json", exists: false, configured: false }, + { kind: "windsurf-user", label: "Windsurf user", filePath: "/home/.codeium/windsurf/mcp_config.json", exists: false, configured: false } + ], + workspaceCount: 1, + environmentLabel: "Local", + environmentSupport: "supported" + } + ); + + assert.match(details, /VS Code workspace.*configured/); + assert.match(details, /Cursor workspace.*not configured/); + assert.match(details, /Windsurf user.*not configured/); +}); + +test("buildStatusDetails shows fallback when mcpTargets is undefined", () => { + const details = buildStatusDetails( + { + ready: true, + source: "path", + message: "Using Patchloom from PATH.", + binaryPath: "/usr/local/bin/patchloom", + version: "patchloom 0.1.0" + }, + { + hasWorkspace: true, + workspaceName: "demo", + hasAgentsFile: true, + hasMcpConfig: true, + workspaceCount: 1, + environmentLabel: "Local", + environmentSupport: "supported" + } + ); + + assert.match(details, /MCP config: no targets available/); +}); + +test("buildStatusDetails shows checkmark for configured targets", () => { + const details = buildStatusDetails( + { + ready: true, + source: "path", + message: "Using Patchloom from PATH.", + binaryPath: "/usr/local/bin/patchloom", + version: "patchloom 0.1.0" + }, + { + hasWorkspace: true, + workspaceName: "demo", + hasAgentsFile: true, + hasMcpConfig: true, + mcpTargets: [ + { kind: "vscode-workspace", label: "VS Code workspace", filePath: "/demo/.vscode/mcp.json", exists: true, configured: true } + ], + workspaceCount: 1, + environmentLabel: "Local", + environmentSupport: "supported" + } + ); + + assert.match(details, /\u2713/); + assert.match(details, /configured/); +}); + +test("buildStatusDetails shows X for unconfigured targets", () => { + const details = buildStatusDetails( + { + ready: true, + source: "path", + message: "Using Patchloom from PATH.", + binaryPath: "/usr/local/bin/patchloom", + version: "patchloom 0.1.0" + }, + { + hasWorkspace: true, + workspaceName: "demo", + hasAgentsFile: true, + hasMcpConfig: false, + mcpTargets: [ + { kind: "cursor-workspace", label: "Cursor workspace", filePath: "/demo/.cursor/mcp.json", exists: false, configured: false } + ], + workspaceCount: 1, + environmentLabel: "Local", + environmentSupport: "supported" + } + ); + + assert.match(details, /\u2717/); + assert.match(details, /not configured/); +});