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