From afbde42785ce369460a8faa5fbc521ca613e31fa Mon Sep 17 00:00:00 2001 From: Kilo Agent Date: Wed, 11 Feb 2026 14:17:19 +0000 Subject: [PATCH 1/2] fix: support remote MCP server migration in mcp-migrator The MCP migrator only handled stdio/local MCP servers. When a user configured a remote streamable-http or sse MCP server in .kilocode/mcp_settings.json, it failed with: The "file" argument must be of type string. Received undefined This happened because convertServer() always assumed local servers, building a command array from server.command (which is undefined for remote servers). Changes: - Add remote transport type detection (streamable-http, sse) - Convert remote servers to Config.McpRemote (type: "remote") - Pass through url and headers fields - Make command optional in KilocodeMcpServer interface - Add comprehensive tests for remote server migration --- .../opencode/src/kilocode/mcp-migrator.ts | 28 +- .../test/kilocode/mcp-migrator.test.ts | 273 ++++++++++++++++++ 2 files changed, 297 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/kilocode/mcp-migrator.ts b/packages/opencode/src/kilocode/mcp-migrator.ts index 5c115da36..fe65f1cf9 100644 --- a/packages/opencode/src/kilocode/mcp-migrator.ts +++ b/packages/opencode/src/kilocode/mcp-migrator.ts @@ -8,13 +8,24 @@ import { KilocodePaths } from "./paths" export namespace McpMigrator { const log = Log.create({ service: "kilocode.mcp-migrator" }) + // Remote transport types used by the Kilocode extension + const REMOTE_TYPES = new Set(["streamable-http", "sse"]) + + function isRemote(server: KilocodeMcpServer): boolean { + return !!server.type && REMOTE_TYPES.has(server.type) + } + // Kilocode MCP server structure export interface KilocodeMcpServer { - command: string + command?: string args?: string[] env?: Record disabled?: boolean alwaysAllow?: string[] + // Remote server fields + type?: string + url?: string + headers?: Record } export interface KilocodeMcpSettings { @@ -38,17 +49,26 @@ export namespace McpMigrator { // Skip disabled servers if (server.disabled) return null + if (isRemote(server)) { + const config: Config.Mcp = { + type: "remote", + url: server.url!, + ...(server.headers && Object.keys(server.headers).length > 0 && { headers: server.headers }), + } + return config + } + // Build command array: [command, ...args] - const command = [server.command, ...(server.args ?? [])] + const command = [server.command!, ...(server.args ?? [])] // Build the MCP config object - const mcpConfig: Config.Mcp = { + const config: Config.Mcp = { type: "local", command, ...(server.env && Object.keys(server.env).length > 0 && { environment: server.env }), } - return mcpConfig + return config } export async function migrate(options?: { diff --git a/packages/opencode/test/kilocode/mcp-migrator.test.ts b/packages/opencode/test/kilocode/mcp-migrator.test.ts index 6c00069bf..80e805cff 100644 --- a/packages/opencode/test/kilocode/mcp-migrator.test.ts +++ b/packages/opencode/test/kilocode/mcp-migrator.test.ts @@ -339,4 +339,277 @@ describe("McpMigrator", () => { expect(Object.keys(result.mcp)).toHaveLength(0) }) }) + + describe("remote server migration", () => { + describe("convertServer", () => { + test("converts streamable-http server to remote type", () => { + const server = { + type: "streamable-http", + url: "http://localhost:4321/mcp", + } as any + + const result = McpMigrator.convertServer("local-mcp", server) + + expect(result).toEqual({ + type: "remote", + url: "http://localhost:4321/mcp", + }) + }) + + test("converts sse server to remote type", () => { + const server = { + type: "sse", + url: "https://mcp.example.com/sse", + } as any + + const result = McpMigrator.convertServer("sse-server", server) + + expect(result).toEqual({ + type: "remote", + url: "https://mcp.example.com/sse", + }) + }) + + test("converts remote server with headers", () => { + const server = { + type: "streamable-http", + url: "https://mcp.example.com/api", + headers: { + Authorization: "Bearer token123", + "X-Custom-Header": "value", + }, + } as any + + const result = McpMigrator.convertServer("auth-server", server) + + expect(result).toEqual({ + type: "remote", + url: "https://mcp.example.com/api", + headers: { + Authorization: "Bearer token123", + "X-Custom-Header": "value", + }, + }) + }) + + test("returns null for disabled remote server", () => { + const server = { + type: "streamable-http", + url: "http://localhost:4321/mcp", + disabled: true, + } as any + + const result = McpMigrator.convertServer("disabled-remote", server) + + expect(result).toBeNull() + }) + + test("omits headers when not provided on remote server", () => { + const server = { + type: "sse", + url: "https://mcp.example.com/sse", + } as any + + const result = McpMigrator.convertServer("no-headers", server) + + expect(result).not.toHaveProperty("headers") + }) + + test("omits headers when empty object on remote server", () => { + const server = { + type: "streamable-http", + url: "https://mcp.example.com/api", + headers: {}, + } as any + + const result = McpMigrator.convertServer("empty-headers", server) + + expect(result).not.toHaveProperty("headers") + }) + }) + + describe("migrate", () => { + test("migrates streamable-http server from project settings", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const settingsDir = path.join(dir, ".kilocode") + await Bun.write( + path.join(settingsDir, "mcp.json"), + JSON.stringify({ + mcpServers: { + "local-mcp": { + type: "streamable-http", + url: "http://localhost:4321/mcp", + }, + }, + }), + ) + }, + }) + + const result = await McpMigrator.migrate({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + + expect(result.mcp).toHaveProperty("local-mcp") + expect(result.mcp["local-mcp"]).toEqual({ + type: "remote", + url: "http://localhost:4321/mcp", + }) + }) + + test("migrates sse server from project settings", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const settingsDir = path.join(dir, ".kilocode") + await Bun.write( + path.join(settingsDir, "mcp.json"), + JSON.stringify({ + mcpServers: { + "sse-server": { + type: "sse", + url: "https://mcp.example.com/sse", + }, + }, + }), + ) + }, + }) + + const result = await McpMigrator.migrate({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + + expect(result.mcp).toHaveProperty("sse-server") + expect(result.mcp["sse-server"]).toEqual({ + type: "remote", + url: "https://mcp.example.com/sse", + }) + }) + + test("migrates mixed stdio and remote servers", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const settingsDir = path.join(dir, ".kilocode") + await Bun.write( + path.join(settingsDir, "mcp.json"), + JSON.stringify({ + mcpServers: { + filesystem: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem"], + }, + "remote-api": { + type: "streamable-http", + url: "http://localhost:4321/mcp", + }, + "sse-api": { + type: "sse", + url: "https://mcp.example.com/sse", + headers: { Authorization: "Bearer secret" }, + }, + }, + }), + ) + }, + }) + + const result = await McpMigrator.migrate({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + + expect(Object.keys(result.mcp)).toHaveLength(3) + expect(result.mcp.filesystem).toEqual({ + type: "local", + command: ["npx", "-y", "@modelcontextprotocol/server-filesystem"], + }) + expect(result.mcp["remote-api"]).toEqual({ + type: "remote", + url: "http://localhost:4321/mcp", + }) + expect(result.mcp["sse-api"]).toEqual({ + type: "remote", + url: "https://mcp.example.com/sse", + headers: { Authorization: "Bearer secret" }, + }) + }) + + test("migrates remote server with headers and auth", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const settingsDir = path.join(dir, ".kilocode") + await Bun.write( + path.join(settingsDir, "mcp.json"), + JSON.stringify({ + mcpServers: { + "auth-api": { + type: "streamable-http", + url: "https://api.example.com/mcp", + headers: { + Authorization: "Bearer token123", + "X-API-Key": "key456", + }, + }, + }, + }), + ) + }, + }) + + const result = await McpMigrator.migrate({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + + expect(result.mcp).toHaveProperty("auth-api") + expect(result.mcp["auth-api"]).toEqual({ + type: "remote", + url: "https://api.example.com/mcp", + headers: { + Authorization: "Bearer token123", + "X-API-Key": "key456", + }, + }) + }) + + test("skips disabled remote servers and records them", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const settingsDir = path.join(dir, ".kilocode") + await Bun.write( + path.join(settingsDir, "mcp.json"), + JSON.stringify({ + mcpServers: { + enabled: { + type: "streamable-http", + url: "http://localhost:4321/mcp", + }, + disabled: { + type: "streamable-http", + url: "http://localhost:4322/mcp", + disabled: true, + }, + }, + }), + ) + }, + }) + + const result = await McpMigrator.migrate({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + + expect(result.mcp).toHaveProperty("enabled") + expect(result.mcp).not.toHaveProperty("disabled") + expect(result.skipped).toContainEqual({ + name: "disabled", + reason: "Server is disabled", + }) + }) + }) + }) }) From 4f719761b4ac8a25f78e203f000e536cb987d613 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 11 Feb 2026 17:36:08 +0100 Subject: [PATCH 2/2] fix: validate url/command before use in mcp-migrator Add guard clauses for missing url on remote servers and missing command on local servers instead of using non-null assertions that could crash on malformed config files. --- packages/opencode/src/kilocode/mcp-migrator.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/mcp-migrator.ts b/packages/opencode/src/kilocode/mcp-migrator.ts index fe65f1cf9..6505cf894 100644 --- a/packages/opencode/src/kilocode/mcp-migrator.ts +++ b/packages/opencode/src/kilocode/mcp-migrator.ts @@ -50,16 +50,25 @@ export namespace McpMigrator { if (server.disabled) return null if (isRemote(server)) { + if (!server.url) { + log.warn("remote MCP server missing url, skipping", { name }) + return null + } const config: Config.Mcp = { type: "remote", - url: server.url!, + url: server.url, ...(server.headers && Object.keys(server.headers).length > 0 && { headers: server.headers }), } return config } + if (!server.command) { + log.warn("local MCP server missing command, skipping", { name }) + return null + } + // Build command array: [command, ...args] - const command = [server.command!, ...(server.args ?? [])] + const command = [server.command, ...(server.args ?? [])] // Build the MCP config object const config: Config.Mcp = {