Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions packages/opencode/src/kilocode/mcp-migrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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?: {
Expand Down
273 changes: 273 additions & 0 deletions packages/opencode/test/kilocode/mcp-migrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})
})
})
})
})
Loading