Skip to content
Merged
20 changes: 11 additions & 9 deletions src/cli/doctor/checks/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,26 @@ import * as mcp from "./mcp"

describe("mcp check", () => {
describe("getBuiltinMcpInfo", () => {
it("returns builtin servers", () => {
it("returns builtin servers", async () => {
// #given
// #when getting builtin info
const servers = mcp.getBuiltinMcpInfo()
const servers = await mcp.getBuiltinMcpInfo()

// #then should include expected servers
expect(servers.length).toBe(2)
expect(servers.length).toBe(3)
expect(servers.every((s) => s.type === "builtin")).toBe(true)
expect(servers.every((s) => s.enabled === true)).toBe(true)
expect(servers.map((s) => s.id)).toContain("websearch")
expect(servers.map((s) => s.id)).toContain("context7")
expect(servers.map((s) => s.id)).toContain("grep_app")
})
})

describe("getUserMcpInfo", () => {
it("returns empty array when no user config", () => {
it("returns empty array when no user config", async () => {
// #given no user config exists
// #when getting user info
const servers = mcp.getUserMcpInfo()
const servers = await mcp.getUserMcpInfo()

// #then should return array (may be empty)
expect(Array.isArray(servers)).toBe(true)
Expand All @@ -36,7 +37,7 @@ describe("mcp check", () => {

// #then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("2")
expect(result.message).toContain("3")
expect(result.message).toContain("enabled")
})

Expand All @@ -46,6 +47,7 @@ describe("mcp check", () => {
const result = await mcp.checkBuiltinMcpServers()

// #then should list servers
expect(result.details?.some((d) => d.includes("websearch"))).toBe(true)
expect(result.details?.some((d) => d.includes("context7"))).toBe(true)
expect(result.details?.some((d) => d.includes("grep_app"))).toBe(true)
})
Expand All @@ -60,7 +62,7 @@ describe("mcp check", () => {

it("returns skip when no user config", async () => {
// #given no user servers
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([])
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockResolvedValue([])

// #when checking
const result = await mcp.checkUserMcpServers()
Expand All @@ -72,7 +74,7 @@ describe("mcp check", () => {

it("returns pass when valid user servers", async () => {
// #given valid user servers
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockResolvedValue([
{ id: "custom-mcp", type: "user", enabled: true, valid: true },
])

Expand All @@ -86,7 +88,7 @@ describe("mcp check", () => {

it("returns warn when servers have issues", async () => {
// #given invalid server config
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockResolvedValue([
{ id: "bad-mcp", type: "user", enabled: true, valid: false, error: "Missing command" },
])

Expand Down
98 changes: 44 additions & 54 deletions src/cli/doctor/checks/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,61 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, McpServerInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { parseJsonc } from "../../../shared"
import { createBuiltinMcps } from "../../../mcp"
import { loadRawMcpConfigs } from "../../../features/claude-code-mcp-loader"
import { createMcpRegistry, filterMcpRegistryServers } from "../../../features/mcp-registry"

const BUILTIN_MCP_SERVERS = ["context7", "grep_app"]
export async function getBuiltinMcpInfo(): Promise<McpServerInfo[]> {
const registry = createMcpRegistry({
builtinServers: createBuiltinMcps(),
})

const MCP_CONFIG_PATHS = [
join(homedir(), ".claude", ".mcp.json"),
join(process.cwd(), ".mcp.json"),
join(process.cwd(), ".claude", ".mcp.json"),
]

interface McpConfig {
mcpServers?: Record<string, unknown>
}

function loadUserMcpConfig(): Record<string, unknown> {
const servers: Record<string, unknown> = {}

for (const configPath of MCP_CONFIG_PATHS) {
if (!existsSync(configPath)) continue

try {
const content = readFileSync(configPath, "utf-8")
const config = parseJsonc<McpConfig>(content)
if (config.mcpServers) {
Object.assign(servers, config.mcpServers)
}
} catch {
// intentionally empty - skip invalid configs
}
}

return servers
}

export function getBuiltinMcpInfo(): McpServerInfo[] {
return BUILTIN_MCP_SERVERS.map((id) => ({
id,
return filterMcpRegistryServers(registry, "builtin").map((server) => ({
id: server.name,
type: "builtin" as const,
enabled: true,
valid: true,
}))
}

export function getUserMcpInfo(): McpServerInfo[] {
const userServers = loadUserMcpConfig()
const servers: McpServerInfo[] = []
export async function getUserMcpInfo(): Promise<McpServerInfo[]> {
const customServers = (await loadRawMcpConfigs()).loadedServers

const registry = createMcpRegistry({
customServers,
})

return filterMcpRegistryServers(registry, "custom").map((server) => {
const config = server.config
let valid = true
let error: string | undefined

if (!config || typeof config !== "object") {
valid = false
error = "Invalid config: not an object"
} else if (config.type === "stdio" || (!config.type && !config.url)) {
if (!config.command || (Array.isArray(config.command) && config.command.length === 0) || config.command === "") {
valid = false
error = "Missing required field: command"
}
} else if (config.type === "http" || config.type === "sse" || config.url) {
if (!config.url) {
valid = false
error = "Missing required field: url"
}
}

for (const [id, config] of Object.entries(userServers)) {
const isValid = typeof config === "object" && config !== null
servers.push({
id,
type: "user",
return {
id: server.name,
type: "user" as const,
enabled: true,
valid: isValid,
error: isValid ? undefined : "Invalid configuration format",
})
}

return servers
valid,
...(error ? { error } : {}),
}
})
}

export async function checkBuiltinMcpServers(): Promise<CheckResult> {
const servers = getBuiltinMcpInfo()
const servers = await getBuiltinMcpInfo()

return {
name: CHECK_NAMES[CHECK_IDS.MCP_BUILTIN],
Expand All @@ -76,7 +66,7 @@ export async function checkBuiltinMcpServers(): Promise<CheckResult> {
}

export async function checkUserMcpServers(): Promise<CheckResult> {
const servers = getUserMcpInfo()
const servers = await getUserMcpInfo()

if (servers.length === 0) {
return {
Expand Down
11 changes: 11 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,15 @@ export const GitMasterConfigSchema = z.object({
include_co_authored_by: z.boolean().default(true),
})

export const LazyLoadingConfigSchema = z.object({
/** Enable lazy loading for heavy tool modules (default: false) */
enabled: z.boolean().default(false),
/** Log timing information for lazy-loaded tools (default: false) */
log_timing: z.boolean().default(false),
/** Tool profiles to expose by default. Others loaded on demand. */
default_profiles: z.array(z.string()).optional(),
})

export const MemoryPersistenceConfigSchema = z.object({
enabled: z.boolean().default(false),
recall_on_start: z.boolean().default(true),
Expand Down Expand Up @@ -350,6 +359,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
background_task: BackgroundTaskConfigSchema.optional(),
notification: NotificationConfigSchema.optional(),
git_master: GitMasterConfigSchema.optional(),
lazy_loading: LazyLoadingConfigSchema.optional(),
browser_automation_engine: BrowserAutomationConfigSchema.optional(),
memory_persistence: MemoryPersistenceConfigSchema.optional(),
})
Expand All @@ -374,6 +384,7 @@ export type CategoryConfig = z.infer<typeof CategoryConfigSchema>
export type CategoriesConfig = z.infer<typeof CategoriesConfigSchema>
export type BuiltinCategoryName = z.infer<typeof BuiltinCategoryNameSchema>
export type GitMasterConfig = z.infer<typeof GitMasterConfigSchema>
export type LazyLoadingConfig = z.infer<typeof LazyLoadingConfigSchema>
export type BrowserAutomationProvider = z.infer<typeof BrowserAutomationProviderSchema>
export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSchema>
export type MemoryPersistenceConfig = z.infer<typeof MemoryPersistenceConfigSchema>
Expand Down
48 changes: 25 additions & 23 deletions src/features/mcp-registry/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,33 +109,35 @@ describe("createMcpRegistry", () => {
expect(registry.effectiveServersByName.hybrid?.transport).toBe("stdio")
})

test("throws for malformed plugin remote config missing url", () => {
// #given / #when / #then
expect(() =>
createMcpRegistry({
pluginServers: {
broken: {
type: "remote",
// Intentionally malformed to verify runtime guard.
url: "",
},
test("skips malformed plugin remote config missing url", () => {
// #given / #when
const registry = createMcpRegistry({
pluginServers: {
broken: {
type: "remote",
// Intentionally malformed — should be silently skipped.
url: "",
},
})
).toThrow(/missing url/)
},
})

// #then
expect(registry.effectiveServers).toHaveLength(0)
})

test("throws for malformed plugin local config with empty command", () => {
// #given / #when / #then
expect(() =>
createMcpRegistry({
pluginServers: {
broken: {
type: "local",
command: [],
},
test("skips malformed plugin local config with empty command", () => {
// #given / #when
const registry = createMcpRegistry({
pluginServers: {
broken: {
type: "local",
command: [],
},
})
).toThrow(/empty command array/)
},
})

// #then
expect(registry.effectiveServers).toHaveLength(0)
})
})

Expand Down
45 changes: 35 additions & 10 deletions src/features/mcp-registry/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
McpTransport,
} from "./types"
import type { McpServerConfig } from "../claude-code-mcp-loader/types"
import { transformMcpServer } from "../claude-code-mcp-loader/transformer"

const SOURCE_PRECEDENCE = {
builtin: 10,
Expand Down Expand Up @@ -35,6 +36,10 @@ function pluginConfigToClaudeConfig(config: McpServerConfig): ClaudeCodeMcpServe
}
}

if (!config.command) {
throw new Error("Invalid plugin MCP local config: missing command")
}

if (config.command.length === 0) {
throw new Error("Invalid plugin MCP local config: empty command array")
}
Expand Down Expand Up @@ -111,17 +116,21 @@ export function createMcpRegistry(input: CreateMcpRegistryInput): McpRegistryRes
}

for (const [name, config] of Object.entries(input.pluginServers ?? {})) {
const claudeConfig = pluginConfigToClaudeConfig(config)
try {
const claudeConfig = pluginConfigToClaudeConfig(config)

allServers.push({
name,
source: "plugin",
scope: "plugin",
precedence: SOURCE_PRECEDENCE.plugin,
transport: inferTransport(claudeConfig),
config: claudeConfig,
contextName: "plugin",
})
allServers.push({
name,
source: "plugin",
scope: "plugin",
precedence: SOURCE_PRECEDENCE.plugin,
transport: inferTransport(claudeConfig),
config: claudeConfig,
contextName: "plugin",
})
} catch {
// Skip plugin servers with invalid config (missing command/url)
}
}

allServers.push(...fromSkills(input.skills ?? []))
Expand Down Expand Up @@ -188,3 +197,19 @@ export function filterMcpRegistryServers(
if (source === "all") return registry.effectiveServers
return registry.effectiveServers.filter((server) => server.source === source)
}

export function toRuntimeMcpServerMap(
servers: McpRegistryServerDescriptor[]
): Record<string, McpServerConfig> {
const map: Record<string, McpServerConfig> = {}

for (const server of servers) {
try {
map[server.name] = transformMcpServer(server.name, server.config)
} catch {
// Skip servers with invalid configs during runtime map construction
}
}

return map
}
Loading