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)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>
|
||||
|
||||
// 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<string, unknown>)
|
||||
)
|
||||
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<SkillMetadata>(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 || ""
|
||||
|
||||
Reference in New Issue
Block a user