feat(tools): add skill_mcp tool for invoking skill-embedded MCP operations

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2026-01-01 22:52:46 +09:00
parent b122273c2f
commit e5330311dd
5 changed files with 313 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
export const SKILL_MCP_TOOL_NAME = "skill_mcp"
export const SKILL_MCP_DESCRIPTION = `Invoke MCP server operations from skill-embedded MCPs. Requires mcp_name plus exactly one of: tool_name, resource_name, or prompt_name.`

View File

@@ -0,0 +1,3 @@
export * from "./constants"
export * from "./types"
export { createSkillMcpTool } from "./tools"

View File

@@ -0,0 +1,151 @@
import { describe, it, expect, beforeEach, mock } from "bun:test"
import { createSkillMcpTool } from "./tools"
import { SkillMcpManager } from "../../features/skill-mcp-manager"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
function createMockSkillWithMcp(name: string, mcpServers: Record<string, unknown>): LoadedSkill {
return {
name,
path: `/test/skills/${name}/SKILL.md`,
resolvedPath: `/test/skills/${name}`,
definition: {
name,
description: `Test skill ${name}`,
template: "Test template",
},
scope: "opencode-project",
mcpConfig: mcpServers as LoadedSkill["mcpConfig"],
}
}
const mockContext = {
sessionID: "test-session",
messageID: "msg-1",
agent: "test-agent",
abort: new AbortController().signal,
}
describe("skill_mcp tool", () => {
let manager: SkillMcpManager
let loadedSkills: LoadedSkill[]
let sessionID: string
beforeEach(() => {
manager = new SkillMcpManager()
loadedSkills = []
sessionID = "test-session-1"
})
describe("parameter validation", () => {
it("throws when no operation specified", async () => {
// #given
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => loadedSkills,
getSessionID: () => sessionID,
})
// #when / #then
await expect(
tool.execute({ mcp_name: "test-server" }, mockContext)
).rejects.toThrow(/Missing operation/)
})
it("throws when multiple operations specified", async () => {
// #given
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => loadedSkills,
getSessionID: () => sessionID,
})
// #when / #then
await expect(
tool.execute({
mcp_name: "test-server",
tool_name: "some-tool",
resource_name: "some://resource",
}, mockContext)
).rejects.toThrow(/Multiple operations/)
})
it("throws when mcp_name not found in any skill", async () => {
// #given
loadedSkills = [
createMockSkillWithMcp("test-skill", {
"known-server": { command: "echo", args: ["test"] },
}),
]
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => loadedSkills,
getSessionID: () => sessionID,
})
// #when / #then
await expect(
tool.execute({ mcp_name: "unknown-server", tool_name: "some-tool" }, mockContext)
).rejects.toThrow(/not found/)
})
it("includes available MCP servers in error message", async () => {
// #given
loadedSkills = [
createMockSkillWithMcp("db-skill", {
sqlite: { command: "uvx", args: ["mcp-server-sqlite"] },
}),
createMockSkillWithMcp("api-skill", {
"rest-api": { command: "node", args: ["server.js"] },
}),
]
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => loadedSkills,
getSessionID: () => sessionID,
})
// #when / #then
await expect(
tool.execute({ mcp_name: "missing", tool_name: "test" }, mockContext)
).rejects.toThrow(/sqlite.*db-skill|rest-api.*api-skill/s)
})
it("throws on invalid JSON arguments", async () => {
// #given
loadedSkills = [
createMockSkillWithMcp("test-skill", {
"test-server": { command: "echo" },
}),
]
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => loadedSkills,
getSessionID: () => sessionID,
})
// #when / #then
await expect(
tool.execute({
mcp_name: "test-server",
tool_name: "some-tool",
arguments: "not valid json",
}, mockContext)
).rejects.toThrow(/Invalid arguments JSON/)
})
})
describe("tool description", () => {
it("has concise description", () => {
// #given / #when
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => [],
getSessionID: () => "session",
})
// #then
expect(tool.description.length).toBeLessThan(200)
expect(tool.description).toContain("mcp_name")
})
})
})

View File

@@ -0,0 +1,149 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import { SKILL_MCP_DESCRIPTION } from "./constants"
import type { SkillMcpArgs } from "./types"
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
interface SkillMcpToolOptions {
manager: SkillMcpManager
getLoadedSkills: () => LoadedSkill[]
getSessionID: () => string
}
type OperationType = { type: "tool" | "resource" | "prompt"; name: string }
function validateOperationParams(args: SkillMcpArgs): OperationType {
const operations: OperationType[] = []
if (args.tool_name) operations.push({ type: "tool", name: args.tool_name })
if (args.resource_name) operations.push({ type: "resource", name: args.resource_name })
if (args.prompt_name) operations.push({ type: "prompt", name: args.prompt_name })
if (operations.length === 0) {
throw new Error(
`Missing operation. Exactly one of tool_name, resource_name, or prompt_name must be specified.\n\n` +
`Examples:\n` +
` skill_mcp(mcp_name="sqlite", tool_name="query", arguments='{"sql": "SELECT * FROM users"}')\n` +
` skill_mcp(mcp_name="memory", resource_name="memory://notes")\n` +
` skill_mcp(mcp_name="helper", prompt_name="summarize", arguments='{"text": "..."}')`
)
}
if (operations.length > 1) {
const provided = [
args.tool_name && `tool_name="${args.tool_name}"`,
args.resource_name && `resource_name="${args.resource_name}"`,
args.prompt_name && `prompt_name="${args.prompt_name}"`,
].filter(Boolean).join(", ")
throw new Error(
`Multiple operations specified. Exactly one of tool_name, resource_name, or prompt_name must be provided.\n\n` +
`Received: ${provided}\n\n` +
`Use separate calls for each operation.`
)
}
return operations[0]
}
function findMcpServer(
mcpName: string,
skills: LoadedSkill[]
): { skill: LoadedSkill; config: NonNullable<LoadedSkill["mcpConfig"]>[string] } | null {
for (const skill of skills) {
if (skill.mcpConfig && mcpName in skill.mcpConfig) {
return { skill, config: skill.mcpConfig[mcpName] }
}
}
return null
}
function formatAvailableMcps(skills: LoadedSkill[]): string {
const mcps: string[] = []
for (const skill of skills) {
if (skill.mcpConfig) {
for (const serverName of Object.keys(skill.mcpConfig)) {
mcps.push(` - "${serverName}" from skill "${skill.name}"`)
}
}
}
return mcps.length > 0 ? mcps.join("\n") : " (none found)"
}
function parseArguments(argsJson: string | undefined): Record<string, unknown> {
if (!argsJson) return {}
try {
const parsed = JSON.parse(argsJson)
if (typeof parsed !== "object" || parsed === null) {
throw new Error("Arguments must be a JSON object")
}
return parsed as Record<string, unknown>
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(
`Invalid arguments JSON: ${errorMessage}\n\n` +
`Expected a valid JSON object, e.g.: '{"key": "value"}'\n` +
`Received: ${argsJson}`
)
}
}
export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition {
const { manager, getLoadedSkills, getSessionID } = options
return tool({
description: SKILL_MCP_DESCRIPTION,
args: {
mcp_name: tool.schema.string().describe("Name of the MCP server from skill config"),
tool_name: tool.schema.string().optional().describe("MCP tool to call"),
resource_name: tool.schema.string().optional().describe("MCP resource URI to read"),
prompt_name: tool.schema.string().optional().describe("MCP prompt to get"),
arguments: tool.schema.string().optional().describe("JSON string of arguments"),
},
async execute(args: SkillMcpArgs) {
const operation = validateOperationParams(args)
const skills = getLoadedSkills()
const found = findMcpServer(args.mcp_name, skills)
if (!found) {
throw new Error(
`MCP server "${args.mcp_name}" not found.\n\n` +
`Available MCP servers in loaded skills:\n` +
formatAvailableMcps(skills) + `\n\n` +
`Hint: Load the skill first using the 'skill' tool, then call skill_mcp.`
)
}
const info: SkillMcpClientInfo = {
serverName: args.mcp_name,
skillName: found.skill.name,
sessionID: getSessionID(),
}
const context: SkillMcpServerContext = {
config: found.config,
skillName: found.skill.name,
}
const parsedArgs = parseArguments(args.arguments)
switch (operation.type) {
case "tool": {
const result = await manager.callTool(info, context, operation.name, parsedArgs)
return JSON.stringify(result, null, 2)
}
case "resource": {
const result = await manager.readResource(info, context, operation.name)
return JSON.stringify(result, null, 2)
}
case "prompt": {
const stringArgs: Record<string, string> = {}
for (const [key, value] of Object.entries(parsedArgs)) {
stringArgs[key] = String(value)
}
const result = await manager.getPrompt(info, context, operation.name, stringArgs)
return JSON.stringify(result, null, 2)
}
}
},
})
}

View File

@@ -0,0 +1,7 @@
export interface SkillMcpArgs {
mcp_name: string
tool_name?: string
resource_name?: string
prompt_name?: string
arguments?: string
}