feat(claude-code-mcp-loader): auto-disable builtin skills with overlapping MCP servers

Add getSystemMcpServerNames() sync function to detect system-configured MCP
servers from .mcp.json files (user, project, and local scopes). Builtin skills
like playwright are now automatically excluded when their MCP server is already
configured in system config, preventing duplicate MCP server registration.

Also adds comprehensive test suite with 5 BDD-style tests covering empty config,
project/local scopes, disabled servers, and merged configurations.

Changes:
- loader.ts: Add getSystemMcpServerNames() function + readFileSync import
- loader.test.ts: Add 5 tests for getSystemMcpServerNames() edge cases
- index.ts: Filter builtin skills to exclude those with overlapping MCP names

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2026-01-02 10:55:54 +09:00
parent a1fe0f8517
commit e6ffdc4352
3 changed files with 200 additions and 108 deletions

View File

@@ -0,0 +1,162 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { mkdirSync, writeFileSync, rmSync } from "fs"
import { join } from "path"
import { tmpdir } from "os"
const TEST_DIR = join(tmpdir(), "mcp-loader-test-" + Date.now())
describe("getSystemMcpServerNames", () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
})
afterEach(() => {
rmSync(TEST_DIR, { recursive: true, force: true })
})
it("returns empty set when no .mcp.json files exist", async () => {
// #given
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
// #when
const { getSystemMcpServerNames } = await import("./loader")
const names = getSystemMcpServerNames()
// #then
expect(names).toBeInstanceOf(Set)
expect(names.size).toBe(0)
} finally {
process.chdir(originalCwd)
}
})
it("returns server names from project .mcp.json", async () => {
// #given
const mcpConfig = {
mcpServers: {
playwright: {
command: "npx",
args: ["@playwright/mcp@latest"],
},
sqlite: {
command: "uvx",
args: ["mcp-server-sqlite"],
},
},
}
writeFileSync(join(TEST_DIR, ".mcp.json"), JSON.stringify(mcpConfig))
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
// #when
const { getSystemMcpServerNames } = await import("./loader")
const names = getSystemMcpServerNames()
// #then
expect(names.has("playwright")).toBe(true)
expect(names.has("sqlite")).toBe(true)
expect(names.size).toBe(2)
} finally {
process.chdir(originalCwd)
}
})
it("returns server names from .claude/.mcp.json", async () => {
// #given
mkdirSync(join(TEST_DIR, ".claude"), { recursive: true })
const mcpConfig = {
mcpServers: {
memory: {
command: "npx",
args: ["-y", "@anthropic-ai/mcp-server-memory"],
},
},
}
writeFileSync(join(TEST_DIR, ".claude", ".mcp.json"), JSON.stringify(mcpConfig))
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
// #when
const { getSystemMcpServerNames } = await import("./loader")
const names = getSystemMcpServerNames()
// #then
expect(names.has("memory")).toBe(true)
} finally {
process.chdir(originalCwd)
}
})
it("excludes disabled MCP servers", async () => {
// #given
const mcpConfig = {
mcpServers: {
playwright: {
command: "npx",
args: ["@playwright/mcp@latest"],
disabled: true,
},
active: {
command: "npx",
args: ["some-mcp"],
},
},
}
writeFileSync(join(TEST_DIR, ".mcp.json"), JSON.stringify(mcpConfig))
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
// #when
const { getSystemMcpServerNames } = await import("./loader")
const names = getSystemMcpServerNames()
// #then
expect(names.has("playwright")).toBe(false)
expect(names.has("active")).toBe(true)
} finally {
process.chdir(originalCwd)
}
})
it("merges server names from multiple .mcp.json files", async () => {
// #given
mkdirSync(join(TEST_DIR, ".claude"), { recursive: true })
const projectMcp = {
mcpServers: {
playwright: { command: "npx", args: ["@playwright/mcp@latest"] },
},
}
const localMcp = {
mcpServers: {
memory: { command: "npx", args: ["-y", "@anthropic-ai/mcp-server-memory"] },
},
}
writeFileSync(join(TEST_DIR, ".mcp.json"), JSON.stringify(projectMcp))
writeFileSync(join(TEST_DIR, ".claude", ".mcp.json"), JSON.stringify(localMcp))
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
// #when
const { getSystemMcpServerNames } = await import("./loader")
const names = getSystemMcpServerNames()
// #then
expect(names.has("playwright")).toBe(true)
expect(names.has("memory")).toBe(true)
} finally {
process.chdir(originalCwd)
}
})
})

View File

@@ -1,4 +1,4 @@
import { existsSync } from "fs"
import { existsSync, readFileSync } from "fs"
import { join } from "path"
import { getClaudeConfigDir } from "../../shared"
import type {
@@ -42,6 +42,30 @@ async function loadMcpConfigFile(
}
}
export function getSystemMcpServerNames(): Set<string> {
const names = new Set<string>()
const paths = getMcpConfigPaths()
for (const { path } of paths) {
if (!existsSync(path)) continue
try {
const content = readFileSync(path, "utf-8")
const config = JSON.parse(content) as ClaudeCodeMcpConfig
if (!config?.mcpServers) continue
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
if (serverConfig.disabled) continue
names.add(name)
}
} catch {
continue
}
}
return names
}
export async function loadMcpConfigs(): Promise<McpLoadResult> {
const servers: McpLoadResult["servers"] = {}
const loadedServers: LoadedMcpServer[] = []