diff --git a/src/features/settings/settings.controller.ts b/src/features/settings/settings.controller.ts index af9c9c1..50fae92 100644 --- a/src/features/settings/settings.controller.ts +++ b/src/features/settings/settings.controller.ts @@ -5,6 +5,7 @@ import { API_KEY_SECRET, providerKeySecret } from "../../services/credentials.se import { PostMessage, ResolveSetting } from "../chat/chat.types"; import { PROVIDER_CATALOG, findProviderDef } from "./providerCatalog"; import { ADD_MCP_SERVER_LABEL, addServer, parseServerLabel, removeServer, toQuickPickLabels } from "./mcpServerList"; +import { McpServerNameTakenError, saveMcpServer, splitCommand } from "../../services/mcpRegistry.service"; export class SettingsController { constructor( @@ -343,6 +344,60 @@ export class SettingsController { this.reload(); } + private async maybeSaveToRegistry(command: string): Promise { + const choice = await vscode.window.showQuickPick(["Just this workspace", "Save for reuse in the CLI too"], { + title: "Save this MCP server for pycodeloop CLI reuse?", + }); + if (choice !== "Save for reuse in the CLI too") { + return command; + } + + const { command: cmd, args } = splitCommand(command); + const defaultName = path.basename(cmd).replace(/\.[^.]+$/, "") || "server"; + const name = await vscode.window.showInputBox({ + title: "Name for this saved MCP server", + value: defaultName, + ignoreFocusOut: true, + }); + if (!name) { + return command; + } + + let overwrite = false; + try { + await saveMcpServer(name, { command: cmd, args }); + } catch (err) { + if (!(err instanceof McpServerNameTakenError)) { + vscode.window.showErrorMessage( + `Couldn't save "${name}" to the pycodeloop registry: ${(err as Error).message}` + ); + return command; + } + const action = await vscode.window.showWarningMessage( + `"${name}" already exists in the pycodeloop registry. Overwrite it?`, + "Overwrite", + "Cancel" + ); + if (action !== "Overwrite") { + return command; + } + overwrite = true; + } + + if (overwrite) { + try { + await saveMcpServer(name, { command: cmd, args }, { overwrite: true }); + } catch (err) { + vscode.window.showErrorMessage( + `Couldn't save "${name}" to the pycodeloop registry: ${(err as Error).message}` + ); + return command; + } + } + + return `saved:${name}`; + } + async toggleWorkspace(next: boolean): Promise { await updateSetting("workspace", next); await this.postSettings(); @@ -370,7 +425,9 @@ export class SettingsController { if (!command) { return; } - await updateSetting("mcpServers", addServer(servers, command)); + + const entry = await this.maybeSaveToRegistry(command); + await updateSetting("mcpServers", addServer(servers, entry)); } else { const removed = parseServerLabel(picked); if (!removed) { diff --git a/src/services/mcpRegistry.service.ts b/src/services/mcpRegistry.service.ts new file mode 100644 index 0000000..509fd1e --- /dev/null +++ b/src/services/mcpRegistry.service.ts @@ -0,0 +1,71 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const CONFIG_PATH = path.join(os.homedir(), ".pycodeloop", "config.json"); +const SECTION = "mcp_servers"; + +export interface SavedMcpServer { + command: string; + args: string[]; + env?: Record; +} + +export class McpServerNameTakenError extends Error { + constructor(readonly name: string) { + super(`A server named "${name}" already exists in the registry.`); + } +} + +async function readConfig(): Promise> { + try { + return JSON.parse(await fs.promises.readFile(CONFIG_PATH, "utf8")); + } catch { + return {}; + } +} + +async function writeConfig(data: Record): Promise { + await fs.promises.mkdir(path.dirname(CONFIG_PATH), { recursive: true }); + await fs.promises.writeFile(CONFIG_PATH, JSON.stringify(data, null, 2)); +} + +// Mirrors shlex.split() on the pycodeloop side (cli/flow.py's _load_mcp_tools). +export function splitCommand(command: string): { command: string; args: string[] } { + const tokens = command.match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + const unquoted = tokens.map((t) => + (t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")) + ? t.slice(1, -1) + : t + ); + const [head, ...rest] = unquoted; + return { command: head ?? "", args: rest }; +} + +export async function listSavedMcpServers(): Promise> { + const data = await readConfig(); + return (data[SECTION] as Record) ?? {}; +} + +export async function saveMcpServer( + name: string, + server: SavedMcpServer, + { overwrite = false }: { overwrite?: boolean } = {} +): Promise { + const data = await readConfig(); + const servers = (data[SECTION] as Record) ?? {}; + if (servers[name] && !overwrite) { + throw new McpServerNameTakenError(name); + } + servers[name] = server; + data[SECTION] = servers; + await writeConfig(data); +} + +export async function deleteSavedMcpServer(name: string): Promise { + const data = await readConfig(); + const servers = (data[SECTION] as Record) ?? {}; + delete servers[name]; + data[SECTION] = servers; + await writeConfig(data); +} diff --git a/test/mcpRegistry.service.test.ts b/test/mcpRegistry.service.test.ts new file mode 100644 index 0000000..93ba83c --- /dev/null +++ b/test/mcpRegistry.service.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { splitCommand } from "../src/services/mcpRegistry.service"; + +test("splitCommand splits a bare command with no args", () => { + assert.deepEqual(splitCommand("npx"), { command: "npx", args: [] }); +}); + +test("splitCommand splits command and positional args", () => { + assert.deepEqual(splitCommand("npx -y @modelcontextprotocol/server-filesystem ."), { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "."], + }); +}); + +test("splitCommand keeps a double-quoted argument as one token", () => { + assert.deepEqual(splitCommand('node server.js "a path/with spaces"'), { + command: "node", + args: ["server.js", "a path/with spaces"], + }); +}); + +test("splitCommand keeps a single-quoted argument as one token", () => { + assert.deepEqual(splitCommand("node server.js 'a path/with spaces'"), { + command: "node", + args: ["server.js", "a path/with spaces"], + }); +}); + +test("splitCommand collapses extra whitespace between tokens", () => { + assert.deepEqual(splitCommand("npx -y server"), { + command: "npx", + args: ["-y", "server"], + }); +});