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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ src/
test/
unit/ Unit tests (node:test, dependency-injected, no VS Code API)
batchApply.test.ts Batch template and operation count parsing (20 tests)
binary.test.ts Binary discovery, managed install, compatibility, workspace env (74 tests)
binary.test.ts Binary discovery, managed install, compatibility, workspace env (76 tests)
binaryDiscovery.test.ts Real executable discovery on PATH (13 tests)
initializeProject.test.ts Status display, agents file classification, formatError (69 tests)
managedLifecycle.test.ts Managed install with real file I/O (26 tests)
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,13 @@
"markdownDescription": "Automatically check for Patchloom CLI updates when the extension activates. Shows a notification when a newer version is available."
}
}
}
},
"mcpServerDefinitionProviders": [
{
"id": "patchloom",
"label": "Patchloom"
}
]
},
"scripts": {
"compile": "tsc -p ./",
Expand Down
16 changes: 14 additions & 2 deletions src/binary/patchloom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,13 +191,25 @@ export interface PatchloomRemediationAction {

/**
* Choose the best one-click remediation for a missing or outdated CLI.
* Prefers managed install/update (GitHub Releases) so users do not stick on
* lagging community packages (winget, Chocolatey). Pure: unit-testable without VS Code.
* A broken patchloom.path or PATH binary still wins resolution, so
* Install/Reinstall would loop. Pure: unit-testable without VS Code.
*/
export function preferredBinaryRemediationAction(
status: PatchloomStatus
): PatchloomRemediationAction | undefined {
if (!status.ready || !status.binaryPath) {
if (status.source === "setting") {
return {
title: "Open Settings",
command: "patchloom.openPatchloomSettings"
};
}
if (status.source === "path") {
return {
title: "Open Releases",
command: "patchloom.openPatchloomReleases"
};
}
if (status.managedInstall?.exists) {
return {
title: "Reinstall Patchloom",
Expand Down
96 changes: 75 additions & 21 deletions src/mcp/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,60 +2,114 @@ import type * as VSCode from "vscode";
import { resolvePatchloomStatus } from "../binary/patchloom.js";
import { getPatchloomLog } from "../logging/outputChannel.js";

/** Plain descriptor used to construct vscode.McpStdioServerDefinition at register time. */
export interface McpServerBinaryDescriptor {
readonly label: string;
readonly command: string;
readonly args: readonly string[];
}

/** Pure helper for native MCP definitions (no vscode). Empty when binary unknown. */
export function mcpServerDefinitionsForBinary(
binaryPath: string | undefined
): readonly Record<string, unknown>[] {
): readonly McpServerBinaryDescriptor[] {
if (!binaryPath) {
return [];
}
return [
{
label: "Patchloom MCP",
serverDefinition: {
type: "stdio",
command: binaryPath,
args: ["mcp-server"]
}
command: binaryPath,
args: ["mcp-server"]
}
];
}

type McpStdioServerDefinitionCtor = new (...args: readonly unknown[]) => unknown;

interface VsCodeLmWithMcp {
registerMcpServerDefinitionProvider?(
id: string,
provider: {
onDidChangeMcpServerDefinitions?: VSCode.Event<void>;
provideMcpServerDefinitions(): unknown;
}
): VSCode.Disposable;
}

interface VsCodeWithMcpApi {
EventEmitter: typeof VSCode.EventEmitter;
lm: VsCodeLmWithMcp;
McpStdioServerDefinition?: McpStdioServerDefinitionCtor;
}

let resolvedBinaryPath: string | undefined;
let providerRegistered = false;
let didChangeEmitter: VSCode.EventEmitter<void> | undefined;

type LmWithMcpProvider = typeof VSCode.lm & {
registerMCPServerDefinitionProvider?: (
id: string,
provider: { provideMCPServerDefinitions(): unknown[] }
) => VSCode.Disposable;
};
function mcpStdioCtor(vscode: VsCodeWithMcpApi): McpStdioServerDefinitionCtor | undefined {
const ctor = vscode.McpStdioServerDefinition;
return typeof ctor === "function" ? ctor : undefined;
}

/** Resolve CLI binary and update the path used by the native MCP provider. */
function createMcpStdioServerDefinition(
Ctor: McpStdioServerDefinitionCtor,
descriptor: McpServerBinaryDescriptor
): unknown | undefined {
const args = [...descriptor.args];
try {
return new Ctor({
label: descriptor.label,
command: descriptor.command,
args
});
} catch {
try {
return new Ctor(descriptor.label, descriptor.command, args);
} catch {
return undefined;
}
}
}

/** Resolve CLI binary and notify the native MCP provider so the editor list refreshes. */
export async function refreshMcpServerBinary(): Promise<void> {
const status = await resolvePatchloomStatus();
if (status.ready && status.binaryPath) {
resolvedBinaryPath = status.binaryPath;
} else {
resolvedBinaryPath = undefined;
}
didChangeEmitter?.fire();
}

/**
* Always register the native MCP provider when the API exists.
* Binary path is resolved at provide time and refreshed after managed install.
* Always register the native MCP provider when the VS Code 1.100+ API exists.
* Binary path is resolved at provide time and refreshed after install/settings/trust.
*/
export async function registerMcpServerProviderWithBinary(context: VSCode.ExtensionContext): Promise<void> {
const vscode = await import("vscode");
const lm = vscode.lm as LmWithMcpProvider;
if (typeof lm.registerMCPServerDefinitionProvider !== "function") {
const vscode = await import("vscode") as unknown as VsCodeWithMcpApi;
const Ctor = mcpStdioCtor(vscode);
if (typeof vscode.lm.registerMcpServerDefinitionProvider !== "function" || Ctor === undefined) {
return;
}

if (!providerRegistered) {
const disposable = lm.registerMCPServerDefinitionProvider("patchloom", {
provideMCPServerDefinitions() {
return [...mcpServerDefinitionsForBinary(resolvedBinaryPath)];
const emitter = new vscode.EventEmitter<void>();
didChangeEmitter = emitter;
context.subscriptions.push(emitter);

const disposable = vscode.lm.registerMcpServerDefinitionProvider("patchloom", {
onDidChangeMcpServerDefinitions: emitter.event,
provideMcpServerDefinitions: async () => {
const definitions: unknown[] = [];
for (const descriptor of mcpServerDefinitionsForBinary(resolvedBinaryPath)) {
const definition = createMcpStdioServerDefinition(Ctor, descriptor);
if (definition !== undefined) {
definitions.push(definition);
}
}
return definitions;
}
});
context.subscriptions.push(disposable);
Expand Down
6 changes: 6 additions & 0 deletions test/suite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ export async function run(): Promise<void> {
assert.equal(statusBarSchema.type, "boolean", "patchloom.showStatusBar should be boolean type");
assert.equal(statusBarSchema.default, true, "patchloom.showStatusBar default should be true");

const mcpProviders = contributes.mcpServerDefinitionProviders as Array<Record<string, unknown>>;
assert.ok(Array.isArray(mcpProviders) && mcpProviders.length === 1,
"should contribute exactly one mcpServerDefinitionProvider");
assert.equal(mcpProviders[0].id, "patchloom", "mcp provider id should be patchloom");
assert.equal(mcpProviders[0].label, "Patchloom", "mcp provider label should be Patchloom");

// New settings contributed
assert.ok(properties["patchloom.enable"], "should contribute patchloom.enable setting");
assert.ok(properties["patchloom.trace.server"], "should contribute patchloom.trace.server setting");
Expand Down
46 changes: 46 additions & 0 deletions test/unit/binary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,52 @@ test("preferredBinaryRemediationAction reinstalls when managed binary present bu
});
});

test("preferredBinaryRemediationAction opens settings when patchloom.path is not ready even if managed exists", () => {
const action = preferredBinaryRemediationAction({
ready: false,
source: "setting",
message: "Patchloom binary is not executable: /does/not/exist",
binaryPath: "/does/not/exist",
managedInstall: {
exists: true,
binaryPath: "/managed/managed-bin/patchloom",
target: {
platform: "darwin",
arch: "arm64",
targetTriple: "aarch64-apple-darwin",
archiveFormat: ".tar.xz"
}
}
});
assert.deepEqual(action, {
title: "Open Settings",
command: "patchloom.openPatchloomSettings"
});
});

test("preferredBinaryRemediationAction opens releases when PATH is not ready even if managed exists", () => {
const action = preferredBinaryRemediationAction({
ready: false,
source: "path",
message: "Patchloom binary is not executable: /usr/local/bin/patchloom",
binaryPath: "/usr/local/bin/patchloom",
managedInstall: {
exists: true,
binaryPath: "/managed/managed-bin/patchloom",
target: {
platform: "darwin",
arch: "arm64",
targetTriple: "aarch64-apple-darwin",
archiveFormat: ".tar.xz"
}
}
});
assert.deepEqual(action, {
title: "Open Releases",
command: "patchloom.openPatchloomReleases"
});
});

test("preferredBinaryRemediationAction updates outdated managed install", () => {
const action = preferredBinaryRemediationAction({
ready: true,
Expand Down
7 changes: 2 additions & 5 deletions test/unit/mcpRegister.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@ test("mcpServerDefinitionsForBinary returns one stdio definition for a path", ()
assert.equal(defs.length, 1);
assert.deepEqual(defs[0], {
label: "Patchloom MCP",
serverDefinition: {
type: "stdio",
command: "/opt/patchloom",
args: ["mcp-server"]
}
command: "/opt/patchloom",
args: ["mcp-server"]
});
});
Loading