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 TEST_DIR = join(tmpdir(), "skill-loader-test-" + Date.now())
|
||||||
const SKILLS_DIR = join(TEST_DIR, ".opencode", "skill")
|
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)
|
const skillDir = join(SKILLS_DIR, name)
|
||||||
mkdirSync(skillDir, { recursive: true })
|
mkdirSync(skillDir, { recursive: true })
|
||||||
const skillPath = join(skillDir, "SKILL.md")
|
const skillPath = join(skillDir, "SKILL.md")
|
||||||
writeFileSync(skillPath, content)
|
writeFileSync(skillPath, content)
|
||||||
|
if (mcpJson) {
|
||||||
|
writeFileSync(join(skillDir, "mcp.json"), JSON.stringify(mcpJson, null, 2))
|
||||||
|
}
|
||||||
return skillDir
|
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 { SkillScope, SkillMetadata, LoadedSkill } from "./types"
|
||||||
import type { SkillMcpConfig } from "../skill-mcp-manager/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---/)
|
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
||||||
if (!frontmatterMatch) return undefined
|
if (!frontmatterMatch) return undefined
|
||||||
|
|
||||||
@@ -25,6 +25,34 @@ function parseSkillMcpConfig(content: string): SkillMcpConfig | undefined {
|
|||||||
return 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 {
|
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
|
||||||
if (!allowedTools) return undefined
|
if (!allowedTools) return undefined
|
||||||
return allowedTools.split(/\s+/).filter(Boolean)
|
return allowedTools.split(/\s+/).filter(Boolean)
|
||||||
@@ -39,7 +67,9 @@ function loadSkillFromPath(
|
|||||||
try {
|
try {
|
||||||
const content = readFileSync(skillPath, "utf-8")
|
const content = readFileSync(skillPath, "utf-8")
|
||||||
const { data, body } = parseFrontmatter<SkillMetadata>(content)
|
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 skillName = data.name || defaultName
|
||||||
const originalDescription = data.description || ""
|
const originalDescription = data.description || ""
|
||||||
|
|||||||
Reference in New Issue
Block a user