mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
94b4511053
commit
e99023a9c7
10 changed files with 353 additions and 44 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
161
src/services/command/mcp-prompts.ts
Normal file
161
src/services/command/mcp-prompts.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue