From db3090fff74833473d9d0fdd72ede85efe2f8fe5 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sat, 15 Aug 2026 23:35:26 -0300 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#36:=20Add=20r?= =?UTF-8?q?eader/writer=20for=20pycodeloop's=20saved=20MCP=20server=20regi?= =?UTF-8?q?stry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/mcpRegistry.service.ts | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/services/mcpRegistry.service.ts diff --git a/src/services/mcpRegistry.service.ts b/src/services/mcpRegistry.service.ts new file mode 100644 index 0000000..882d502 --- /dev/null +++ b/src/services/mcpRegistry.service.ts @@ -0,0 +1,65 @@ +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; +} + +function readConfig(): Record { + try { + return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); + } catch { + return {}; + } +} + +function writeConfig(data: Record): void { + fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }); + fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2)); +} + +/** Split a shell-style command string into `command`/`args`, mirroring + * `shlex.split()` on the pycodeloop side (`cli/flow.py`'s `_load_mcp_tools`) + * closely enough for the common case: whitespace-separated tokens with + * optional single/double-quoted segments. */ +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 }; +} + +/** Named MCP server configs from `~/.pycodeloop/config.json`'s + * `"mcp_servers"` section — the same store `MCPServerRegistry` (pycodeloop) + * reads/writes, so a server saved here is usable as `--mcp saved:` + * from both the extension and the CLI. */ +export function listSavedMcpServers(): Record { + const data = readConfig(); + return (data[SECTION] as Record) ?? {}; +} + +export function saveMcpServer(name: string, server: SavedMcpServer): void { + const data = readConfig(); + const servers = (data[SECTION] as Record) ?? {}; + servers[name] = server; + data[SECTION] = servers; + writeConfig(data); +} + +export function deleteSavedMcpServer(name: string): void { + const data = readConfig(); + const servers = (data[SECTION] as Record) ?? {}; + delete servers[name]; + data[SECTION] = servers; + writeConfig(data); +} From c86f3d5be3c50cf999623d2ae76a7aed64f774c6 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sat, 15 Aug 2026 23:35:27 -0300 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9D=A4=EF=B8=8F=20TEST-#36:=20Cover=20sp?= =?UTF-8?q?litCommand=20tokenizing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/mcpRegistry.service.test.ts | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 test/mcpRegistry.service.test.ts 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"], + }); +}); From 26fe7c2b27d68ca02eefc14a8c39c03cd702b1ae Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sat, 15 Aug 2026 23:35:27 -0300 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20FEATURE-#36:=20Offer?= =?UTF-8?q?=20to=20save=20new=20MCP=20servers=20into=20the=20shared=20pyco?= =?UTF-8?q?deloop=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/settings/settings.controller.ts | 34 +++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/features/settings/settings.controller.ts b/src/features/settings/settings.controller.ts index 916f95f..e79ac56 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 { saveMcpServer, splitCommand } from "../../services/mcpRegistry.service"; export class SettingsController { constructor( @@ -342,6 +343,35 @@ export class SettingsController { this.reload(); } + /** Offers to save a newly-typed MCP launch command into pycodeloop's + * native `saved:` registry (`~/.pycodeloop/config.json`) instead + * of keeping only the raw command in VS Code settings — so the same + * server is usable from the CLI (`--mcp saved:`) too. Returns + * the entry to store in `pycodeloop.mcpServers`: `saved:` if the + * user opted in, otherwise the raw command unchanged. */ + 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; + } + + saveMcpServer(name, { command: cmd, args }); + return `saved:${name}`; + } + async manageMcpServers(): Promise { const servers = readSettings().mcpServers; @@ -363,7 +393,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) { From c2faa205543cc717a5b6f37df2d53e7e8580a425 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sat, 15 Aug 2026 23:36:07 -0300 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=93=9D=20LINT-#36:=20Drop=20JSDoc-sty?= =?UTF-8?q?le=20comment=20blocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/settings/settings.controller.ts | 6 ------ src/services/mcpRegistry.service.ts | 9 +-------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/features/settings/settings.controller.ts b/src/features/settings/settings.controller.ts index e79ac56..eb64e63 100644 --- a/src/features/settings/settings.controller.ts +++ b/src/features/settings/settings.controller.ts @@ -343,12 +343,6 @@ export class SettingsController { this.reload(); } - /** Offers to save a newly-typed MCP launch command into pycodeloop's - * native `saved:` registry (`~/.pycodeloop/config.json`) instead - * of keeping only the raw command in VS Code settings — so the same - * server is usable from the CLI (`--mcp saved:`) too. Returns - * the entry to store in `pycodeloop.mcpServers`: `saved:` if the - * user opted in, otherwise the raw command unchanged. */ 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?", diff --git a/src/services/mcpRegistry.service.ts b/src/services/mcpRegistry.service.ts index 882d502..5d560b4 100644 --- a/src/services/mcpRegistry.service.ts +++ b/src/services/mcpRegistry.service.ts @@ -24,10 +24,7 @@ function writeConfig(data: Record): void { fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2)); } -/** Split a shell-style command string into `command`/`args`, mirroring - * `shlex.split()` on the pycodeloop side (`cli/flow.py`'s `_load_mcp_tools`) - * closely enough for the common case: whitespace-separated tokens with - * optional single/double-quoted segments. */ +// 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) => @@ -39,10 +36,6 @@ export function splitCommand(command: string): { command: string; args: string[] return { command: head ?? "", args: rest }; } -/** Named MCP server configs from `~/.pycodeloop/config.json`'s - * `"mcp_servers"` section — the same store `MCPServerRegistry` (pycodeloop) - * reads/writes, so a server saved here is usable as `--mcp saved:` - * from both the extension and the CLI. */ export function listSavedMcpServers(): Record { const data = readConfig(); return (data[SECTION] as Record) ?? {}; From 83a608f6fec5febe6621f8128b935af65daf74c7 Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sun, 16 Aug 2026 00:11:25 -0300 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=AA=B2=20BUG-#36:=20Make=20registry?= =?UTF-8?q?=20I/O=20async,=20guard=20write=20errors,=20and=20confirm=20nam?= =?UTF-8?q?e=20collisions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/mcpRegistry.service.ts | 39 +++++++++++++++++++---------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/services/mcpRegistry.service.ts b/src/services/mcpRegistry.service.ts index 5d560b4..509fd1e 100644 --- a/src/services/mcpRegistry.service.ts +++ b/src/services/mcpRegistry.service.ts @@ -11,17 +11,23 @@ export interface SavedMcpServer { env?: Record; } -function readConfig(): 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(fs.readFileSync(CONFIG_PATH, "utf8")); + return JSON.parse(await fs.promises.readFile(CONFIG_PATH, "utf8")); } catch { return {}; } } -function writeConfig(data: Record): void { - fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }); - fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2)); +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). @@ -36,23 +42,30 @@ export function splitCommand(command: string): { command: string; args: string[] return { command: head ?? "", args: rest }; } -export function listSavedMcpServers(): Record { - const data = readConfig(); +export async function listSavedMcpServers(): Promise> { + const data = await readConfig(); return (data[SECTION] as Record) ?? {}; } -export function saveMcpServer(name: string, server: SavedMcpServer): void { - const data = readConfig(); +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; - writeConfig(data); + await writeConfig(data); } -export function deleteSavedMcpServer(name: string): void { - const data = readConfig(); +export async function deleteSavedMcpServer(name: string): Promise { + const data = await readConfig(); const servers = (data[SECTION] as Record) ?? {}; delete servers[name]; data[SECTION] = servers; - writeConfig(data); + await writeConfig(data); } From 7ea80dbcc9029b82474d0e672614e5e45c40dd8f Mon Sep 17 00:00:00 2001 From: Fernando Celmer Date: Sun, 16 Aug 2026 00:11:25 -0300 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=AA=B2=20BUG-#36:=20Handle=20registry?= =?UTF-8?q?=20save=20failures=20and=20name=20conflicts=20in=20maybeSaveToR?= =?UTF-8?q?egistry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/settings/settings.controller.ts | 35 ++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/features/settings/settings.controller.ts b/src/features/settings/settings.controller.ts index eb64e63..d8d69b3 100644 --- a/src/features/settings/settings.controller.ts +++ b/src/features/settings/settings.controller.ts @@ -5,7 +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 { saveMcpServer, splitCommand } from "../../services/mcpRegistry.service"; +import { McpServerNameTakenError, saveMcpServer, splitCommand } from "../../services/mcpRegistry.service"; export class SettingsController { constructor( @@ -362,7 +362,38 @@ export class SettingsController { return command; } - saveMcpServer(name, { command: cmd, args }); + 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}`; }