From 8fba90766dbdb048f8f9a75a351cd36e3a32eaeb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 31 Mar 2026 17:05:55 -0700 Subject: [PATCH] fix: apply scope filter to getSystemMcpServerNames and fix async native skill description refresh --- .../claude-code-mcp-loader/loader.test.ts | 58 ++++++++++++++-- src/features/claude-code-mcp-loader/loader.ts | 2 + .../skill/async-description-refresh.test.ts | 69 +++++++++++++++++++ src/tools/skill/tools.ts | 6 +- 4 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 src/tools/skill/async-description-refresh.test.ts diff --git a/src/features/claude-code-mcp-loader/loader.test.ts b/src/features/claude-code-mcp-loader/loader.test.ts index bd9e206d8..48ab1a288 100644 --- a/src/features/claude-code-mcp-loader/loader.test.ts +++ b/src/features/claude-code-mcp-loader/loader.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test" import { mkdirSync, writeFileSync, rmSync } from "fs" import { join } from "path" @@ -198,10 +200,10 @@ describe("getSystemMcpServerNames", () => { } }) - it("reads both ~/.claude.json and ~/.claude/.mcp.json for user scope", async () => { - // given - const claudeDir = join(TEST_HOME, ".claude") - mkdirSync(claudeDir, { recursive: true }) + it("reads both ~/.claude.json and ~/.claude/.mcp.json for user scope", async () => { + // given + const claudeDir = join(TEST_HOME, ".claude") + mkdirSync(claudeDir, { recursive: true }) writeFileSync(join(TEST_HOME, ".claude.json"), JSON.stringify({ mcpServers: { @@ -226,10 +228,55 @@ describe("getSystemMcpServerNames", () => { // then expect(names.has("server-from-claude-json")).toBe(true) expect(names.has("server-from-mcp-json")).toBe(true) + } finally { + process.chdir(originalCwd) + } + }) + + it("ignores local-scope user MCP entries for other projects", async () => { + //#given + const otherProjectDir = join(TEST_DIR, "project-a") + const currentProjectDir = join(TEST_DIR, "project-b") + mkdirSync(otherProjectDir, { recursive: true }) + mkdirSync(currentProjectDir, { recursive: true }) + + writeFileSync(join(TEST_HOME, ".claude.json"), JSON.stringify({ + mcpServers: { + playwright: { + command: "npx", + args: ["@playwright/mcp@latest"], + scope: "local", + projectPath: otherProjectDir, + }, + sqlite: { + command: "uvx", + args: ["mcp-server-sqlite"], + scope: "local", + projectPath: currentProjectDir, + }, + memory: { + command: "npx", + args: ["memory-mcp"], + }, + }, + })) + + const originalCwd = process.cwd() + process.chdir(currentProjectDir) + + try { + //#when + const { getSystemMcpServerNames } = await import("./loader") + const names = getSystemMcpServerNames() + + //#then + expect(names.has("playwright")).toBe(false) + expect(names.has("sqlite")).toBe(true) + expect(names.has("memory")).toBe(true) } finally { process.chdir(originalCwd) } - }) + }) }) describe("loadMcpConfigs", () => { @@ -334,4 +381,3 @@ describe("loadMcpConfigs", () => { } }) }) - diff --git a/src/features/claude-code-mcp-loader/loader.ts b/src/features/claude-code-mcp-loader/loader.ts index 6ccf08b42..49c56ca2f 100644 --- a/src/features/claude-code-mcp-loader/loader.ts +++ b/src/features/claude-code-mcp-loader/loader.ts @@ -48,6 +48,7 @@ async function loadMcpConfigFile( export function getSystemMcpServerNames(): Set { const names = new Set() const paths = getMcpConfigPaths() + const cwd = process.cwd() for (const { path } of paths) { if (!existsSync(path)) continue @@ -59,6 +60,7 @@ export function getSystemMcpServerNames(): Set { for (const [name, serverConfig] of Object.entries(config.mcpServers)) { if (serverConfig.disabled) continue + if (!shouldLoadMcpServer(serverConfig, cwd)) continue names.add(name) } } catch { diff --git a/src/tools/skill/async-description-refresh.test.ts b/src/tools/skill/async-description-refresh.test.ts new file mode 100644 index 000000000..27931597d --- /dev/null +++ b/src/tools/skill/async-description-refresh.test.ts @@ -0,0 +1,69 @@ +/// + +import { describe, expect, it } from "bun:test" +import { createSkillTool } from "./tools" +import type { LoadedSkill } from "../../features/opencode-skill-loader/types" + +function createMockSkill(name: string): LoadedSkill { + return { + name, + path: `/test/skills/${name}/SKILL.md`, + resolvedPath: `/test/skills/${name}`, + definition: { + name, + description: `Test skill ${name}`, + template: `Test skill template for ${name}`, + }, + scope: "opencode-project", + } +} + +async function waitForRefresh(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (predicate()) { + return + } + + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +describe("skill tool - async native skill description refresh", () => { + it("updates description after async native skills resolve", async () => { + //#given + let allCallCount = 0 + const tool = createSkillTool({ + skills: [createMockSkill("seeded-skill")], + commands: [], + nativeSkills: { + async all() { + allCallCount += 1 + + return [{ + name: "async-native-skill", + description: "Async native skill from plugin input", + location: "/external/skills/async-native-skill/SKILL.md", + content: "Async native skill body", + }] + }, + async get() { + return undefined + }, + async dirs() { + return [] + }, + }, + }) + + expect(tool.description).toContain("seeded-skill") + expect(tool.description).not.toContain("async-native-skill") + + //#when + await waitForRefresh(() => allCallCount === 2) + + //#then + expect(allCallCount).toBe(2) + expect(tool.description).toContain("seeded-skill") + expect(tool.description).toContain("async-native-skill") + }) +}) diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index 448b3752c..70d2016e3 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -265,8 +265,8 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition }) } - const buildDescription = async (): Promise => { - if (cachedDescription) return cachedDescription + const buildDescription = async (force = false): Promise => { + if (!force && cachedDescription) return cachedDescription const skills = await getSkills() const commands = getCommands() const skillInfos = skills.map(loadedSkillToInfo) @@ -294,7 +294,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition cachedDescription = formatCombinedDescription(skillInfos, commandsForDescription) if (needsAsyncRefresh) { - void buildDescription() + void buildDescription(true) } } else if (options.commands !== undefined) { cachedDescription = formatCombinedDescription([], options.commands)