From 1492604ee6b0837f7b6bfe3f67ddfaec8d9b0841 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Sat, 7 Dec 2024 19:46:59 -0800 Subject: [PATCH] Add use_mcp_tool and access_mcp_resource tools --- src/core/Cline.ts | 163 +++++++++++++++++- src/core/assistant-message/index.ts | 16 ++ src/core/prompts/responses.ts | 3 + src/core/prompts/system.ts | 141 ++++++++++++++- src/core/webview/ClineProvider.ts | 8 +- src/services/mcp/McpHub.ts | 108 +++++++++++- src/shared/ExtensionMessage.ts | 11 ++ src/shared/mcp.ts | 43 +++++ webview-ui/src/components/chat/ChatRow.tsx | 123 ++++++++++++- webview-ui/src/components/chat/ChatView.tsx | 14 ++ .../src/components/mcp/McpResourceRow.tsx | 62 +++++++ webview-ui/src/components/mcp/McpToolRow.tsx | 75 ++++++++ webview-ui/src/components/mcp/McpView.tsx | 73 +++----- webview-ui/src/utils/mcp.ts | 47 +++++ 14 files changed, 821 insertions(+), 66 deletions(-) create mode 100644 webview-ui/src/components/mcp/McpResourceRow.tsx create mode 100644 webview-ui/src/components/mcp/McpToolRow.tsx create mode 100644 webview-ui/src/utils/mcp.ts diff --git a/src/core/Cline.ts b/src/core/Cline.ts index e7ffd4b..5571822 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -28,6 +28,7 @@ import { ClineApiReqCancelReason, ClineApiReqInfo, ClineAsk, + ClineAskUseMcpServer, ClineMessage, ClineSay, ClineSayBrowserAction, @@ -757,7 +758,7 @@ export class Cline { }) const mcpServers = this.providerRef.deref()?.mcpHub?.connections.map((conn) => conn.server) console.log("mcpServers for system prompt:", JSON.stringify(mcpServers, null, 2)) - let systemPrompt = await SYSTEM_PROMPT(cwd, this.api.getModel().info.supportsComputerUse ?? false) + let systemPrompt = await SYSTEM_PROMPT(cwd, this.api.getModel().info.supportsComputerUse ?? false, mcpServers) if (this.customInstructions && this.customInstructions.trim()) { // altering the system prompt mid-task will break the prompt cache, but in the grand scheme this will not change often so it's better to not pollute user messages with it the way we have to with systemPrompt += addCustomInstructions(this.customInstructions) @@ -898,6 +899,10 @@ export class Cline { return `[${block.name} for '${block.params.path}']` case "browser_action": return `[${block.name} for '${block.params.action}']` + case "use_mcp_tool": + return `[${block.name} for '${block.params.server_name}']` + case "access_mcp_resource": + return `[${block.name} for '${block.params.server_name}']` case "ask_followup_question": return `[${block.name} for '${block.params.question}']` case "attempt_completion": @@ -1544,7 +1549,163 @@ export class Cline { break } } + case "use_mcp_tool": { + const server_name: string | undefined = block.params.server_name + const tool_name: string | undefined = block.params.tool_name + const mcp_arguments: string | undefined = block.params.arguments + try { + if (block.partial) { + const partialMessage = JSON.stringify({ + type: "use_mcp_tool", + serverName: removeClosingTag("server_name", server_name), + toolName: removeClosingTag("tool_name", tool_name), + arguments: removeClosingTag("arguments", mcp_arguments), + } satisfies ClineAskUseMcpServer) + await this.ask("use_mcp_server", partialMessage, block.partial).catch(() => {}) + break + } else { + if (!server_name) { + this.consecutiveMistakeCount++ + pushToolResult( + await this.sayAndCreateMissingParamError("use_mcp_tool", "server_name"), + ) + break + } + if (!tool_name) { + this.consecutiveMistakeCount++ + pushToolResult( + await this.sayAndCreateMissingParamError("use_mcp_tool", "tool_name"), + ) + break + } + // arguments are optional, but if they are provided they must be valid JSON + // if (!mcp_arguments) { + // this.consecutiveMistakeCount++ + // pushToolResult(await this.sayAndCreateMissingParamError("use_mcp_tool", "arguments")) + // break + // } + let parsedArguments: Record | undefined + if (mcp_arguments) { + try { + parsedArguments = JSON.parse(mcp_arguments) + } catch (error) { + this.consecutiveMistakeCount++ + await this.say( + "error", + `Cline tried to use ${tool_name} with an invalid JSON argument. Retrying...`, + ) + pushToolResult( + formatResponse.toolError( + formatResponse.invalidMcpToolArgumentError(server_name, tool_name), + ), + ) + break + } + } + this.consecutiveMistakeCount = 0 + const completeMessage = JSON.stringify({ + type: "use_mcp_tool", + serverName: server_name, + toolName: tool_name, + arguments: mcp_arguments, + } satisfies ClineAskUseMcpServer) + const didApprove = await askApproval("use_mcp_server", completeMessage) + if (!didApprove) { + break + } + // now execute the tool + await this.say("mcp_server_request_started") // same as browser_action_result + const toolResult = await this.providerRef + .deref() + ?.mcpHub?.callTool(server_name, tool_name, parsedArguments) + // TODO: add progress indicator and ability to parse images and non-text responses + const toolResultPretty = + (toolResult?.isError + ? "Error: The tool call failed" + : toolResult?.content + .map((item) => { + if (item.type === "text") { + return item.text + } + if (item.type === "resource") { + const { blob, ...rest } = item.resource + return JSON.stringify(rest, null, 2) + } + return "" + }) + .filter(Boolean) + .join("\n\n")) || "(Empty response)" + await this.say("mcp_server_response", toolResultPretty) + pushToolResult(formatResponse.toolResult(toolResultPretty)) + break + } + } catch (error) { + await handleError("executing MCP tool", error) + break + } + } + case "access_mcp_resource": { + const server_name: string | undefined = block.params.server_name + const uri: string | undefined = block.params.uri + try { + if (block.partial) { + const partialMessage = JSON.stringify({ + type: "access_mcp_resource", + serverName: removeClosingTag("server_name", server_name), + uri: removeClosingTag("uri", uri), + } satisfies ClineAskUseMcpServer) + await this.ask("use_mcp_server", partialMessage, block.partial).catch(() => {}) + break + } else { + if (!server_name) { + this.consecutiveMistakeCount++ + pushToolResult( + await this.sayAndCreateMissingParamError("access_mcp_resource", "server_name"), + ) + break + } + if (!uri) { + this.consecutiveMistakeCount++ + pushToolResult( + await this.sayAndCreateMissingParamError("access_mcp_resource", "uri"), + ) + break + } + this.consecutiveMistakeCount = 0 + const completeMessage = JSON.stringify({ + type: "access_mcp_resource", + serverName: server_name, + uri, + } satisfies ClineAskUseMcpServer) + const didApprove = await askApproval("use_mcp_server", completeMessage) + if (!didApprove) { + break + } + // now execute the tool + await this.say("mcp_server_request_started") + const resourceResult = await this.providerRef + .deref() + ?.mcpHub?.readResource(server_name, uri) + const resourceResultPretty = + resourceResult?.contents + .map((item) => { + if (item.text) { + return item.text + } + return "" + }) + .filter(Boolean) + .join("\n\n") || "(Empty response)" + await this.say("mcp_server_response", resourceResultPretty) + pushToolResult(formatResponse.toolResult(resourceResultPretty)) + break + } + } catch (error) { + await handleError("accessing MCP resource", error) + break + } + } case "ask_followup_question": { const question: string | undefined = block.params.question try { diff --git a/src/core/assistant-message/index.ts b/src/core/assistant-message/index.ts index 32c50ae..5228cf9 100644 --- a/src/core/assistant-message/index.ts +++ b/src/core/assistant-message/index.ts @@ -16,6 +16,8 @@ export const toolUseNames = [ "list_files", "list_code_definition_names", "browser_action", + "use_mcp_tool", + "access_mcp_resource", "ask_followup_question", "attempt_completion", ] as const @@ -34,6 +36,10 @@ export const toolParamNames = [ "url", "coordinate", "text", + "server_name", + "tool_name", + "arguments", + "uri", "question", "result", ] as const @@ -84,6 +90,16 @@ export interface BrowserActionToolUse extends ToolUse { params: Partial, "action" | "url" | "coordinate" | "text">> } +export interface UseMcpToolToolUse extends ToolUse { + name: "use_mcp_tool" + params: Partial, "server_name" | "tool_name" | "arguments">> +} + +export interface AccessMcpResourceToolUse extends ToolUse { + name: "access_mcp_resource" + params: Partial, "server_name" | "uri">> +} + export interface AskFollowupQuestionToolUse extends ToolUse { name: "ask_followup_question" params: Partial, "question">> diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 7ed45d7..05f33ba 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -28,6 +28,9 @@ Otherwise, if you have not completed the task and do not need additional informa missingToolParameterError: (paramName: string) => `Missing value for required parameter '${paramName}'. Please retry with complete response.\n\n${toolUseInstructionsReminder}`, + invalidMcpToolArgumentError: (serverName: string, toolName: string) => + `Invalid JSON argument used with ${serverName} for ${toolName}. Please retry with a properly formatted JSON argument.`, + toolResult: ( text: string, images?: string[], diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index ae29462..d0c88ab 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -1,10 +1,12 @@ import osName from "os-name" import defaultShell from "default-shell" import os from "os" +import { McpServer } from "../../shared/mcp" export const SYSTEM_PROMPT = async ( cwd: string, supportsComputerUse: boolean, + mcpServers: McpServer[] = [], ) => `You are Cline, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices. ==== @@ -135,6 +137,35 @@ Usage: : "" } +## use_mcp_tool +Description: Request to use a tool provided by a connected MCP server. Each MCP server can provide multiple tools with different capabilities. Tools have defined input schemas that specify required and optional parameters. +Parameters: +- server_name: (required) The name of the MCP server providing the tool +- tool_name: (required) The name of the tool to execute +- arguments: (required) A JSON object containing the tool's input parameters, following the tool's input schema +Usage: + +server name here +tool name here + +{ + "param1": "value1", + "param2": "value2" +} + + + +## access_mcp_resource +Description: Request to access a resource provided by a connected MCP server. Resources represent data sources that can be used as context, such as files, API responses, or system information. +Parameters: +- server_name: (required) The name of the MCP server providing the resource +- uri: (required) The URI identifying the specific resource to access +Usage: + +server name here +resource URI here + + ## ask_followup_question Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth. Parameters: @@ -188,6 +219,26 @@ Your final result description here +## Example 3: Requesting to use an MCP tool + + +weather-server +get_forecast + +{ + "city": "San Francisco", + "days": 5 +} + + + +## Example 4: Requesting to access an MCP resource + + +weather-server +weather://san-francisco/current + + # Tool Use Guidelines 1. In tags, assess what information you already have and what information you need to proceed with the task. @@ -209,6 +260,92 @@ It is crucial to proceed step-by-step, waiting for the user's message after each By waiting for and carefully considering the user's response after each tool use, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work. +==== + +MCP SERVERS + +MCP (Model Context Protocol) servers provide additional capabilities through a standardized protocol. Each server can offer tools and resources that extend your abilities. + +When a server is connected, you can: + +1. Use the server's tools via the use_mcp_tool tool: + +server name here +tool name here + +{ + "param1": "value1", + "param2": "value2" +} + + + +2. Access the server's resources via the access_mcp_resource tool: + +server name here +resource URI here + + +# Guidelines for MCP Usage + +- Use one MCP operation per message and wait for confirmation before proceeding +- Handle any errors returned from MCP operations gracefully + +# Connected MCP Servers + +${ + mcpServers.length > 0 + ? `${mcpServers + .filter((server) => server.status === "connected") + .map((server) => { + const tools = + server.tools + ?.map((tool) => { + const schemaStr = tool.inputSchema + ? ` Input Schema: + ${JSON.stringify(tool.inputSchema, null, 2).split("\n").join("\n ")}` + : "" + + return `- ${tool.name}: ${tool.description || "No description provided"}\n${schemaStr}` + }) + .join("\n\n") || "No tools available" + + const templates = server.resourceTemplates?.length + ? server.resourceTemplates + .map( + (template) => + `- ${template.uriTemplate} (${template.name}): ${template.description || "No description provided"}`, + ) + .join("\n") + : "No resource templates available" + + const resources = server.resources?.length + ? server.resources + .map( + (resource) => + `- ${resource.uri} (${resource.name}): ${resource.description || "No description provided"}`, + ) + .join("\n") + : "No resources available" + + return `## Server: ${server.name} + +### Available Tools +${tools} + +### Available Resources + +#### Dynamic Resource Templates +${templates} + +#### Static Resources +${resources} +` + }) + .join("\n\n")}` + : "No MCP servers currently connected." +} + ==== CAPABILITIES @@ -225,6 +362,7 @@ CAPABILITIES ? "\n- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. This tool may be useful at key stages of web development tasks-such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.\n - For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser." : "" } +- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively. ==== @@ -245,7 +383,7 @@ RULES - The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it. - Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.${ supportsComputerUse - ? '\n- The user may ask generic non-development tasks, such as "what\'s the latest news" or "look up the weather in San Diego", in which case you might use the browser_action tool to complete the task if it makes sense to do so, rather than trying to create a website or using curl to answer the question.' + ? '\n- The user may ask generic non-development tasks, such as "what\'s the latest news" or "look up the weather in San Diego", in which case you might use the browser_action tool to complete the task if it makes sense to do so, rather than trying to create a website or using curl to answer the question. However, if an available MCP server tool or resource can be used instead, you should prefer to use it over browser_action.' : "" } - NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user. @@ -254,6 +392,7 @@ RULES - At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details. - Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal. - When using the write_to_file tool, ALWAYS provide the COMPLETE file content in your response. This is NON-NEGOTIABLE. Partial updates or placeholders like '// rest of code unchanged' are STRICTLY FORBIDDEN. You MUST include ALL parts of the file, even if they haven't been modified. Failure to do so will result in incomplete or broken code, severely impacting the user's project. +- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations. - It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.${ supportsComputerUse ? " Then if you want to test your work, you might use browser_action to launch the site, wait for the user's response confirming the site was launched along with a screenshot, then perhaps e.g., click a button to test functionality if needed, wait for the user's response confirming the button was clicked along with a screenshot of the new state, before finally closing the browser." diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f51035a..8425c96 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -588,12 +588,18 @@ export class ClineProvider implements vscode.WebviewViewProvider { // await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome } - async ensureCacheDirectoryExists(): Promise { + private async ensureCacheDirectoryExists(): Promise { const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache") await fs.mkdir(cacheDir, { recursive: true }) return cacheDir } + async ensureSettingsDirectoryExists(): Promise { + const settingsDir = path.join(this.context.globalStorageUri.fsPath, "settings") + await fs.mkdir(settingsDir, { recursive: true }) + return settingsDir + } + async readOpenRouterModels(): Promise | undefined> { const openRouterModelsFilePath = path.join( await this.ensureCacheDirectoryExists(), diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 2f7257d..088dd84 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -1,13 +1,26 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StdioClientTransport, StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js" -import { ListResourcesResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js" +import { + ListResourcesResultSchema, + ListToolsResultSchema, + ListResourceTemplatesResultSchema, + ReadResourceResultSchema, + CallToolResultSchema, +} from "@modelcontextprotocol/sdk/types.js" import deepEqual from "fast-deep-equal" import * as fs from "fs/promises" import * as path from "path" import * as vscode from "vscode" import { z } from "zod" import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider" -import { McpResource, McpServer, McpTool } from "../../shared/mcp" +import { + McpResource, + McpResourceResponse, + McpResourceTemplate, + McpServer, + McpTool, + McpToolCallResponse, +} from "../../shared/mcp" import { fileExistsAtPath } from "../../utils/fs" import { arePathsEqual } from "../../utils/path" @@ -46,7 +59,10 @@ export class McpHub { if (!provider) { throw new Error("Provider not available") } - const mcpSettingsFilePath = path.join(await provider.ensureCacheDirectoryExists(), GlobalFileNames.mcpSettings) + const mcpSettingsFilePath = path.join( + await provider.ensureSettingsDirectoryExists(), + GlobalFileNames.mcpSettings, + ) const fileExists = await fileExistsAtPath(mcpSettingsFilePath) if (!fileExists) { await fs.writeFile( @@ -177,9 +193,32 @@ export class McpHub { this.connections.push(connection) connection.server.status = "connected" - // After successful connection, fetch tools and resources - connection.server.tools = await this.fetchTools(name) - connection.server.resources = await this.fetchResources(name) + // // Set up notification handlers + // client.setNotificationHandler( + // // @ts-ignore-next-line + // { method: "notifications/tools/list_changed" }, + // async () => { + // console.log(`Tools changed for server: ${name}`) + // connection.server.tools = await this.fetchTools(name) + // await this.notifyWebviewOfServerChanges() + // }, + // ) + + // client.setNotificationHandler( + // // @ts-ignore-next-line + // { method: "notifications/resources/list_changed" }, + // async () => { + // console.log(`Resources changed for server: ${name}`) + // connection.server.resources = await this.fetchResources(name) + // connection.server.resourceTemplates = await this.fetchResourceTemplates(name) + // await this.notifyWebviewOfServerChanges() + // }, + // ) + + // Initial fetch of tools and resources + connection.server.tools = await this.fetchToolsList(name) + connection.server.resources = await this.fetchResourcesList(name) + connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name) await this.notifyWebviewOfServerChanges() } catch (error) { @@ -194,7 +233,7 @@ export class McpHub { } } - private async fetchTools(serverName: string): Promise { + private async fetchToolsList(serverName: string): Promise { try { const response = await this.connections .find((conn) => conn.server.name === serverName) @@ -206,7 +245,7 @@ export class McpHub { } } - private async fetchResources(serverName: string): Promise { + private async fetchResourcesList(serverName: string): Promise { try { const response = await this.connections .find((conn) => conn.server.name === serverName) @@ -218,10 +257,24 @@ export class McpHub { } } + private async fetchResourceTemplatesList(serverName: string): Promise { + try { + const response = await this.connections + .find((conn) => conn.server.name === serverName) + ?.client.request({ method: "resources/templates/list" }, ListResourceTemplatesResultSchema) + return response?.resourceTemplates || [] + } catch (error) { + console.error(`Failed to fetch resource templates for ${serverName}:`, error) + return [] + } + } + async deleteConnection(name: string): Promise { const connection = this.connections.find((conn) => conn.server.name === name) if (connection) { try { + // connection.client.removeNotificationHandler("notifications/tools/list_changed") + // connection.client.removeNotificationHandler("notifications/resources/list_changed") await connection.transport.close() await connection.client.close() } catch (error) { @@ -297,6 +350,45 @@ export class McpHub { }) } + // Using server + + async readResource(serverName: string, uri: string): Promise { + const connection = this.connections.find((conn) => conn.server.name === serverName) + if (!connection) { + throw new Error(`No connection found for server: ${serverName}`) + } + return await connection.client.request( + { + method: "resources/read", + params: { + uri, + }, + }, + ReadResourceResultSchema, + ) + } + + async callTool( + serverName: string, + toolName: string, + toolArguments?: Record, + ): Promise { + const connection = this.connections.find((conn) => conn.server.name === serverName) + if (!connection) { + throw new Error(`No connection found for server: ${serverName}`) + } + return await connection.client.request( + { + method: "tools/call", + params: { + name: toolName, + arguments: toolArguments, + }, + }, + CallToolResultSchema, + ) + } + async dispose(): Promise { for (const connection of this.connections) { try { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 51cb0a6..f930f3f 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -68,6 +68,7 @@ export type ClineAsk = | "resume_completed_task" | "mistake_limit_reached" | "browser_action_launch" + | "use_mcp_server" export type ClineSay = | "task" @@ -84,6 +85,8 @@ export type ClineSay = | "shell_integration_warning" | "browser_action" | "browser_action_result" + | "mcp_server_request_started" + | "mcp_server_response" export interface ClineSayTool { tool: @@ -118,6 +121,14 @@ export type BrowserActionResult = { currentMousePosition?: string } +export interface ClineAskUseMcpServer { + serverName: string + type: "use_mcp_tool" | "access_mcp_resource" + toolName?: string + arguments?: string + uri?: string +} + export interface ClineApiReqInfo { request?: string tokensIn?: number diff --git a/src/shared/mcp.ts b/src/shared/mcp.ts index 66eede2..82efae2 100644 --- a/src/shared/mcp.ts +++ b/src/shared/mcp.ts @@ -5,6 +5,7 @@ export type McpServer = { error?: string tools?: McpTool[] resources?: McpResource[] + resourceTemplates?: McpResourceTemplate[] } export type McpTool = { @@ -19,3 +20,45 @@ export type McpResource = { mimeType?: string description?: string } + +export type McpResourceTemplate = { + uriTemplate: string + name: string + description?: string + mimeType?: string +} + +export type McpResourceResponse = { + _meta?: Record + contents: Array<{ + uri: string + mimeType?: string + text?: string + blob?: string + }> +} + +export type McpToolCallResponse = { + _meta?: Record + content: Array< + | { + type: "text" + text: string + } + | { + type: "image" + data: string + mimeType: string + } + | { + type: "resource" + resource: { + uri: string + mimeType?: string + text?: string + blob?: string + } + } + > + isError?: boolean +} diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 9d0063f..ec5b877 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -2,13 +2,22 @@ import { VSCodeBadge, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/reac import deepEqual from "fast-deep-equal" import React, { memo, useEffect, useMemo, useRef } from "react" import { useSize } from "react-use" -import { ClineApiReqInfo, ClineMessage, ClineSayTool } from "../../../../src/shared/ExtensionMessage" +import { + ClineApiReqInfo, + ClineAskUseMcpServer, + ClineMessage, + ClineSayTool, +} from "../../../../src/shared/ExtensionMessage" import { COMMAND_OUTPUT_STRING } from "../../../../src/shared/combineCommandSequences" +import { useExtensionState } from "../../context/ExtensionStateContext" +import { findMatchingResourceOrTemplate } from "../../utils/mcp" import { vscode } from "../../utils/vscode" import CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian" import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock" import MarkdownBlock from "../common/MarkdownBlock" import Thumbnails from "../common/Thumbnails" +import McpResourceRow from "../mcp/McpResourceRow" +import McpToolRow from "../mcp/McpToolRow" import { highlightMentions } from "./TaskHeader" interface ChatRowProps { @@ -67,6 +76,7 @@ export const ChatRowContent = ({ lastModifiedMessage, isLast, }: ChatRowContentProps) => { + const { mcpServers } = useExtensionState() const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => { if (message.text != null && message.say === "api_req_started") { const info: ClineApiReqInfo = JSON.parse(message.text) @@ -81,6 +91,9 @@ export const ChatRowContent = ({ : undefined const isCommandExecuting = isLast && lastModifiedMessage?.ask === "command" && lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING) + + const isMcpServerResponding = isLast && lastModifiedMessage?.say === "mcp_server_request_started" + const type = message.type === "ask" ? message.ask : message.say const normalColor = "var(--vscode-foreground)" @@ -117,6 +130,20 @@ export const ChatRowContent = ({ Cline wants to execute this command: , ] + case "use_mcp_server": + const mcpServerUse = JSON.parse(message.text || "{}") as ClineAskUseMcpServer + return [ + isMcpServerResponding ? ( + + ) : ( + + ), + + Cline wants to use the {mcpServerUse.serverName} MCP server: + , + ] case "completion_result": return [ ) + case "mcp_server_response": + return ( + <> +
+
+ Response +
+ +
+ + ) default: return ( <> @@ -716,6 +768,73 @@ export const ChatRowContent = ({ ) + case "use_mcp_server": + const useMcpServer = JSON.parse(message.text || "{}") as ClineAskUseMcpServer + const server = mcpServers.find((server) => server.name === useMcpServer.serverName) + return ( + <> +
+ {icon} + {title} +
+ +
+ {useMcpServer.type === "access_mcp_resource" && ( + + )} + + {useMcpServer.type === "use_mcp_tool" && ( + <> + tool.name === useMcpServer.toolName) + ?.description || "", + }} + /> + {useMcpServer.arguments && ( +
+
+ Arguments +
+ +
+ )} + + )} +
+ + ) case "completion_result": if (message.text) { return ( diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index abdcec7..0773099 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -133,6 +133,13 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie setPrimaryButtonText("Proceed While Running") setSecondaryButtonText(undefined) break + case "use_mcp_server": + setTextAreaDisabled(isPartial) + setClineAsk("use_mcp_server") + setEnableButtons(!isPartial) + setPrimaryButtonText("Approve") + setSecondaryButtonText("Reject") + break case "completion_result": // extension waiting for feedback. but we can just present a new task button setTextAreaDisabled(isPartial) @@ -179,6 +186,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie case "browser_action": case "browser_action_result": case "command_output": + case "mcp_server_request_started": + case "mcp_server_response": case "completion_result": case "tool": break @@ -247,6 +256,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie case "browser_action_launch": case "command": // user can provide feedback to a tool or command use case "command_output": // user can send input to command stdin + case "use_mcp_server": case "completion_result": // if this happens then the user has feedback for the completion result case "resume_task": case "resume_completed_task": @@ -288,6 +298,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie case "command_output": case "tool": case "browser_action_launch": + case "use_mcp_server": case "resume_task": case "mistake_limit_reached": vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" }) @@ -321,6 +332,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie case "command": case "tool": case "browser_action_launch": + case "use_mcp_server": // responds to the API with a "This operation failed" and lets it try again vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" }) break @@ -436,6 +448,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie return false } break + case "mcp_server_request_started": + return false } return true }) diff --git a/webview-ui/src/components/mcp/McpResourceRow.tsx b/webview-ui/src/components/mcp/McpResourceRow.tsx new file mode 100644 index 0000000..8ecccdc --- /dev/null +++ b/webview-ui/src/components/mcp/McpResourceRow.tsx @@ -0,0 +1,62 @@ +import { McpResource, McpResourceTemplate } from "../../../../src/shared/mcp" + +type McpResourceRowProps = { + item: McpResource | McpResourceTemplate +} + +const McpResourceRow = ({ item }: McpResourceRowProps) => { + const isTemplate = "uriTemplate" in item + const uri = isTemplate ? item.uriTemplate : item.uri + + return ( +
+
+ + {uri} +
+
+ {item.name && item.description + ? `${item.name}: ${item.description}` + : !item.name && item.description + ? item.description + : !item.description && item.name + ? item.name + : "No description"} +
+
+ Returns + + {item.mimeType || "Unknown"} + +
+
+ ) +} + +export default McpResourceRow diff --git a/webview-ui/src/components/mcp/McpToolRow.tsx b/webview-ui/src/components/mcp/McpToolRow.tsx new file mode 100644 index 0000000..703c86b --- /dev/null +++ b/webview-ui/src/components/mcp/McpToolRow.tsx @@ -0,0 +1,75 @@ +import { McpTool } from "../../../../src/shared/mcp" +import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock" + +type McpToolRowProps = { + tool: McpTool +} + +const McpToolRow = ({ tool }: McpToolRowProps) => { + return ( +
+
+ + {tool.name} +
+ {tool.description && ( +
+ {tool.description} +
+ )} + {tool.inputSchema && "properties" in tool.inputSchema && ( +
+
+ Parameters +
+ {Object.entries(tool.inputSchema.properties as Record).map(([paramName, schema]) => { + const isRequired = + tool.inputSchema && + "required" in tool.inputSchema && + Array.isArray(tool.inputSchema.required) && + tool.inputSchema.required.includes(paramName) + + return ( +
+ + {paramName} + {isRequired && *} + + {schema.description || "No description"} +
+ ) + })} +
+ )} +
+ ) +} + +export default McpToolRow diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index 8da3650..391628d 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -3,6 +3,8 @@ import { useState } from "react" import { vscode } from "../../utils/vscode" import { useExtensionState } from "../../context/ExtensionStateContext" import { McpServer } from "../../../../src/shared/mcp" +import McpToolRow from "./McpToolRow" +import McpResourceRow from "./McpResourceRow" type McpViewProps = { onDone: () => void @@ -162,7 +164,7 @@ const ServerRow = ({ server }: { server: McpServer }) => { display: "flex", alignItems: "center", padding: "8px", - background: "var(--vscode-list-hoverBackground)", + background: "var(--vscode-textCodeBlock-background)", cursor: server.error ? "default" : "pointer", borderRadius: isExpanded || server.error ? "4px 4px 0 0" : "4px", }} @@ -190,7 +192,7 @@ const ServerRow = ({ server }: { server: McpServer }) => { style={{ padding: "8px", fontSize: "13px", - background: "var(--vscode-list-hoverBackground)", + background: "var(--vscode-textCodeBlock-background)", borderRadius: "0 0 4px 4px", }}>
{server.error}
@@ -203,7 +205,7 @@ const ServerRow = ({ server }: { server: McpServer }) => { isExpanded && (
{ {server.tools && server.tools.length > 0 ? ( -
+
{server.tools.map((tool) => ( -
-
- - {tool.name} -
-
- {tool.description} -
-
+ ))}
) : ( @@ -246,35 +229,19 @@ const ServerRow = ({ server }: { server: McpServer }) => { )} - {/* Resources Panel View */} - {server.resources && server.resources.length > 0 ? ( -
- {server.resources.map((resource) => ( -
-
- - {resource.name} -
-
- - {resource.uri} - -
-
- ))} + {(server.resources && server.resources.length > 0) || + (server.resourceTemplates && server.resourceTemplates.length > 0) ? ( +
+ {[...(server.resourceTemplates || []), ...(server.resources || [])].map( + (item) => ( + + ), + )}
) : (
diff --git a/webview-ui/src/utils/mcp.ts b/webview-ui/src/utils/mcp.ts new file mode 100644 index 0000000..3b53009 --- /dev/null +++ b/webview-ui/src/utils/mcp.ts @@ -0,0 +1,47 @@ +import { McpResource, McpResourceTemplate } from "../../../src/shared/mcp" + +/** + * Matches a URI against an array of URI templates and returns the matching template + * @param uri The URI to match + * @param templates Array of URI templates to match against + * @returns The matching template or undefined if no match is found + */ +export function findMatchingTemplate( + uri: string, + templates: McpResourceTemplate[] = [], +): McpResourceTemplate | undefined { + return templates.find((template) => { + // Convert template to regex pattern + const pattern = template.uriTemplate + // Replace {param} with ([^/]+) to match any non-slash characters + .replace(/\{([^}]+)\}/g, "([^/]+)") + // Escape special regex characters except the ones we just added + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + // Un-escape the capturing groups we added + .replace(/\\\(/g, "(") + .replace(/\\\)/g, ")") + + const regex = new RegExp(`^${pattern}$`) + return regex.test(uri) + }) +} + +/** + * Finds either an exact resource match or a matching template for a given URI + * @param uri The URI to find a match for + * @param resources Array of concrete resources + * @param templates Array of resource templates + * @returns The matching resource, template, or undefined + */ +export function findMatchingResourceOrTemplate( + uri: string, + resources: McpResource[] = [], + templates: McpResourceTemplate[] = [], +): McpResource | McpResourceTemplate | undefined { + // First try to find an exact resource match + const exactMatch = resources.find((resource) => resource.uri === uri) + if (exactMatch) return exactMatch + + // If no exact match, try to find a matching template + return findMatchingTemplate(uri, templates) +}