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:
3
src/tools/skill-mcp/constants.ts
Normal file
3
src/tools/skill-mcp/constants.ts
Normal 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.`
|
||||
3
src/tools/skill-mcp/index.ts
Normal file
3
src/tools/skill-mcp/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
export { createSkillMcpTool } from "./tools"
|
||||
151
src/tools/skill-mcp/tools.test.ts
Normal file
151
src/tools/skill-mcp/tools.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
149
src/tools/skill-mcp/tools.ts
Normal file
149
src/tools/skill-mcp/tools.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
7
src/tools/skill-mcp/types.ts
Normal file
7
src/tools/skill-mcp/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface SkillMcpArgs {
|
||||
mcp_name: string
|
||||
tool_name?: string
|
||||
resource_name?: string
|
||||
prompt_name?: string
|
||||
arguments?: string
|
||||
}
|
||||
Reference in New Issue
Block a user