feat: add MCP server prompts support

- Add prompt types to shared/mcp.ts (McpPrompt, McpPromptArgument, etc.)
- Update McpHub to fetch prompts from MCP servers via prompts/list
- Add getPrompt method to McpHub for executing prompts
- Create mcp-prompts.ts service to integrate MCP prompts with slash commands
- Update command system to support MCP prompts as slash commands (mcp.<server>.<prompt>)
- Add support for prompt arguments/parameters
- Update tests to support new function signatures

Implements #8004
This commit is contained in:
Roo Code 2025-09-15 21:59:42 +00:00
parent 94b4511053
commit e99023a9c7
10 changed files with 353 additions and 44 deletions

View file

@ -6,7 +6,7 @@ describe("Command Integration Tests", () => {
const testWorkspaceDir = path.join(__dirname, "../../")
it("should discover command files in .roo/commands/", async () => {
const commands = await getCommands(testWorkspaceDir)
const commands = await getCommands(testWorkspaceDir, undefined)
// Should be able to discover commands (may be empty in test environment)
expect(Array.isArray(commands)).toBe(true)
@ -22,7 +22,7 @@ describe("Command Integration Tests", () => {
})
it("should return command names correctly", async () => {
const commandNames = await getCommandNames(testWorkspaceDir)
const commandNames = await getCommandNames(testWorkspaceDir, undefined)
// Should return an array (may be empty in test environment)
expect(Array.isArray(commandNames)).toBe(true)
@ -35,11 +35,11 @@ describe("Command Integration Tests", () => {
})
it("should load command content if commands exist", async () => {
const commands = await getCommands(testWorkspaceDir)
const commands = await getCommands(testWorkspaceDir, undefined)
if (commands.length > 0) {
const firstCommand = commands[0]
const loadedCommand = await getCommand(testWorkspaceDir, firstCommand.name)
const loadedCommand = await getCommand(testWorkspaceDir, firstCommand.name, undefined)
expect(loadedCommand).toBeDefined()
expect(loadedCommand?.name).toBe(firstCommand.name)
@ -50,7 +50,7 @@ describe("Command Integration Tests", () => {
})
it("should handle non-existent commands gracefully", async () => {
const nonExistentCommand = await getCommand(testWorkspaceDir, "non-existent-command")
const nonExistentCommand = await getCommand(testWorkspaceDir, "non-existent-command", undefined)
expect(nonExistentCommand).toBeUndefined()
})
})

View file

@ -40,21 +40,21 @@ describe("Command Utilities", () => {
describe("getCommands", () => {
it("should return empty array when no command directories exist", async () => {
// This will fail to find directories but should return empty array gracefully
const commands = await getCommands(testCwd)
const commands = await getCommands(testCwd, undefined)
expect(Array.isArray(commands)).toBe(true)
})
})
describe("getCommandNames", () => {
it("should return empty array when no commands exist", async () => {
const names = await getCommandNames(testCwd)
const names = await getCommandNames(testCwd, undefined)
expect(Array.isArray(names)).toBe(true)
})
})
describe("getCommand", () => {
it("should return undefined for non-existent command", async () => {
const result = await getCommand(testCwd, "non-existent")
const result = await getCommand(testCwd, "non-existent", undefined)
expect(result).toBeUndefined()
})
})
@ -78,8 +78,8 @@ describe("Command Utilities", () => {
describe("command loading behavior", () => {
it("should handle multiple calls to getCommands", async () => {
const commands1 = await getCommands(testCwd)
const commands2 = await getCommands(testCwd)
const commands1 = await getCommands(testCwd, undefined)
const commands2 = await getCommands(testCwd, undefined)
expect(Array.isArray(commands1)).toBe(true)
expect(Array.isArray(commands2)).toBe(true)
})
@ -88,9 +88,9 @@ describe("Command Utilities", () => {
describe("error handling", () => {
it("should handle invalid command names gracefully", async () => {
// These should not throw errors
expect(await getCommand(testCwd, "")).toBeUndefined()
expect(await getCommand(testCwd, " ")).toBeUndefined()
expect(await getCommand(testCwd, "non/existent/path")).toBeUndefined()
expect(await getCommand(testCwd, "", undefined)).toBeUndefined()
expect(await getCommand(testCwd, " ", undefined)).toBeUndefined()
expect(await getCommand(testCwd, "non/existent/path", undefined)).toBeUndefined()
})
})
})

View file

@ -92,7 +92,8 @@ export async function parseMentions(
const commandExistenceChecks = await Promise.all(
Array.from(uniqueCommandNames).map(async (commandName) => {
try {
const command = await getCommand(cwd, commandName)
// TODO: Pass McpHub instance when available for MCP prompt support
const command = await getCommand(cwd, commandName, undefined)
return { commandName, command }
} catch (error) {
// If there's an error checking command existence, treat it as non-existent

View file

@ -3,6 +3,7 @@ import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } f
import { formatResponse } from "../prompts/responses"
import { getCommand, getCommandNames } from "../../services/command/commands"
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { McpServerManager } from "../../services/mcp/McpServerManager"
export async function runSlashCommandTool(
task: Task,
@ -49,12 +50,20 @@ export async function runSlashCommandTool(
task.consecutiveMistakeCount = 0
// Get the command from the commands service
const command = await getCommand(task.cwd, commandName)
// Get the command from the commands service (pass McpHub for MCP prompt support)
let mcpHub = undefined
if (provider) {
try {
mcpHub = await McpServerManager.getInstance(provider.context, provider)
} catch (error) {
console.error("Failed to get MCP hub:", error)
}
}
const command = await getCommand(task.cwd, commandName, mcpHub)
if (!command) {
// Get available commands for error message
const availableCommands = await getCommandNames(task.cwd)
const availableCommands = await getCommandNames(task.cwd, mcpHub)
task.recordToolError("run_slash_command")
pushToolResult(
formatResponse.toolError(
@ -64,6 +73,41 @@ export async function runSlashCommandTool(
return
}
// Handle MCP prompt commands differently
let commandContent = command.content
if (command.source === "mcp" && command.name.startsWith("mcp.") && mcpHub) {
const parts = command.name.split(".")
if (parts.length >= 3) {
const serverName = parts[1]
const promptName = parts.slice(2).join(".")
try {
const { executeMcpPrompt, parsePromptArguments } = await import(
"../../services/command/mcp-prompts"
)
// Parse arguments if provided
let promptArgs: Record<string, unknown> = {}
if (args) {
const servers = mcpHub.getAllServers()
const server = servers.find((s) => s.name === serverName)
const prompt = server?.prompts?.find((p) => p.name === promptName)
if (prompt) {
promptArgs = parsePromptArguments(prompt, args)
}
}
// Execute the MCP prompt to get the actual content
commandContent = await executeMcpPrompt(mcpHub, serverName, promptName, promptArgs)
} catch (error) {
console.error(`Failed to execute MCP prompt ${command.name}:`, error)
commandContent = `Error executing MCP prompt: ${error instanceof Error ? error.message : String(error)}`
}
}
}
const toolMessage = JSON.stringify({
tool: "runSlashCommand",
command: commandName,
@ -94,7 +138,7 @@ export async function runSlashCommandTool(
}
result += `\nSource: ${command.source}`
result += `\n\n--- Command Content ---\n\n${command.content}`
result += `\n\n--- Command Content ---\n\n${commandContent}`
// Return the command content as the tool result
pushToolResult(result)

View file

@ -2817,7 +2817,10 @@ export const webviewMessageHandler = async (
try {
if (message.text) {
const { getCommand } = await import("../../services/command/commands")
const command = await getCommand(getCurrentCwd(), message.text)
const { executeMcpPrompt, parsePromptArguments } = await import(
"../../services/command/mcp-prompts"
)
const command = await getCommand(getCurrentCwd(), message.text, provider.mcpHub)
if (command && command.filePath) {
openFile(command.filePath)
@ -2954,7 +2957,7 @@ export const webviewMessageHandler = async (
// Refresh commands list
const { getCommands } = await import("../../services/command/commands")
const commands = await getCommands(getCurrentCwd() || "")
const commands = await getCommands(getCurrentCwd() || "", provider.mcpHub)
const commandList = commands.map((command) => ({
name: command.name,
source: command.source,

View file

@ -40,7 +40,7 @@ npm run build
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
const result = await getCommand("/test/cwd", "setup")
const result = await getCommand("/test/cwd", "setup", undefined)
expect(result).toEqual({
name: "setup",
@ -64,7 +64,7 @@ npm run build
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
const result = await getCommand("/test/cwd", "setup")
const result = await getCommand("/test/cwd", "setup", undefined)
expect(result).toEqual({
name: "setup",
@ -89,7 +89,7 @@ Command content here.`
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
const result = await getCommand("/test/cwd", "setup")
const result = await getCommand("/test/cwd", "setup", undefined)
expect(result?.description).toBeUndefined()
})
@ -107,7 +107,7 @@ Command content here.`
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
const result = await getCommand("/test/cwd", "setup")
const result = await getCommand("/test/cwd", "setup", undefined)
expect(result).toEqual({
name: "setup",
@ -142,7 +142,7 @@ Global setup instructions.`
.mockResolvedValueOnce(projectCommandContent) // First call for project
.mockResolvedValueOnce(globalCommandContent) // Second call for global (shouldn't be used)
const result = await getCommand("/test/cwd", "setup")
const result = await getCommand("/test/cwd", "setup", undefined)
expect(result).toEqual({
name: "setup",
@ -169,7 +169,7 @@ Global setup instructions.`
.mockRejectedValueOnce(new Error("File not found")) // Project command doesn't exist
.mockResolvedValueOnce(globalCommandContent) // Global command exists
const result = await getCommand("/test/cwd", "setup")
const result = await getCommand("/test/cwd", "setup", undefined)
expect(result).toEqual({
name: "setup",
@ -196,7 +196,7 @@ Create a new release.`
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
const result = await getCommand("/test/cwd", "release")
const result = await getCommand("/test/cwd", "release", undefined)
expect(result).toEqual({
name: "release",
@ -222,7 +222,7 @@ Deploy the application.`
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
const result = await getCommand("/test/cwd", "deploy")
const result = await getCommand("/test/cwd", "deploy", undefined)
expect(result).toEqual({
name: "deploy",
@ -247,7 +247,7 @@ Test content.`
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
const result = await getCommand("/test/cwd", "test")
const result = await getCommand("/test/cwd", "test", undefined)
expect(result?.argumentHint).toBeUndefined()
})
@ -265,7 +265,7 @@ Test content.`
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
const result = await getCommand("/test/cwd", "test")
const result = await getCommand("/test/cwd", "test", undefined)
expect(result?.argumentHint).toBeUndefined()
})
@ -283,7 +283,7 @@ Test content.`
mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
const result = await getCommand("/test/cwd", "test")
const result = await getCommand("/test/cwd", "test", undefined)
expect(result?.argumentHint).toBeUndefined()
})
@ -324,7 +324,7 @@ Build instructions without frontmatter.`
.mockResolvedValueOnce(deployContent)
.mockResolvedValueOnce(buildContent)
const result = await getCommands("/test/cwd")
const result = await getCommands("/test/cwd", undefined)
expect(result).toHaveLength(3)
expect(result).toEqual(
@ -374,7 +374,7 @@ Deploy the app.`
])
mockFs.readFile = vi.fn().mockResolvedValueOnce(releaseContent).mockResolvedValueOnce(deployContent)
const result = await getCommands("/test/cwd")
const result = await getCommands("/test/cwd", undefined)
expect(result).toHaveLength(2)
expect(result).toEqual(

View file

@ -3,21 +3,23 @@ import * as path from "path"
import matter from "gray-matter"
import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config"
import { getBuiltInCommands, getBuiltInCommand } from "./built-in-commands"
import { getMcpPromptsAsCommands, getMcpPromptCommand } from "./mcp-prompts"
import { McpHub } from "../mcp/McpHub"
export interface Command {
name: string
content: string
source: "global" | "project" | "built-in"
source: "global" | "project" | "built-in" | "mcp"
filePath: string
description?: string
argumentHint?: string
}
/**
* Get all available commands from built-in, global, and project directories
* Priority order: project > global > built-in (later sources override earlier ones)
* Get all available commands from built-in, global, project directories, and MCP servers
* Priority order: MCP prompts > project > global > built-in (later sources override earlier ones)
*/
export async function getCommands(cwd: string): Promise<Command[]> {
export async function getCommands(cwd: string, mcpHub?: McpHub): Promise<Command[]> {
const commands = new Map<string, Command>()
// Add built-in commands first (lowest priority)
@ -30,23 +32,37 @@ export async function getCommands(cwd: string): Promise<Command[]> {
const globalDir = path.join(getGlobalRooDirectory(), "commands")
await scanCommandDirectory(globalDir, "global", commands)
// Scan project commands (highest priority - override both global and built-in)
// Scan project commands (override both global and built-in)
const projectDir = path.join(getProjectRooDirectoryForCwd(cwd), "commands")
await scanCommandDirectory(projectDir, "project", commands)
// Add MCP prompts as commands (highest priority - override all others)
const mcpCommands = await getMcpPromptsAsCommands(mcpHub)
for (const command of mcpCommands) {
commands.set(command.name, { ...command, source: "mcp" })
}
return Array.from(commands.values())
}
/**
* Get a specific command by name (optimized to avoid scanning all commands)
* Priority order: project > global > built-in
* Priority order: MCP prompts > project > global > built-in
*/
export async function getCommand(cwd: string, name: string): Promise<Command | undefined> {
export async function getCommand(cwd: string, name: string, mcpHub?: McpHub): Promise<Command | undefined> {
// Check if it's an MCP prompt command first (highest priority)
if (name.startsWith("mcp.") && mcpHub) {
const mcpCommand = await getMcpPromptCommand(mcpHub, name)
if (mcpCommand) {
return { ...mcpCommand, source: "mcp" }
}
}
// Try to find the command directly without scanning all commands
const projectDir = path.join(getProjectRooDirectoryForCwd(cwd), "commands")
const globalDir = path.join(getGlobalRooDirectory(), "commands")
// Check project directory first (highest priority)
// Check project directory first
const projectCommand = await tryLoadCommand(projectDir, name, "project")
if (projectCommand) {
return projectCommand
@ -128,8 +144,8 @@ async function tryLoadCommand(
/**
* Get command names for autocomplete
*/
export async function getCommandNames(cwd: string): Promise<string[]> {
const commands = await getCommands(cwd)
export async function getCommandNames(cwd: string, mcpHub?: McpHub): Promise<string[]> {
const commands = await getCommands(cwd, mcpHub)
return commands.map((cmd) => cmd.name)
}

View file

@ -0,0 +1,161 @@
import { Command } from "./commands"
import { McpHub } from "../mcp/McpHub"
import { McpPrompt } from "../../shared/mcp"
/**
* Convert MCP prompts to commands that can be used in the slash command system
*/
export async function getMcpPromptsAsCommands(mcpHub: McpHub | undefined): Promise<Command[]> {
if (!mcpHub) {
return []
}
const commands: Command[] = []
const servers = mcpHub.getAllServers()
for (const server of servers) {
if (server.disabled || server.status !== "connected" || !server.prompts) {
continue
}
// Add each prompt as a command with the pattern: mcp.<serverName>.<promptName>
for (const prompt of server.prompts) {
const commandName = `mcp.${server.name}.${prompt.name}`
commands.push({
name: commandName,
content: "", // Content will be fetched dynamically when the command is used
source: server.source === "project" ? "project" : "global",
filePath: "", // Virtual command, no file path
description: prompt.description || `MCP prompt from ${server.name}`,
argumentHint: getArgumentHint(prompt),
})
}
}
return commands
}
/**
* Get a specific MCP prompt command by name
*/
export async function getMcpPromptCommand(
mcpHub: McpHub | undefined,
commandName: string,
): Promise<Command | undefined> {
if (!mcpHub || !commandName.startsWith("mcp.")) {
return undefined
}
// Parse the command name: mcp.<serverName>.<promptName>
const parts = commandName.split(".")
if (parts.length < 3) {
return undefined
}
const serverName = parts[1]
const promptName = parts.slice(2).join(".") // Handle prompt names with dots
const servers = mcpHub.getAllServers()
const server = servers.find((s) => s.name === serverName)
if (!server || server.disabled || server.status !== "connected" || !server.prompts) {
return undefined
}
const prompt = server.prompts.find((p) => p.name === promptName)
if (!prompt) {
return undefined
}
return {
name: commandName,
content: "", // Content will be fetched dynamically when the command is used
source: server.source === "project" ? "project" : "global",
filePath: "", // Virtual command, no file path
description: prompt.description || `MCP prompt from ${server.name}`,
argumentHint: getArgumentHint(prompt),
}
}
/**
* Execute an MCP prompt and get the resulting content
*/
export async function executeMcpPrompt(
mcpHub: McpHub,
serverName: string,
promptName: string,
args?: Record<string, unknown>,
): Promise<string> {
try {
const response = await mcpHub.getPrompt(serverName, promptName, args)
// Convert the prompt response to a string that can be used as command content
if (response.messages && response.messages.length > 0) {
// Combine all messages into a single string
const content = response.messages
.map((msg) => {
if (msg.content.type === "text" && msg.content.text) {
return msg.content.text
} else if (msg.content.type === "resource" && msg.content.resource?.text) {
return msg.content.resource.text
}
return ""
})
.filter((text) => text.length > 0)
.join("\n\n")
return content || "No content returned from MCP prompt"
}
return "No messages returned from MCP prompt"
} catch (error) {
console.error(`Failed to execute MCP prompt ${promptName} on server ${serverName}:`, error)
throw new Error(`Failed to execute MCP prompt: ${error instanceof Error ? error.message : String(error)}`)
}
}
/**
* Get argument hint for a prompt based on its arguments
*/
function getArgumentHint(prompt: McpPrompt): string | undefined {
if (!prompt.arguments || prompt.arguments.length === 0) {
return undefined
}
const requiredArgs = prompt.arguments.filter((arg) => arg.required !== false)
const optionalArgs = prompt.arguments.filter((arg) => arg.required === false)
const hints: string[] = []
if (requiredArgs.length > 0) {
hints.push(requiredArgs.map((arg) => `<${arg.name}>`).join(" "))
}
if (optionalArgs.length > 0) {
hints.push(optionalArgs.map((arg) => `[${arg.name}]`).join(" "))
}
return hints.join(" ") || undefined
}
/**
* Parse arguments from a command string
*/
export function parsePromptArguments(prompt: McpPrompt, argsString: string): Record<string, unknown> {
if (!prompt.arguments || prompt.arguments.length === 0) {
return {}
}
const args: Record<string, unknown> = {}
const parts = argsString.trim().split(/\s+/)
// Simple positional argument parsing
// In a more sophisticated implementation, we could support named arguments
prompt.arguments.forEach((arg, index) => {
if (index < parts.length) {
args[arg.name] = parts[index]
}
})
return args
}

View file

@ -9,6 +9,8 @@ import {
ListResourceTemplatesResultSchema,
ListToolsResultSchema,
ReadResourceResultSchema,
ListPromptsResultSchema,
GetPromptResultSchema,
} from "@modelcontextprotocol/sdk/types.js"
import chokidar, { FSWatcher } from "chokidar"
import delay from "delay"
@ -28,6 +30,8 @@ import {
McpServer,
McpTool,
McpToolCallResponse,
McpPrompt,
McpGetPromptResponse,
} from "../../shared/mcp"
import { fileExistsAtPath } from "../../utils/fs"
import { arePathsEqual, getWorkspacePath } from "../../utils/path"
@ -835,10 +839,11 @@ export class McpHub {
connection.server.error = ""
connection.server.instructions = client.getInstructions()
// Initial fetch of tools and resources
// Initial fetch of tools, resources, and prompts
connection.server.tools = await this.fetchToolsList(name, source)
connection.server.resources = await this.fetchResourcesList(name, source)
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name, source)
connection.server.prompts = await this.fetchPromptsList(name, source)
} catch (error) {
// Update status with error
const connection = this.findConnection(name, source)
@ -993,6 +998,49 @@ export class McpHub {
}
}
private async fetchPromptsList(serverName: string, source?: "global" | "project"): Promise<McpPrompt[]> {
try {
const connection = this.findConnection(serverName, source)
if (!connection || connection.type !== "connected") {
return []
}
const response = await connection.client.request({ method: "prompts/list" }, ListPromptsResultSchema)
return response?.prompts || []
} catch (error) {
// Prompts might not be supported by all servers, so we silently handle errors
// console.error(`Failed to fetch prompts for ${serverName}:`, error)
return []
}
}
async getPrompt(
serverName: string,
promptName: string,
args?: Record<string, unknown>,
source?: "global" | "project",
): Promise<McpGetPromptResponse> {
const connection = this.findConnection(serverName, source)
if (!connection || connection.type !== "connected") {
throw new Error(
`No connection found for server: ${serverName}${source ? ` with source ${source}` : ""}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`,
)
}
if (connection.server.disabled) {
throw new Error(`Server "${serverName}" is disabled and cannot be used`)
}
return await connection.client.request(
{
method: "prompts/get",
params: {
name: promptName,
arguments: args,
},
},
GetPromptResultSchema,
)
}
async deleteConnection(name: string, source?: "global" | "project"): Promise<void> {
// Clean up file watchers for this server
this.removeFileWatchersForServer(name)
@ -1384,6 +1432,7 @@ export class McpHub {
serverName,
serverSource,
)
connection.server.prompts = await this.fetchPromptsList(serverName, serverSource)
}
} catch (error) {
console.error(`Failed to refresh capabilities for ${serverName}:`, error)

View file

@ -13,6 +13,7 @@ export type McpServer = {
tools?: McpTool[]
resources?: McpResource[]
resourceTemplates?: McpResourceTemplate[]
prompts?: McpPrompt[]
disabled?: boolean
timeout?: number
source?: "global" | "project"
@ -42,6 +43,40 @@ export type McpResourceTemplate = {
mimeType?: string
}
export type McpPrompt = {
name: string
description?: string
arguments?: McpPromptArgument[]
}
export type McpPromptArgument = {
name: string
description?: string
required?: boolean
}
export type McpPromptMessage = {
role: "user" | "assistant" | "system"
content: {
type: "text" | "image" | "resource"
text?: string
data?: string
mimeType?: string
resource?: {
uri: string
text?: string
blob?: string
mimeType?: string
}
}
}
export type McpGetPromptResponse = {
_meta?: Record<string, any>
description?: string
messages: McpPromptMessage[]
}
export type McpResourceResponse = {
_meta?: Record<string, any>
contents: Array<{