diff --git a/src/tools/skill-mcp/constants.ts b/src/tools/skill-mcp/constants.ts new file mode 100644 index 0000000..4df4f4d --- /dev/null +++ b/src/tools/skill-mcp/constants.ts @@ -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.` diff --git a/src/tools/skill-mcp/index.ts b/src/tools/skill-mcp/index.ts new file mode 100644 index 0000000..1b3ccae --- /dev/null +++ b/src/tools/skill-mcp/index.ts @@ -0,0 +1,3 @@ +export * from "./constants" +export * from "./types" +export { createSkillMcpTool } from "./tools" diff --git a/src/tools/skill-mcp/tools.test.ts b/src/tools/skill-mcp/tools.test.ts new file mode 100644 index 0000000..1868c99 --- /dev/null +++ b/src/tools/skill-mcp/tools.test.ts @@ -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): 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") + }) + }) +}) diff --git a/src/tools/skill-mcp/tools.ts b/src/tools/skill-mcp/tools.ts new file mode 100644 index 0000000..7a56f11 --- /dev/null +++ b/src/tools/skill-mcp/tools.ts @@ -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[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 { + 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 + } 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 = {} + 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) + } + } + }, + }) +} diff --git a/src/tools/skill-mcp/types.ts b/src/tools/skill-mcp/types.ts new file mode 100644 index 0000000..3b56026 --- /dev/null +++ b/src/tools/skill-mcp/types.ts @@ -0,0 +1,7 @@ +export interface SkillMcpArgs { + mcp_name: string + tool_name?: string + resource_name?: string + prompt_name?: string + arguments?: string +}