diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 0a9bfc62030..c8ad527e883 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -71,10 +71,43 @@ export namespace Command { }) export async function get(name: string) { - return state().then((x) => x[name]) + const command = await state().then((x) => x[name]) + if (command) return command + + // Check if this is an MCP prompt + const { MCP } = await import("../mcp") + const mcpPrompts = await MCP.prompts() + const prompt = mcpPrompts[name] + if (prompt) { + return { + name, + description: prompt.description, + // the template will be fetched when the command is executed so this is + // just a placeholder + template: "$ARGUMENTS", + } satisfies Info + } + + return undefined } export async function list() { - return state().then((x) => Object.values(x)) + const commands = await state().then((x) => Object.values(x)) + + // Add MCP prompts as commands + const { MCP } = await import("../mcp") + const mcpPrompts = await MCP.prompts() + + for (const [key, prompt] of Object.entries(mcpPrompts)) { + commands.push({ + name: key, + description: prompt.description, + // the template will be fetched when the command is executed so this is + // just a placeholder + template: "$ARGUMENTS", + }) + } + + return commands } } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 41d59097b52..958fe61edb9 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -1,5 +1,5 @@ import { type Tool } from "ai" -import { experimental_createMCPClient } from "@ai-sdk/mcp" +import { experimental_createMCPClient, type experimental_MCPClient as MCPClient } from "@ai-sdk/mcp" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" @@ -77,6 +77,13 @@ export namespace MCP { type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport const pendingOAuthTransports = new Map() + // Prompt cache types + type PromptInfo = Awaited>["prompts"][number] + type PromptCache = { + prompts: Record + keyMapping: Record + } + const state = Instance.state( async () => { const cfg = await Config.get() @@ -121,6 +128,84 @@ export namespace MCP { }, ) + // Helper function to fetch prompts for a specific client + async function fetchPromptsForClient(clientName: string, client: Client, cache: PromptCache): Promise { + const prompts = await client.listPrompts().catch((e) => { + log.error("failed to get prompts", { clientName, error: e.message }) + return undefined + }) + + if (!prompts) { + return + } + + for (const prompt of prompts.prompts) { + const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_") + const sanitizedPromptName = prompt.name.replace(/[^a-zA-Z0-9_-]/g, "_") + const key = "mcp." + sanitizedClientName + "." + sanitizedPromptName + + cache.prompts[key] = prompt + cache.keyMapping[key] = { + clientName, + promptName: prompt.name, + } + } + } + + // Prompt cache state + const promptState = Instance.state(async () => { + const cache: PromptCache = { + prompts: {}, + keyMapping: {}, + } + + // Proactively fetch prompts from all connected clients + const s = await state() + const clientsSnapshot = await clients() + + await Promise.all( + Object.entries(clientsSnapshot).map(async ([clientName, client]) => { + if (s.status[clientName]?.status !== "connected") { + return + } + + await fetchPromptsForClient(clientName, client, cache) + }), + ) + + return cache + }) + + // Invalidate prompts for a specific MCP server + async function invalidatePrompts(clientName: string) { + const cache = await promptState() + const clientsSnapshot = await clients() + const client = clientsSnapshot[clientName] + + // Remove all prompts from this client + const keysToRemove = Object.entries(cache.keyMapping) + .filter(([_, mapping]) => mapping.clientName === clientName) + .map(([key]) => key) + + for (const key of keysToRemove) { + delete cache.prompts[key] + delete cache.keyMapping[key] + } + + // Re-fetch if client is still connected + if (client) { + const s = await state() + if (s.status[clientName]?.status === "connected") { + await fetchPromptsForClient(clientName, client, cache) + } + } + + log.info("invalidated prompts for client", { + clientName, + removedCount: keysToRemove.length, + }) + } + export async function add(name: string, mcp: Config.Mcp) { const s = await state() const result = await create(name, mcp) @@ -143,6 +228,10 @@ export namespace MCP { s.clients[name] = result.mcpClient s.status[name] = result.status + // Fetch prompts for the newly added client + const cache = await promptState() + await fetchPromptsForClient(name, result.mcpClient, cache) + return { status: s.status, } @@ -366,6 +455,9 @@ export namespace MCP { s.status[name] = result.status if (result.mcpClient) { s.clients[name] = result.mcpClient + + // Invalidate and refresh prompts for the reconnected client + await invalidatePrompts(name) } } @@ -379,6 +471,9 @@ export namespace MCP { delete s.clients[name] } s.status[name] = { status: "disabled" } + + // Remove prompts for the disconnected client + await invalidatePrompts(name) } export async function tools() { @@ -413,6 +508,49 @@ export namespace MCP { return result } + export async function prompts() { + const cache = await promptState() + return cache.prompts + } + + export async function getPrompt(key: string, args?: Record) { + const cache = await promptState() + const mapping = cache.keyMapping[key] + + if (!mapping) { + log.warn("prompt not found in cache", { key }) + return undefined + } + + const clientsSnapshot = await clients() + const client = clientsSnapshot[mapping.clientName] + + if (!client) { + log.warn("client not found for prompt", { + key, + clientName: mapping.clientName, + }) + return undefined + } + + const result = await client + .getPrompt({ + name: mapping.promptName, + arguments: args, + }) + .catch((e) => { + log.error("failed to get prompt from MCP server", { + key, + clientName: mapping.clientName, + promptName: mapping.promptName, + error: e.message, + }) + return undefined + }) + + return result + } + /** * Start OAuth authentication flow for an MCP server. * Returns the authorization URL that should be opened in a browser. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ff5194d5594..aa5ae5ab6ec 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1282,42 +1282,90 @@ export namespace SessionPrompt { export async function command(input: CommandInput) { log.info("command", input) const command = await Command.get(input.command) + if (!command) { + throw new Error(`Command not found: ${input.command}`) + } const agentName = command.agent ?? input.agent ?? "build" - const raw = input.arguments.match(argsRegex) ?? [] - const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) + // Check if this is an MCP prompt + const { MCP } = await import("../mcp") + const mcpPrompts = await MCP.prompts() + const isMcpPrompt = !!mcpPrompts[input.command] + + let template = "" + if (isMcpPrompt) { + // Parse arguments for MCP prompt + const raw = input.arguments.match(argsRegex) ?? [] + const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) + + // Get the prompt info to extract argument names + const promptInfo = mcpPrompts[input.command] + const mcpArgs: Record = {} + + // Map positional arguments to named arguments + if (promptInfo.arguments) { + promptInfo.arguments.forEach((arg, index) => { + if (index < args.length) { + mcpArgs[arg.name] = args[index] + } + }) + } - const placeholders = command.template.match(placeholderRegex) ?? [] - let last = 0 - for (const item of placeholders) { - const value = Number(item.slice(1)) - if (value > last) last = value - } + // Get the actual prompt from the MCP server + const mcpPrompt = await MCP.getPrompt(input.command, mcpArgs) + if (!mcpPrompt) { + throw new Error( + `Failed to load MCP prompt: ${input.command}. The MCP server may be disconnected or the prompt may not exist.`, + ) + } - // Let the final placeholder swallow any extra arguments so prompts read naturally - const withArgs = command.template.replaceAll(placeholderRegex, (_, index) => { - const position = Number(index) - const argIndex = position - 1 - if (argIndex >= args.length) return "" - if (position === last) return args.slice(argIndex).join(" ") - return args[argIndex] - }) - let template = withArgs.replaceAll("$ARGUMENTS", input.arguments) - - const shell = ConfigMarkdown.shell(template) - if (shell.length > 0) { - const results = await Promise.all( - shell.map(async ([, cmd]) => { - try { - return await $`${{ raw: cmd }}`.nothrow().text() - } catch (error) { - return `Error executing command: ${error instanceof Error ? error.message : String(error)}` + // Convert MCP prompt messages to a template string + template = mcpPrompt.messages + .map((msg) => { + if (msg.content.type === "text") { + return msg.content.text } - }), - ) - let index = 0 - template = template.replace(bashRegex, () => results[index++]) + return "" + }) + .join("\n\n") + } else { + // Regular command - process template normally + const raw = input.arguments.match(argsRegex) ?? [] + const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) + + const placeholders = command.template.match(placeholderRegex) ?? [] + let last = 0 + for (const item of placeholders) { + const value = Number(item.slice(1)) + if (value > last) last = value + } + + // Let the final placeholder swallow any extra arguments so prompts read naturally + const withArgs = command.template.replaceAll(placeholderRegex, (_, index) => { + const position = Number(index) + const argIndex = position - 1 + if (argIndex >= args.length) return "" + if (position === last) return args.slice(argIndex).join(" ") + return args[argIndex] + }) + template = withArgs.replaceAll("$ARGUMENTS", input.arguments) + + const shell = ConfigMarkdown.shell(template) + if (shell.length > 0) { + const results = await Promise.all( + shell.map(async ([, cmd]) => { + try { + return await $`${{ raw: cmd }}`.nothrow().text() + } catch (error) { + return `Error executing command: ${error instanceof Error ? error.message : String(error)}` + } + }), + ) + let index = 0 + template = template.replace(bashRegex, () => results[index++]) + } } + template = template.trim() const model = await (async () => { diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 48b38442c7d..f010cd75291 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -340,6 +340,10 @@ The glob pattern uses simple regex globbing patterns. --- +## Prompts + +Some MCP servers expose `prompts`, pre-defined prompt templates (with or without arguments) that you can invoke to fill your input. In OpenCode you can access them as slash commands. Just type `/mcp.mcpservername.promptname` (don't worry they will autocomplete for you) and, if required provide a space separated list of the arguments. Just like a slash command sending the prompt will substitute the command with the MCP provided prompt. + ## Examples Below are examples of some common MCP servers. You can submit a PR if you want to document other servers.