mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 14:07:20 +08:00
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:
co-authored by
Kilo Agent
marius-kilocode
parent
836ef41b29
commit
3e3385fbb3
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user