fix: support remote MCP server migration in mcp-migrator (#247)

* 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

* 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.

---------

Co-authored-by: Kilo Agent <agent@kilo.ai>
Co-authored-by: marius-kilocode <marius@kilocode.ai>
This commit is contained in:
kiloconnect[bot]
2026-02-12 08:19:44 +00:00
committed by GitHub
co-authored by Kilo Agent marius-kilocode
parent 836ef41b29
commit 3e3385fbb3
2 changed files with 305 additions and 3 deletions
+32 -3
View File
@@ -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<string, string>
disabled?: boolean
alwaysAllow?: string[]
// Remote server fields
type?: string
url?: string
headers?: Record<string, string>
}
export interface KilocodeMcpSettings {
@@ -38,17 +49,35 @@ export namespace McpMigrator {
// Skip disabled servers
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,
...(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 ?? [])]
// 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?: {
@@ -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",
})
})
})
})
})