diff --git a/AGENTS.md b/AGENTS.md index a5a7abf..5d12644 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/package.json b/package.json index 4777b95..a7d3664 100644 --- a/package.json +++ b/package.json @@ -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 ./", diff --git a/src/binary/patchloom.ts b/src/binary/patchloom.ts index 0fc6e64..00556fe 100644 --- a/src/binary/patchloom.ts +++ b/src/binary/patchloom.ts @@ -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", diff --git a/src/mcp/register.ts b/src/mcp/register.ts index 3836d96..9d87967 100644 --- a/src/mcp/register.ts +++ b/src/mcp/register.ts @@ -2,36 +2,77 @@ 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[] { +): 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; + 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 | 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 { const status = await resolvePatchloomStatus(); if (status.ready && status.binaryPath) { @@ -39,23 +80,36 @@ export async function refreshMcpServerBinary(): Promise { } 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 { - 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(); + 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); diff --git a/test/suite/index.ts b/test/suite/index.ts index 9208639..505a790 100644 --- a/test/suite/index.ts +++ b/test/suite/index.ts @@ -82,6 +82,12 @@ export async function run(): Promise { 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>; + 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"); diff --git a/test/unit/binary.test.ts b/test/unit/binary.test.ts index 21ca083..70263c0 100644 --- a/test/unit/binary.test.ts +++ b/test/unit/binary.test.ts @@ -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, diff --git a/test/unit/mcpRegister.test.ts b/test/unit/mcpRegister.test.ts index f89ace4..44e3242 100644 --- a/test/unit/mcpRegister.test.ts +++ b/test/unit/mcpRegister.test.ts @@ -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"] }); });