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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,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

Expand Down Expand Up @@ -92,6 +102,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 |
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
"onCommand:patchloom.showOutput",
"onCommand:patchloom.installBinary",
"onCommand:patchloom.updateBinary",
"onCommand:patchloom.reinstallBinary"
"onCommand:patchloom.reinstallBinary",
"onCommand:patchloom.verifyMcp"
],
"contributes": {
"commands": [
Expand Down Expand Up @@ -114,6 +115,11 @@
"command": "patchloom.reinstallBinary",
"title": "Reinstall Patchloom",
"category": "Patchloom"
},
{
"command": "patchloom.verifyMcp",
"title": "Verify MCP Server",
"category": "Patchloom"
}
],
"configuration": {
Expand Down
26 changes: 14 additions & 12 deletions src/commands/setupWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,32 +37,34 @@ export async function setupWorkspace(): Promise<void> {

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<void> {
Expand Down
182 changes: 182 additions & 0 deletions src/commands/verifyMcp.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<VerifyMcpResult> {
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<VerifyMcpResult> {
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<string, unknown>;
if (response.jsonrpc !== "2.0") {
continue;
}

if (response.error) {
const error = response.error as Record<string, unknown>;
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<string, unknown>;
const serverInfo = result.serverInfo as Record<string, unknown> | 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;
}
2 changes: 2 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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")) {
Expand Down
16 changes: 13 additions & 3 deletions src/status/details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
}

Expand Down Expand Up @@ -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"}`;
});
}
8 changes: 5 additions & 3 deletions src/status/statusBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ export async function refreshStatusBar(): Promise<void> {
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();
Expand Down
16 changes: 10 additions & 6 deletions src/workspace/readiness.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
Expand All @@ -40,19 +41,21 @@ 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,
environmentNote: environment.note
};
}

const mcpTargets = await inspectMcpTargets({
const targets = await inspectMcpTargets({
workspaceFolderPath: folder.uri.fsPath,
includeUserTarget: environment.supportsUserMcpConfig
});
Expand All @@ -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,
Expand Down
Loading
Loading