Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
3 changes: 2 additions & 1 deletion test/suite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ const EXPECTED_COMMANDS = [
"patchloom.showStatus",
"patchloom.installBinary",
"patchloom.updateBinary",
"patchloom.reinstallBinary"
"patchloom.reinstallBinary",
"patchloom.verifyMcp"
];

export async function run(): Promise<void> {
Expand Down
Loading
Loading