From ff760e5865a032ca3b3c23ef76dea93877981546 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 1 Jan 2026 23:01:06 +0900 Subject: [PATCH] feat(skill-loader): support mcp.json file for AmpCode compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added loadMcpJsonFromDir() to load MCP config from skill directory's mcp.json - Supports AmpCode format (mcpServers wrapper) and direct format - mcp.json takes priority over YAML frontmatter when both exist - Added 3 tests covering mcpServers format, priority, and direct format 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- .../opencode-skill-loader/loader.test.ts | 115 +++++++++++++++++- src/features/opencode-skill-loader/loader.ts | 34 +++++- 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/src/features/opencode-skill-loader/loader.test.ts b/src/features/opencode-skill-loader/loader.test.ts index 241d730..35b5909 100644 --- a/src/features/opencode-skill-loader/loader.test.ts +++ b/src/features/opencode-skill-loader/loader.test.ts @@ -6,11 +6,14 @@ import { tmpdir } from "os" const TEST_DIR = join(tmpdir(), "skill-loader-test-" + Date.now()) const SKILLS_DIR = join(TEST_DIR, ".opencode", "skill") -function createTestSkill(name: string, content: string): string { +function createTestSkill(name: string, content: string, mcpJson?: object): string { const skillDir = join(SKILLS_DIR, name) mkdirSync(skillDir, { recursive: true }) const skillPath = join(skillDir, "SKILL.md") writeFileSync(skillPath, content) + if (mcpJson) { + writeFileSync(join(skillDir, "mcp.json"), JSON.stringify(mcpJson, null, 2)) + } return skillDir } @@ -157,4 +160,114 @@ Skill body. } }) }) + + describe("mcp.json file loading (AmpCode compat)", () => { + it("loads MCP config from mcp.json with mcpServers format", async () => { + // #given + const skillContent = `--- +name: ampcode-skill +description: Skill with mcp.json +--- +Skill body. +` + const mcpJson = { + mcpServers: { + playwright: { + command: "npx", + args: ["@playwright/mcp@latest"] + } + } + } + createTestSkill("ampcode-skill", skillContent, mcpJson) + + // #when + const { discoverSkills } = await import("./loader") + const originalCwd = process.cwd() + process.chdir(TEST_DIR) + + try { + const skills = discoverSkills({ includeClaudeCodePaths: false }) + const skill = skills.find(s => s.name === "ampcode-skill") + + // #then + expect(skill).toBeDefined() + expect(skill?.mcpConfig).toBeDefined() + expect(skill?.mcpConfig?.playwright).toBeDefined() + expect(skill?.mcpConfig?.playwright?.command).toBe("npx") + expect(skill?.mcpConfig?.playwright?.args).toEqual(["@playwright/mcp@latest"]) + } finally { + process.chdir(originalCwd) + } + }) + + it("mcp.json takes priority over YAML frontmatter", async () => { + // #given + const skillContent = `--- +name: priority-skill +mcp: + from-yaml: + command: yaml-cmd + args: [yaml-arg] +--- +Skill body. +` + const mcpJson = { + mcpServers: { + "from-json": { + command: "json-cmd", + args: ["json-arg"] + } + } + } + createTestSkill("priority-skill", skillContent, mcpJson) + + // #when + const { discoverSkills } = await import("./loader") + const originalCwd = process.cwd() + process.chdir(TEST_DIR) + + try { + const skills = discoverSkills({ includeClaudeCodePaths: false }) + const skill = skills.find(s => s.name === "priority-skill") + + // #then - mcp.json should take priority + expect(skill?.mcpConfig?.["from-json"]).toBeDefined() + expect(skill?.mcpConfig?.["from-yaml"]).toBeUndefined() + } finally { + process.chdir(originalCwd) + } + }) + + it("supports direct format without mcpServers wrapper", async () => { + // #given + const skillContent = `--- +name: direct-format +--- +Skill body. +` + const mcpJson = { + sqlite: { + command: "uvx", + args: ["mcp-server-sqlite"] + } + } + createTestSkill("direct-format", skillContent, mcpJson) + + // #when + const { discoverSkills } = await import("./loader") + const originalCwd = process.cwd() + process.chdir(TEST_DIR) + + try { + const skills = discoverSkills({ includeClaudeCodePaths: false }) + const skill = skills.find(s => s.name === "direct-format") + + // #then + expect(skill?.mcpConfig?.sqlite).toBeDefined() + expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx") + } finally { + process.chdir(originalCwd) + } + }) + }) }) diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index 206e6d6..8d7a041 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -10,7 +10,7 @@ import type { CommandDefinition } from "../claude-code-command-loader/types" import type { SkillScope, SkillMetadata, LoadedSkill } from "./types" import type { SkillMcpConfig } from "../skill-mcp-manager/types" -function parseSkillMcpConfig(content: string): SkillMcpConfig | undefined { +function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined { const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) if (!frontmatterMatch) return undefined @@ -25,6 +25,34 @@ function parseSkillMcpConfig(content: string): SkillMcpConfig | undefined { return undefined } +function loadMcpJsonFromDir(skillDir: string): SkillMcpConfig | undefined { + const mcpJsonPath = join(skillDir, "mcp.json") + if (!existsSync(mcpJsonPath)) return undefined + + try { + const content = readFileSync(mcpJsonPath, "utf-8") + const parsed = JSON.parse(content) as Record + + // AmpCode format: { "mcpServers": { "name": { ... } } } + if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) { + return parsed.mcpServers as SkillMcpConfig + } + + // Also support direct format: { "name": { command: ..., args: ... } } + if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) { + const hasCommandField = Object.values(parsed).some( + (v) => v && typeof v === "object" && "command" in (v as Record) + ) + if (hasCommandField) { + return parsed as SkillMcpConfig + } + } + } catch { + return undefined + } + return undefined +} + function parseAllowedTools(allowedTools: string | undefined): string[] | undefined { if (!allowedTools) return undefined return allowedTools.split(/\s+/).filter(Boolean) @@ -39,7 +67,9 @@ function loadSkillFromPath( try { const content = readFileSync(skillPath, "utf-8") const { data, body } = parseFrontmatter(content) - const mcpConfig = parseSkillMcpConfig(content) + const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content) + const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath) + const mcpConfig = mcpJsonMcp || frontmatterMcp const skillName = data.name || defaultName const originalDescription = data.description || ""