From 1646ec3e290c1675d8bf03f1448ff444199778a5 Mon Sep 17 00:00:00 2001 From: Uka Osim Date: Sun, 11 Oct 2026 22:48:10 +0100 Subject: [PATCH 1/8] Prepare v0.4.2 release Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a4fa06f..360d8e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@4onstudios/iris-agent", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@4onstudios/iris-agent", - "version": "0.4.1", + "version": "0.4.2", "license": "MIT", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", diff --git a/package.json b/package.json index 1995f15..1daca0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@4onstudios/iris-agent", - "version": "0.4.1", + "version": "0.4.2", "private": false, "description": "Standalone HTTP coding-agent service and CLI with ACP (Agent Client Protocol) support.", "type": "module", From 3f39d7b49733adc2b81dfa33f63531bc48a712c4 Mon Sep 17 00:00:00 2001 From: Uka Osim Date: Tue, 13 Oct 2026 18:00:32 +0100 Subject: [PATCH 2/8] Add MCP config option to CLI chat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 25 +++++++++++++++++++++++++ api/core/library/mcpSettings.ts | 27 +++++++++++++++++++++++++++ cli.ts | 21 ++++++++++++++++++++- tests/mcpSettings.test.ts | 25 +++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c09e4b0..f40e6ee 100644 --- a/README.md +++ b/README.md @@ -134,12 +134,37 @@ OPENROUTER_API_KEY=... npm run cli -- --workspace /path/to/project --chat # Interactive chat with a specific model OPENROUTER_API_KEY=... npm run cli -- --workspace /path/to/project --chat --modelId openrouter/anthropic/claude-3.7-sonnet + +# Interactive chat with MCP servers from a JSON config file +OPENROUTER_API_KEY=... npm run cli -- --workspace /path/to/project --chat --mcp-config ./mcp-servers.json ``` This starts an interactive streaming chat session in your terminal with access to the workspace and tools. OpenTUI is selected automatically on supported interactive terminals. With Node.js >=26.4.0, enable FFI to use it with the same npm launcher: +The config file can contain an array of MCP server definitions or an object with +an `mcpServers` array. For example, create `mcp-servers.json`: + +```json +{ + "mcpServers": [ + { + "id": "local-tools", + "name": "Local tools", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"], + "enabled": true + } + ] +} +``` + +Use `--mcp-config ` with `--chat`; relative paths are resolved from the +current directory. Local server processes inherit the CLI environment, so set +required credentials in the shell before launching. Keep config files containing +secrets out of source control. + ```sh # With OPENROUTER_API_KEY already configured in your terminal NODE_OPTIONS="--experimental-ffi" npm run cli -- --workspace . --chat diff --git a/api/core/library/mcpSettings.ts b/api/core/library/mcpSettings.ts index 86d4924..7c8c0c2 100644 --- a/api/core/library/mcpSettings.ts +++ b/api/core/library/mcpSettings.ts @@ -264,6 +264,33 @@ export const sanitizeMcpServers = ( .slice(0, maxServers); }; +export const parseMcpServerConfigFile = (contents: string): McpServerConfig[] => { + let parsed: unknown; + try { + parsed = JSON.parse(contents); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`MCP config is not valid JSON: ${message}`); + } + + const servers = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record).mcpServers + : parsed; + if (!Array.isArray(servers)) { + throw new Error('MCP config must be an array or an object with a "mcpServers" array'); + } + if (servers.length > MAX_MCP_SERVERS) { + throw new Error(`MCP config supports at most ${MAX_MCP_SERVERS} servers`); + } + + const sanitizedServers = sanitizeMcpServers(servers); + if (sanitizedServers.length !== servers.length) { + throw new Error("MCP config contains an invalid server entry"); + } + return sanitizedServers; +}; + export const toStableMcpFingerprint = (servers: McpServerConfig[]): string => { const normalized = servers .map((server) => ({ diff --git a/cli.ts b/cli.ts index d71d6b0..4ac79fe 100644 --- a/cli.ts +++ b/cli.ts @@ -9,9 +9,12 @@ import yargs from "yargs"; import { hideBin } from "yargs/helpers"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; import { createCodingAgent } from "./api/core/agent/index.js"; import { startAcpServer } from "./api/acp/acpServer.js"; import { getMissingProviderSetup } from "./api/core/library/providerSetup.js"; +import { parseMcpServerConfigFile } from "./api/core/library/mcpSettings.js"; import { createCliChatUi, type CliChatUiMode, @@ -58,6 +61,10 @@ const argv = yargs(hideBin(process.argv)) description: "Language model identifier", default: defaultModelId, }) + .option("mcp-config", { + type: "string", + description: "Path to a JSON file containing MCP server configuration (chat mode)", + }) .help() .parseSync(); @@ -65,6 +72,10 @@ async function main() { const workspaceRoot = argv.workspace as string; const modelId = argv.modelId; + if (argv.mcpConfig && (!argv.chat || argv.acp)) { + throw new Error("--mcp-config can only be used with --chat"); + } + if (argv.acp) { const stderrLog = console.error.bind(console); console.log = stderrLog; @@ -108,7 +119,15 @@ async function main() { process.exitCode = 1; return; } - const agent = await createCodingAgent(modelId, workspaceRoot); + const mcpServers = argv.mcpConfig + ? parseMcpServerConfigFile( + await readFile(resolve(argv.mcpConfig), "utf8"), + ) + : []; + const agent = await createCodingAgent(modelId, workspaceRoot, { mcpServers }); + if (mcpServers.length > 0) { + console.log(`🧩 MCP servers configured: ${mcpServers.length}`); + } console.log(`💬 Entering chat mode (type "exit" to quit)`); await startChatMode(agent, workspaceRoot, modelId, argv["chat-ui"] as CliChatUiMode); } else { diff --git a/tests/mcpSettings.test.ts b/tests/mcpSettings.test.ts index 41d9e79..f3c14bd 100644 --- a/tests/mcpSettings.test.ts +++ b/tests/mcpSettings.test.ts @@ -1,4 +1,5 @@ import { + parseMcpServerConfigFile, sanitizeMcpServer, toStableMcpFingerprint, redactMcpUrlForDisplay, @@ -46,6 +47,30 @@ describe("MCP server settings", () => { ).toBeNull(); }); + it("parses MCP config files as arrays or mcpServers objects", () => { + const server = { + id: "local-tools", + name: "Local tools", + command: "npx", + args: ["-y", "example-mcp-server"], + }; + + expect(parseMcpServerConfigFile(JSON.stringify([server]))).toMatchObject([ + { ...server, env: {}, enabled: true, command: "npx" }, + ]); + expect( + parseMcpServerConfigFile(JSON.stringify({ mcpServers: [server] })), + ).toMatchObject([{ ...server, env: {}, enabled: true, command: "npx" }]); + }); + + it("rejects malformed MCP config files and invalid server entries", () => { + expect(() => parseMcpServerConfigFile("{")).toThrow("not valid JSON"); + expect(() => parseMcpServerConfigFile("{}")).toThrow("mcpServers"); + expect(() => parseMcpServerConfigFile(JSON.stringify([{}]))).toThrow( + "invalid server entry", + ); + }); + it("rejects the whole config when a malformed URL is paired with a stale command, instead of falling back to local mode", () => { // A config that was previously remote but now has a broken/unsupported // url must not silently resurrect the leftover `command` and launch a From a75d816668d0ea117ca33cded9421fe8b79860d0 Mon Sep 17 00:00:00 2001 From: Uka Osim Date: Tue, 13 Oct 2026 18:15:18 +0100 Subject: [PATCH 3/8] Address CLI MCP config review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 28 ++++++++++++++-------------- api/core/library/mcpSettings.ts | 11 +++++++++++ tests/mcpSettings.test.ts | 5 +++++ 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index f40e6ee..ab7b6ad 100644 --- a/README.md +++ b/README.md @@ -143,8 +143,20 @@ This starts an interactive streaming chat session in your terminal with access t OpenTUI is selected automatically on supported interactive terminals. With Node.js >=26.4.0, enable FFI to use it with the same npm launcher: -The config file can contain an array of MCP server definitions or an object with -an `mcpServers` array. For example, create `mcp-servers.json`: +```sh +# With OPENROUTER_API_KEY already configured in your terminal +NODE_OPTIONS="--experimental-ffi" npm run cli -- --workspace . --chat + +# Require OpenTUI rather than allowing a fallback to plain chat +NODE_OPTIONS="--experimental-ffi" npm run cli -- --workspace . --chat --chat-ui opentui +``` + +Keep `NODE_OPTIONS="--experimental-ffi"` scoped to the Node.js OpenTUI launch +command rather than exporting it globally. It is not needed for Bun, plain chat, +the HTTP service or ACP, and older Node.js versions may reject the flag. + +The MCP config file can contain an array of server definitions or an object +with an `mcpServers` array. For example, create `mcp-servers.json`: ```json { @@ -165,18 +177,6 @@ current directory. Local server processes inherit the CLI environment, so set required credentials in the shell before launching. Keep config files containing secrets out of source control. -```sh -# With OPENROUTER_API_KEY already configured in your terminal -NODE_OPTIONS="--experimental-ffi" npm run cli -- --workspace . --chat - -# Require OpenTUI rather than allowing a fallback to plain chat -NODE_OPTIONS="--experimental-ffi" npm run cli -- --workspace . --chat --chat-ui opentui -``` - -Keep `NODE_OPTIONS="--experimental-ffi"` scoped to the Node.js OpenTUI launch -command rather than exporting it globally. It is not needed for Bun, plain chat, -the HTTP service or ACP, and older Node.js versions may reject the flag. - #### Classic terminal chat (readline) To use the old terminal chat instead of OpenTUI, explicitly select `plain`: diff --git a/api/core/library/mcpSettings.ts b/api/core/library/mcpSettings.ts index 7c8c0c2..7ed2ac7 100644 --- a/api/core/library/mcpSettings.ts +++ b/api/core/library/mcpSettings.ts @@ -283,6 +283,17 @@ export const parseMcpServerConfigFile = (contents: string): McpServerConfig[] => if (servers.length > MAX_MCP_SERVERS) { throw new Error(`MCP config supports at most ${MAX_MCP_SERVERS} servers`); } + for (const server of servers) { + if ( + server && + typeof server === "object" && + !Array.isArray(server) && + "enabled" in server && + typeof server.enabled !== "boolean" + ) { + throw new Error('MCP config server "enabled" value must be a boolean'); + } + } const sanitizedServers = sanitizeMcpServers(servers); if (sanitizedServers.length !== servers.length) { diff --git a/tests/mcpSettings.test.ts b/tests/mcpSettings.test.ts index f3c14bd..1364cdc 100644 --- a/tests/mcpSettings.test.ts +++ b/tests/mcpSettings.test.ts @@ -69,6 +69,11 @@ describe("MCP server settings", () => { expect(() => parseMcpServerConfigFile(JSON.stringify([{}]))).toThrow( "invalid server entry", ); + expect(() => + parseMcpServerConfigFile( + JSON.stringify([{ command: "npx", enabled: "false" }]), + ), + ).toThrow('"enabled" value must be a boolean'); }); it("rejects the whole config when a malformed URL is paired with a stale command, instead of falling back to local mode", () => { From 3e85d49acbe15d945b27fb381f4a9c21573487ba Mon Sep 17 00:00:00 2001 From: Uka Osim Date: Tue, 13 Oct 2026 18:27:07 +0100 Subject: [PATCH 4/8] Support keyed MCP config and validate executables Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 17 ++-- api/core/library/mcpSettings.ts | 155 ++++++++++++++++++++++++++++---- tests/mcpSettings.test.ts | 38 +++++++- 3 files changed, 181 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index ab7b6ad..7eccf4e 100644 --- a/README.md +++ b/README.md @@ -155,20 +155,19 @@ Keep `NODE_OPTIONS="--experimental-ffi"` scoped to the Node.js OpenTUI launch command rather than exporting it globally. It is not needed for Bun, plain chat, the HTTP service or ACP, and older Node.js versions may reject the flag. -The MCP config file can contain an array of server definitions or an object -with an `mcpServers` array. For example, create `mcp-servers.json`: +The MCP config file can contain a server array or an object with `mcpServers` +as either an array or a map keyed by server name. Keyed entries inherit their +key as the server `id` and `name` unless those fields are explicitly set. +For example, create `mcp-servers.json`: ```json { - "mcpServers": [ - { - "id": "local-tools", - "name": "Local tools", + "mcpServers": { + "filesystem": { "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"], - "enabled": true + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"] } - ] + } } ``` diff --git a/api/core/library/mcpSettings.ts b/api/core/library/mcpSettings.ts index 7ed2ac7..32beb18 100644 --- a/api/core/library/mcpSettings.ts +++ b/api/core/library/mcpSettings.ts @@ -273,33 +273,152 @@ export const parseMcpServerConfigFile = (contents: string): McpServerConfig[] => throw new Error(`MCP config is not valid JSON: ${message}`); } - const servers = - parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record).mcpServers - : parsed; - if (!Array.isArray(servers)) { - throw new Error('MCP config must be an array or an object with a "mcpServers" array'); + let servers: unknown[]; + if (Array.isArray(parsed)) { + servers = parsed; + } else if (parsed && typeof parsed === "object") { + const configuredServers = (parsed as Record).mcpServers; + if (Array.isArray(configuredServers)) { + servers = configuredServers; + } else if (configuredServers && typeof configuredServers === "object") { + servers = Object.entries(configuredServers).map(([key, value]) => { + if (!key.trim() || key !== key.trim() || key.length > MAX_NAME_LENGTH) { + throw new Error(`MCP config has an invalid server key: "${key}"`); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value; + } + const server = value as Record; + return { + ...server, + id: server.id ?? key, + name: server.name ?? key, + }; + }); + } else { + throw new Error( + 'MCP config must be an array or an object with a "mcpServers" array or object', + ); + } + } else { + throw new Error( + 'MCP config must be an array or an object with a "mcpServers" array or object', + ); } if (servers.length > MAX_MCP_SERVERS) { throw new Error(`MCP config supports at most ${MAX_MCP_SERVERS} servers`); } - for (const server of servers) { - if ( - server && - typeof server === "object" && - !Array.isArray(server) && - "enabled" in server && - typeof server.enabled !== "boolean" - ) { - throw new Error('MCP config server "enabled" value must be a boolean'); + + const validatedServers: Array<{ + config: unknown; + args?: string[]; + env?: Record; + }> = servers.map((server) => { + if (server && typeof server === "object" && !Array.isArray(server)) { + const config = server as Record; + const validatedConfig = { ...config }; + if ("enabled" in config && typeof config.enabled !== "boolean") { + throw new Error('MCP config server "enabled" value must be a boolean'); + } + if ("command" in config) { + if (typeof config.command !== "string" || !config.command.trim()) { + throw new Error( + 'MCP config server "command" must be a non-empty string', + ); + } + if (config.command !== config.command.trim()) { + throw new Error( + 'MCP config server "command" cannot have surrounding whitespace', + ); + } + if (config.command.length > MAX_COMMAND_LENGTH) { + throw new Error( + `MCP config server "command" exceeds ${MAX_COMMAND_LENGTH} characters`, + ); + } + } + + let args: string[] | undefined; + if ("args" in config) { + if (!Array.isArray(config.args)) { + throw new Error( + 'MCP config server "args" must be an array of strings', + ); + } + if (config.args.length > MAX_ARGS) { + throw new Error( + `MCP config server "args" supports at most ${MAX_ARGS} values`, + ); + } + args = config.args.map((argument) => { + if (typeof argument !== "string") { + throw new Error( + 'MCP config server "args" must contain only strings', + ); + } + if (argument.length > MAX_ARG_LENGTH) { + throw new Error( + `MCP config server arguments cannot exceed ${MAX_ARG_LENGTH} characters`, + ); + } + return argument; + }); + validatedConfig.args = args; + } + + let env: Record | undefined; + if ("env" in config) { + if ( + !config.env || + typeof config.env !== "object" || + Array.isArray(config.env) + ) { + throw new Error('MCP config server "env" must be an object of strings'); + } + const entries = Object.entries(config.env); + if (entries.length > MAX_ENV_VARS) { + throw new Error( + `MCP config server "env" supports at most ${MAX_ENV_VARS} values`, + ); + } + env = Object.fromEntries( + entries.map(([key, value]) => { + if ( + key.length > MAX_ENV_KEY_LENGTH || + !isSafeEnvKey(key) || + typeof value !== "string" || + value.length > MAX_ENV_VALUE_LENGTH + ) { + throw new Error( + 'MCP config server "env" contains an invalid key or value', + ); + } + return [key, value]; + }), + ); + validatedConfig.env = env; + } + + return { config: validatedConfig, args, env }; } - } + return { config: server }; + }); - const sanitizedServers = sanitizeMcpServers(servers); + const sanitizedServers = sanitizeMcpServers( + validatedServers.map(({ config }) => config), + ); if (sanitizedServers.length !== servers.length) { throw new Error("MCP config contains an invalid server entry"); } - return sanitizedServers; + return sanitizedServers.map((server, index) => ({ + ...server, + ...(validatedServers[index].args !== undefined + ? { args: validatedServers[index].args } + : {}), + ...(validatedServers[index].env !== undefined + ? { env: validatedServers[index].env } + : {}), + })); }; export const toStableMcpFingerprint = (servers: McpServerConfig[]): string => { diff --git a/tests/mcpSettings.test.ts b/tests/mcpSettings.test.ts index 1364cdc..85c5fea 100644 --- a/tests/mcpSettings.test.ts +++ b/tests/mcpSettings.test.ts @@ -47,7 +47,7 @@ describe("MCP server settings", () => { ).toBeNull(); }); - it("parses MCP config files as arrays or mcpServers objects", () => { + it("parses array and keyed-object MCP server configs", () => { const server = { id: "local-tools", name: "Local tools", @@ -61,9 +61,32 @@ describe("MCP server settings", () => { expect( parseMcpServerConfigFile(JSON.stringify({ mcpServers: [server] })), ).toMatchObject([{ ...server, env: {}, enabled: true, command: "npx" }]); + + expect( + parseMcpServerConfigFile( + JSON.stringify({ + mcpServers: { + filesystem: { + command: "npx", + args: ["-y", "server-filesystem", " /path with spaces ", ""], + env: { TOKEN: " token " }, + }, + }, + }), + ), + ).toMatchObject([ + { + id: "filesystem", + name: "filesystem", + command: "npx", + args: ["-y", "server-filesystem", " /path with spaces ", ""], + env: { TOKEN: " token " }, + enabled: true, + }, + ]); }); - it("rejects malformed MCP config files and invalid server entries", () => { + it("rejects malformed MCP entries instead of rewriting their executable fields", () => { expect(() => parseMcpServerConfigFile("{")).toThrow("not valid JSON"); expect(() => parseMcpServerConfigFile("{}")).toThrow("mcpServers"); expect(() => parseMcpServerConfigFile(JSON.stringify([{}]))).toThrow( @@ -74,6 +97,17 @@ describe("MCP server settings", () => { JSON.stringify([{ command: "npx", enabled: "false" }]), ), ).toThrow('"enabled" value must be a boolean'); + expect(() => + parseMcpServerConfigFile(JSON.stringify([{ command: 42, args: [] }])), + ).toThrow('"command" must be a non-empty string'); + expect(() => + parseMcpServerConfigFile(JSON.stringify([{ command: "npx", args: ["-y", 42] }])), + ).toThrow('"args" must contain only strings'); + expect(() => + parseMcpServerConfigFile( + JSON.stringify([{ command: "npx", args: [], env: { TOKEN: 42 } }]), + ), + ).toThrow('"env" contains an invalid key or value'); }); it("rejects the whole config when a malformed URL is paired with a stale command, instead of falling back to local mode", () => { From 860c1059721fee4fe3d6cf9f1ec2bf95cd41ea1d Mon Sep 17 00:00:00 2001 From: Uka Osim Date: Tue, 13 Oct 2026 23:22:52 +0100 Subject: [PATCH 5/8] Validate MCP config URLs and headers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/core/library/mcpSettings.ts | 53 ++++++++++++++++++++++++++++++++- tests/mcpSettings.test.ts | 31 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/api/core/library/mcpSettings.ts b/api/core/library/mcpSettings.ts index 32beb18..5c1648b 100644 --- a/api/core/library/mcpSettings.ts +++ b/api/core/library/mcpSettings.ts @@ -313,6 +313,7 @@ export const parseMcpServerConfigFile = (contents: string): McpServerConfig[] => config: unknown; args?: string[]; env?: Record; + headers?: Record; }> = servers.map((server) => { if (server && typeof server === "object" && !Array.isArray(server)) { const config = server as Record; @@ -320,6 +321,17 @@ export const parseMcpServerConfigFile = (contents: string): McpServerConfig[] => if ("enabled" in config && typeof config.enabled !== "boolean") { throw new Error('MCP config server "enabled" value must be a boolean'); } + if ("url" in config) { + if ( + typeof config.url !== "string" || + !config.url.trim() || + config.url.length > MAX_URL_LENGTH + ) { + throw new Error( + 'MCP config server "url" must be a non-empty string', + ); + } + } if ("command" in config) { if (typeof config.command !== "string" || !config.command.trim()) { throw new Error( @@ -399,7 +411,43 @@ export const parseMcpServerConfigFile = (contents: string): McpServerConfig[] => validatedConfig.env = env; } - return { config: validatedConfig, args, env }; + let headers: Record | undefined; + if ("headers" in config) { + if ( + !config.headers || + typeof config.headers !== "object" || + Array.isArray(config.headers) + ) { + throw new Error( + 'MCP config server "headers" must be an object of strings', + ); + } + const entries = Object.entries(config.headers); + if (entries.length > MAX_HEADERS) { + throw new Error( + `MCP config server "headers" supports at most ${MAX_HEADERS} values`, + ); + } + headers = Object.fromEntries( + entries.map(([key, value]) => { + if ( + !key || + key !== key.trim() || + key.length > MAX_HEADER_LENGTH || + typeof value !== "string" || + value.length > MAX_HEADER_LENGTH + ) { + throw new Error( + 'MCP config server "headers" contains an invalid key or value', + ); + } + return [key, value]; + }), + ); + validatedConfig.headers = headers; + } + + return { config: validatedConfig, args, env, headers }; } return { config: server }; }); @@ -418,6 +466,9 @@ export const parseMcpServerConfigFile = (contents: string): McpServerConfig[] => ...(validatedServers[index].env !== undefined ? { env: validatedServers[index].env } : {}), + ...(validatedServers[index].headers !== undefined + ? { headers: validatedServers[index].headers } + : {}), })); }; diff --git a/tests/mcpSettings.test.ts b/tests/mcpSettings.test.ts index 85c5fea..364ca8a 100644 --- a/tests/mcpSettings.test.ts +++ b/tests/mcpSettings.test.ts @@ -110,6 +110,37 @@ describe("MCP server settings", () => { ).toThrow('"env" contains an invalid key or value'); }); + it("rejects invalid present URLs rather than falling back to a local command", () => { + for (const url of [42, "", " "]) { + expect(() => + parseMcpServerConfigFile( + JSON.stringify([{ url, command: "some-stale-command" }]), + ), + ).toThrow('"url" must be a non-empty string'); + } + }); + + it("validates and preserves MCP header values from config files", () => { + expect(() => + parseMcpServerConfigFile( + JSON.stringify([ + { url: "https://example.com/mcp", headers: { Authorization: 42 } }, + ]), + ), + ).toThrow('"headers" contains an invalid key or value'); + + expect( + parseMcpServerConfigFile( + JSON.stringify([ + { + url: "https://example.com/mcp", + headers: { Authorization: " Bearer token " }, + }, + ]), + )[0].headers, + ).toEqual({ Authorization: " Bearer token " }); + }); + it("rejects the whole config when a malformed URL is paired with a stale command, instead of falling back to local mode", () => { // A config that was previously remote but now has a broken/unsupported // url must not silently resurrect the leftover `command` and launch a From 85dafe292c1ae5367585eedd839e0b6243b91da8 Mon Sep 17 00:00:00 2001 From: Uka Osim Date: Tue, 13 Oct 2026 23:43:55 +0100 Subject: [PATCH 6/8] Reject malformed MCP HTTP headers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/core/library/mcpSettings.ts | 46 ++++++++++++++++++++++++--------- tests/mcpSettings.test.ts | 34 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/api/core/library/mcpSettings.ts b/api/core/library/mcpSettings.ts index 5c1648b..1c710fe 100644 --- a/api/core/library/mcpSettings.ts +++ b/api/core/library/mcpSettings.ts @@ -27,6 +27,34 @@ export type McpServerConfig = { }; const isSafeEnvKey = (key: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key); +const isValidHttpHeaderName = (name: string): boolean => + /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name); +const isValidHttpHeaderValue = (value: unknown): value is string => + typeof value === "string" && + value.length <= MAX_HEADER_LENGTH && + /^[\t\x20-\x7E\x80-\xFF]*$/.test(value); + +const isValidHttpHeaders = ( + headers: unknown, +): headers is Record => { + if (!headers || typeof headers !== "object" || Array.isArray(headers)) { + return false; + } + + const entries = Object.entries(headers); + return ( + entries.length <= MAX_HEADERS && + entries.every( + ([name, value]) => + name.length <= MAX_HEADER_LENGTH && + isValidHttpHeaderName(name) && + isValidHttpHeaderValue(value), + ) + ); +}; + +const hasValidHttpHeaders = (headers: unknown): boolean => + headers === undefined || isValidHttpHeaders(headers); const splitCommandString = (value: string): string[] => { const parts: string[] = []; @@ -79,16 +107,10 @@ const sanitizeEnv = (env: unknown): Record => { }; const sanitizeHeaders = (headers: unknown): Record => { - if (!headers || typeof headers !== "object" || Array.isArray(headers)) return {}; + if (!isValidHttpHeaders(headers)) return {}; return Object.fromEntries( - Object.entries(headers) - .map(([key, value]) => [ - safeString(key, MAX_HEADER_LENGTH), - safeString(value, MAX_HEADER_LENGTH), - ] as const) - .filter(([key, value]) => key.length > 0 && value.length > 0) - .slice(0, MAX_HEADERS), + Object.entries(headers).slice(0, MAX_HEADERS), ); }; @@ -157,6 +179,7 @@ const normalizeMcpServerDraft = ( if (!input || typeof input !== "object" || Array.isArray(input)) return null; const raw = input as Record; + if (!hasValidHttpHeaders(raw.headers)) return null; const id = safeString(raw.id, 120) || getDefaultId(index); const hasUrlInput = typeof raw.url === "string" && raw.url.trim().length > 0; const url = sanitizeUrl(raw.url); @@ -213,6 +236,7 @@ export const sanitizeMcpServer = (input: unknown, index = 0): McpServerConfig | if (!input || typeof input !== "object" || Array.isArray(input)) return null; const raw = input as Record; + if (!hasValidHttpHeaders(raw.headers)) return null; const hasUrlInput = typeof raw.url === "string" && raw.url.trim().length > 0; const url = sanitizeUrl(raw.url); // A non-empty url that fails sanitization is malformed/unsupported, not @@ -431,11 +455,9 @@ export const parseMcpServerConfigFile = (contents: string): McpServerConfig[] => headers = Object.fromEntries( entries.map(([key, value]) => { if ( - !key || - key !== key.trim() || key.length > MAX_HEADER_LENGTH || - typeof value !== "string" || - value.length > MAX_HEADER_LENGTH + !isValidHttpHeaderName(key) || + !isValidHttpHeaderValue(value) ) { throw new Error( 'MCP config server "headers" contains an invalid key or value', diff --git a/tests/mcpSettings.test.ts b/tests/mcpSettings.test.ts index 364ca8a..60aaf6f 100644 --- a/tests/mcpSettings.test.ts +++ b/tests/mcpSettings.test.ts @@ -141,6 +141,40 @@ describe("MCP server settings", () => { ).toEqual({ Authorization: " Bearer token " }); }); + it("rejects malformed HTTP header names and values", () => { + for (const headers of [ + { "Bad Header": "value" }, + { Authorization: "Bearer token\r\nX-Injected: yes" }, + ]) { + expect(() => + parseMcpServerConfigFile( + JSON.stringify([{ url: "https://example.com/mcp", headers }]), + ), + ).toThrow('"headers" contains an invalid key or value'); + + expect( + sanitizeMcpServer({ + url: "https://example.com/mcp", + headers, + }), + ).toBeNull(); + } + }); + + it("rejects HTTP header values containing control characters", () => { + const value = `Bearer token${String.fromCharCode(13, 10)}X-Injected: yes`; + const headers = { Authorization: value + String.fromCharCode(13) }; + + expect(() => + parseMcpServerConfigFile( + JSON.stringify([{ url: "https://example.com/mcp", headers }]), + ), + ).toThrow('"headers" contains an invalid key or value'); + expect( + sanitizeMcpServer({ url: "https://example.com/mcp", headers }), + ).toBeNull(); + }); + it("rejects the whole config when a malformed URL is paired with a stale command, instead of falling back to local mode", () => { // A config that was previously remote but now has a broken/unsupported // url must not silently resurrect the leftover `command` and launch a From 3fe8fbfaf9427f3dd3d8564cce09b2943dc472c3 Mon Sep 17 00:00:00 2001 From: Uka Osim Date: Wed, 14 Oct 2026 00:03:25 +0100 Subject: [PATCH 7/8] Preserve configured MCP PATH overrides Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/core/agent/tools/mcpTools.ts | 9 +++++---- api/core/library/mcpSettings.ts | 8 ++++++-- tests/mcpSettings.test.ts | 7 +++++++ tests/mcpTools.test.ts | 11 +++++++++++ 4 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 tests/mcpTools.test.ts diff --git a/api/core/agent/tools/mcpTools.ts b/api/core/agent/tools/mcpTools.ts index 92c5297..e490d00 100644 --- a/api/core/agent/tools/mcpTools.ts +++ b/api/core/agent/tools/mcpTools.ts @@ -134,11 +134,12 @@ export const summarizeMcpToolError = (result: { return summarizeStructuredError(result.structuredContent) || summarizeMcpContent(result.content); }; -const buildAugmentedPath = (): string => { +export const buildAugmentedPath = ( + basePath = process.env.PATH || "", +): string => { const home = process.env.HOME || os.homedir() || ""; - const currentPath = process.env.PATH || ""; const pathParts = [ - currentPath, + basePath, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", @@ -208,7 +209,7 @@ const createTransportParams = ( ...process.env, ...server.env, } as Record; - env.PATH = buildAugmentedPath(); + env.PATH = buildAugmentedPath(env.PATH); return { command: server.command!.trim(), diff --git a/api/core/library/mcpSettings.ts b/api/core/library/mcpSettings.ts index 1c710fe..ed90175 100644 --- a/api/core/library/mcpSettings.ts +++ b/api/core/library/mcpSettings.ts @@ -348,13 +348,17 @@ export const parseMcpServerConfigFile = (contents: string): McpServerConfig[] => if ("url" in config) { if ( typeof config.url !== "string" || - !config.url.trim() || - config.url.length > MAX_URL_LENGTH + !config.url.trim() ) { throw new Error( 'MCP config server "url" must be a non-empty string', ); } + if (config.url.length > MAX_URL_LENGTH) { + throw new Error( + `MCP config server "url" exceeds ${MAX_URL_LENGTH} characters`, + ); + } } if ("command" in config) { if (typeof config.command !== "string" || !config.command.trim()) { diff --git a/tests/mcpSettings.test.ts b/tests/mcpSettings.test.ts index 60aaf6f..d5d3f85 100644 --- a/tests/mcpSettings.test.ts +++ b/tests/mcpSettings.test.ts @@ -120,6 +120,13 @@ describe("MCP server settings", () => { } }); + it("reports the maximum length when a configured URL is oversized", () => { + const url = `https://example.com/${"a".repeat(2000)}`; + expect(() => + parseMcpServerConfigFile(JSON.stringify([{ url }])), + ).toThrow('"url" exceeds 2000 characters'); + }); + it("validates and preserves MCP header values from config files", () => { expect(() => parseMcpServerConfigFile( diff --git a/tests/mcpTools.test.ts b/tests/mcpTools.test.ts new file mode 100644 index 0000000..35cca55 --- /dev/null +++ b/tests/mcpTools.test.ts @@ -0,0 +1,11 @@ +import { buildAugmentedPath } from "../api/core/agent/tools/mcpTools"; + +describe("MCP tool environment", () => { + it("preserves configured PATH entries while adding standard executable paths", () => { + const configuredPath = "/custom/mcp-bin:/usr/bin"; + const pathEntries = buildAugmentedPath(configuredPath).split(":"); + + expect(pathEntries.slice(0, 2)).toEqual(configuredPath.split(":")); + expect(pathEntries).toContain("/usr/local/bin"); + }); +}); From 619c92a0ebcc6207f483292c13d997f0b6bc624b Mon Sep 17 00:00:00 2001 From: Uka Osim Date: Wed, 14 Oct 2026 00:14:04 +0100 Subject: [PATCH 8/8] Use platform delimiter for MCP PATH Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/core/agent/tools/mcpTools.ts | 5 ++++- tests/mcpTools.test.ts | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/api/core/agent/tools/mcpTools.ts b/api/core/agent/tools/mcpTools.ts index e490d00..15f7cc6 100644 --- a/api/core/agent/tools/mcpTools.ts +++ b/api/core/agent/tools/mcpTools.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import Ajv from "ajv"; import type { JSONSchema7 } from "json-schema"; import os from "os"; +import { delimiter as pathDelimiter } from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport, @@ -148,7 +149,9 @@ export const buildAugmentedPath = ( home ? `${home}/.cargo/bin` : "", ].filter(Boolean); - return Array.from(new Set(pathParts.join(":").split(":"))).join(":"); + return Array.from( + new Set(pathParts.join(pathDelimiter).split(pathDelimiter)), + ).join(pathDelimiter); }; type CommandCandidate = { diff --git a/tests/mcpTools.test.ts b/tests/mcpTools.test.ts index 35cca55..ecadae9 100644 --- a/tests/mcpTools.test.ts +++ b/tests/mcpTools.test.ts @@ -1,11 +1,12 @@ import { buildAugmentedPath } from "../api/core/agent/tools/mcpTools"; +import { delimiter as pathDelimiter } from "node:path"; describe("MCP tool environment", () => { it("preserves configured PATH entries while adding standard executable paths", () => { - const configuredPath = "/custom/mcp-bin:/usr/bin"; - const pathEntries = buildAugmentedPath(configuredPath).split(":"); + const configuredPath = ["/custom/mcp-bin", "/usr/bin"].join(pathDelimiter); + const pathEntries = buildAugmentedPath(configuredPath).split(pathDelimiter); - expect(pathEntries.slice(0, 2)).toEqual(configuredPath.split(":")); + expect(pathEntries.slice(0, 2)).toEqual(configuredPath.split(pathDelimiter)); expect(pathEntries).toContain("/usr/local/bin"); }); });