Skip to content
59 changes: 58 additions & 1 deletion src/features/settings/settings.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -343,6 +344,60 @@ export class SettingsController {
this.reload();
}

private async maybeSaveToRegistry(command: string): Promise<string> {
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,
Comment thread
FernandoCelmer marked this conversation as resolved.
});
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}`;
}
Comment thread
FernandoCelmer marked this conversation as resolved.

async toggleWorkspace(next: boolean): Promise<void> {
await updateSetting("workspace", next);
await this.postSettings();
Expand Down Expand Up @@ -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) {
Expand Down
71 changes: 71 additions & 0 deletions src/services/mcpRegistry.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

export class McpServerNameTakenError extends Error {
constructor(readonly name: string) {
super(`A server named "${name}" already exists in the registry.`);
}
}

async function readConfig(): Promise<Record<string, unknown>> {
try {
return JSON.parse(await fs.promises.readFile(CONFIG_PATH, "utf8"));
} catch {
Comment thread
FernandoCelmer marked this conversation as resolved.
Comment thread
FernandoCelmer marked this conversation as resolved.
return {};
}
}

async function writeConfig(data: Record<string, unknown>): Promise<void> {
await fs.promises.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
await fs.promises.writeFile(CONFIG_PATH, JSON.stringify(data, null, 2));
}
Comment thread
FernandoCelmer marked this conversation as resolved.

// 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<Record<string, SavedMcpServer>> {
const data = await readConfig();
return (data[SECTION] as Record<string, SavedMcpServer>) ?? {};
}

export async function saveMcpServer(
name: string,
server: SavedMcpServer,
{ overwrite = false }: { overwrite?: boolean } = {}
): Promise<void> {
const data = await readConfig();
const servers = (data[SECTION] as Record<string, SavedMcpServer>) ?? {};
if (servers[name] && !overwrite) {
throw new McpServerNameTakenError(name);
}
servers[name] = server;
data[SECTION] = servers;
Comment thread
FernandoCelmer marked this conversation as resolved.
await writeConfig(data);
}

export async function deleteSavedMcpServer(name: string): Promise<void> {
const data = await readConfig();
const servers = (data[SECTION] as Record<string, SavedMcpServer>) ?? {};
delete servers[name];
data[SECTION] = servers;
await writeConfig(data);
}
35 changes: 35 additions & 0 deletions test/mcpRegistry.service.test.ts
Original file line number Diff line number Diff line change
@@ -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"],
});
});
Loading