Skip to content

Commit ff760e5

Browse files
committed
feat(skill-loader): support mcp.json file for AmpCode compatibility
- 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)
1 parent 4039722 commit ff760e5

File tree

2 files changed

+146
-3
lines changed

2 files changed

+146
-3
lines changed

src/features/opencode-skill-loader/loader.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import { tmpdir } from "os"
66
const TEST_DIR = join(tmpdir(), "skill-loader-test-" + Date.now())
77
const SKILLS_DIR = join(TEST_DIR, ".opencode", "skill")
88

9-
function createTestSkill(name: string, content: string): string {
9+
function createTestSkill(name: string, content: string, mcpJson?: object): string {
1010
const skillDir = join(SKILLS_DIR, name)
1111
mkdirSync(skillDir, { recursive: true })
1212
const skillPath = join(skillDir, "SKILL.md")
1313
writeFileSync(skillPath, content)
14+
if (mcpJson) {
15+
writeFileSync(join(skillDir, "mcp.json"), JSON.stringify(mcpJson, null, 2))
16+
}
1417
return skillDir
1518
}
1619

@@ -157,4 +160,114 @@ Skill body.
157160
}
158161
})
159162
})
163+
164+
describe("mcp.json file loading (AmpCode compat)", () => {
165+
it("loads MCP config from mcp.json with mcpServers format", async () => {
166+
// #given
167+
const skillContent = `---
168+
name: ampcode-skill
169+
description: Skill with mcp.json
170+
---
171+
Skill body.
172+
`
173+
const mcpJson = {
174+
mcpServers: {
175+
playwright: {
176+
command: "npx",
177+
args: ["@playwright/mcp@latest"]
178+
}
179+
}
180+
}
181+
createTestSkill("ampcode-skill", skillContent, mcpJson)
182+
183+
// #when
184+
const { discoverSkills } = await import("./loader")
185+
const originalCwd = process.cwd()
186+
process.chdir(TEST_DIR)
187+
188+
try {
189+
const skills = discoverSkills({ includeClaudeCodePaths: false })
190+
const skill = skills.find(s => s.name === "ampcode-skill")
191+
192+
// #then
193+
expect(skill).toBeDefined()
194+
expect(skill?.mcpConfig).toBeDefined()
195+
expect(skill?.mcpConfig?.playwright).toBeDefined()
196+
expect(skill?.mcpConfig?.playwright?.command).toBe("npx")
197+
expect(skill?.mcpConfig?.playwright?.args).toEqual(["@playwright/mcp@latest"])
198+
} finally {
199+
process.chdir(originalCwd)
200+
}
201+
})
202+
203+
it("mcp.json takes priority over YAML frontmatter", async () => {
204+
// #given
205+
const skillContent = `---
206+
name: priority-skill
207+
mcp:
208+
from-yaml:
209+
command: yaml-cmd
210+
args: [yaml-arg]
211+
---
212+
Skill body.
213+
`
214+
const mcpJson = {
215+
mcpServers: {
216+
"from-json": {
217+
command: "json-cmd",
218+
args: ["json-arg"]
219+
}
220+
}
221+
}
222+
createTestSkill("priority-skill", skillContent, mcpJson)
223+
224+
// #when
225+
const { discoverSkills } = await import("./loader")
226+
const originalCwd = process.cwd()
227+
process.chdir(TEST_DIR)
228+
229+
try {
230+
const skills = discoverSkills({ includeClaudeCodePaths: false })
231+
const skill = skills.find(s => s.name === "priority-skill")
232+
233+
// #then - mcp.json should take priority
234+
expect(skill?.mcpConfig?.["from-json"]).toBeDefined()
235+
expect(skill?.mcpConfig?.["from-yaml"]).toBeUndefined()
236+
} finally {
237+
process.chdir(originalCwd)
238+
}
239+
})
240+
241+
it("supports direct format without mcpServers wrapper", async () => {
242+
// #given
243+
const skillContent = `---
244+
name: direct-format
245+
---
246+
Skill body.
247+
`
248+
const mcpJson = {
249+
sqlite: {
250+
command: "uvx",
251+
args: ["mcp-server-sqlite"]
252+
}
253+
}
254+
createTestSkill("direct-format", skillContent, mcpJson)
255+
256+
// #when
257+
const { discoverSkills } = await import("./loader")
258+
const originalCwd = process.cwd()
259+
process.chdir(TEST_DIR)
260+
261+
try {
262+
const skills = discoverSkills({ includeClaudeCodePaths: false })
263+
const skill = skills.find(s => s.name === "direct-format")
264+
265+
// #then
266+
expect(skill?.mcpConfig?.sqlite).toBeDefined()
267+
expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx")
268+
} finally {
269+
process.chdir(originalCwd)
270+
}
271+
})
272+
})
160273
})

src/features/opencode-skill-loader/loader.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { CommandDefinition } from "../claude-code-command-loader/types"
1010
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
1111
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
1212

13-
function parseSkillMcpConfig(content: string): SkillMcpConfig | undefined {
13+
function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined {
1414
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
1515
if (!frontmatterMatch) return undefined
1616

@@ -25,6 +25,34 @@ function parseSkillMcpConfig(content: string): SkillMcpConfig | undefined {
2525
return undefined
2626
}
2727

28+
function loadMcpJsonFromDir(skillDir: string): SkillMcpConfig | undefined {
29+
const mcpJsonPath = join(skillDir, "mcp.json")
30+
if (!existsSync(mcpJsonPath)) return undefined
31+
32+
try {
33+
const content = readFileSync(mcpJsonPath, "utf-8")
34+
const parsed = JSON.parse(content) as Record<string, unknown>
35+
36+
// AmpCode format: { "mcpServers": { "name": { ... } } }
37+
if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) {
38+
return parsed.mcpServers as SkillMcpConfig
39+
}
40+
41+
// Also support direct format: { "name": { command: ..., args: ... } }
42+
if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) {
43+
const hasCommandField = Object.values(parsed).some(
44+
(v) => v && typeof v === "object" && "command" in (v as Record<string, unknown>)
45+
)
46+
if (hasCommandField) {
47+
return parsed as SkillMcpConfig
48+
}
49+
}
50+
} catch {
51+
return undefined
52+
}
53+
return undefined
54+
}
55+
2856
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
2957
if (!allowedTools) return undefined
3058
return allowedTools.split(/\s+/).filter(Boolean)
@@ -39,7 +67,9 @@ function loadSkillFromPath(
3967
try {
4068
const content = readFileSync(skillPath, "utf-8")
4169
const { data, body } = parseFrontmatter<SkillMetadata>(content)
42-
const mcpConfig = parseSkillMcpConfig(content)
70+
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
71+
const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath)
72+
const mcpConfig = mcpJsonMcp || frontmatterMcp
4373

4474
const skillName = data.name || defaultName
4575
const originalDescription = data.description || ""

0 commit comments

Comments
 (0)