Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
140 changes: 139 additions & 1 deletion packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -77,6 +77,13 @@ export namespace MCP {
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
const pendingOAuthTransports = new Map<string, TransportWithAuth>()

// Prompt cache types
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
type PromptCache = {
prompts: Record<string, PromptInfo>
keyMapping: Record<string, { clientName: string; promptName: string }>
}

const state = Instance.state(
async () => {
const cfg = await Config.get()
Expand Down Expand Up @@ -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<void> {
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)
Expand All @@ -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,
}
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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() {
Expand Down Expand Up @@ -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<string, unknown>) {
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.
Expand Down
108 changes: 78 additions & 30 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}

// 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 () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/web/src/content/docs/mcp-servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down