Files
cherry-studio/src/renderer/src/utils/mcp-tools.ts
T
LiuVaayne 24e28b86cf feat(mcp): support MCP by prompt (#4476)
* feat: implement tool usage handling and system prompt building for AI providers

* refactor: streamline tool usage handling and remove unused code in OpenAIProvider and formats

* refactor: simplify tool usage handling in Anthropic and Gemini providers, and update prompt instructions

* refactor: remove unused function calling model checks and simplify MCP tools handling in Inputbar

* hidden tool use in message

* revert  import

* Add idx parameter to parseAndCallTools for unique tool IDs
2025-04-09 11:22:14 +08:00

462 lines
13 KiB
TypeScript

import { Tool, ToolUnion, ToolUseBlock } from '@anthropic-ai/sdk/resources'
import {
ArraySchema,
BaseSchema,
BooleanSchema,
EnumStringSchema,
FunctionCall,
FunctionDeclaration,
FunctionDeclarationSchema,
FunctionDeclarationSchemaProperty,
IntegerSchema,
NumberSchema,
ObjectSchema,
SchemaType,
SimpleStringSchema,
Tool as geminiTool
} from '@google/generative-ai'
import { nanoid } from '@reduxjs/toolkit'
import store from '@renderer/store'
import { addMCPServer } from '@renderer/store/mcp'
import { MCPServer, MCPTool, MCPToolResponse } from '@renderer/types'
import { ChatCompletionMessageToolCall, ChatCompletionTool } from 'openai/resources'
import { ChunkCallbackData, CompletionsParams } from '../providers/AiProvider'
const ensureValidSchema = (obj: Record<string, any>): FunctionDeclarationSchemaProperty => {
// Filter out unsupported keys for Gemini
const filteredObj = filterUnsupportedKeys(obj)
// Handle base schema properties
const baseSchema = {
description: filteredObj.description,
nullable: filteredObj.nullable
} as BaseSchema
// Handle string type
if (filteredObj.type?.toLowerCase() === SchemaType.STRING) {
if (filteredObj.enum && Array.isArray(filteredObj.enum)) {
return {
...baseSchema,
type: SchemaType.STRING,
format: 'enum',
enum: filteredObj.enum as string[]
} as EnumStringSchema
}
return {
...baseSchema,
type: SchemaType.STRING,
format: filteredObj.format === 'date-time' ? 'date-time' : undefined
} as SimpleStringSchema
}
// Handle number type
if (filteredObj.type?.toLowerCase() === SchemaType.NUMBER) {
return {
...baseSchema,
type: SchemaType.NUMBER,
format: ['float', 'double'].includes(filteredObj.format) ? (filteredObj.format as 'float' | 'double') : undefined
} as NumberSchema
}
// Handle integer type
if (filteredObj.type?.toLowerCase() === SchemaType.INTEGER) {
return {
...baseSchema,
type: SchemaType.INTEGER,
format: ['int32', 'int64'].includes(filteredObj.format) ? (filteredObj.format as 'int32' | 'int64') : undefined
} as IntegerSchema
}
// Handle boolean type
if (filteredObj.type?.toLowerCase() === SchemaType.BOOLEAN) {
return {
...baseSchema,
type: SchemaType.BOOLEAN
} as BooleanSchema
}
// Handle array type
if (filteredObj.type?.toLowerCase() === SchemaType.ARRAY) {
return {
...baseSchema,
type: SchemaType.ARRAY,
items: filteredObj.items
? ensureValidSchema(filteredObj.items as Record<string, any>)
: ({ type: SchemaType.STRING } as SimpleStringSchema),
minItems: filteredObj.minItems,
maxItems: filteredObj.maxItems
} as ArraySchema
}
// Handle object type (default)
const properties = filteredObj.properties
? Object.fromEntries(
Object.entries(filteredObj.properties).map(([key, value]) => [
key,
ensureValidSchema(value as Record<string, any>)
])
)
: { _empty: { type: SchemaType.STRING } as SimpleStringSchema } // Ensure properties is never empty
return {
...baseSchema,
type: SchemaType.OBJECT,
properties,
required: Array.isArray(filteredObj.required) ? filteredObj.required : undefined
} as ObjectSchema
}
function filterUnsupportedKeys(obj: Record<string, any>): Record<string, any> {
const supportedBaseKeys = ['description', 'nullable']
const supportedStringKeys = [...supportedBaseKeys, 'type', 'format', 'enum']
const supportedNumberKeys = [...supportedBaseKeys, 'type', 'format']
const supportedBooleanKeys = [...supportedBaseKeys, 'type']
const supportedArrayKeys = [...supportedBaseKeys, 'type', 'items', 'minItems', 'maxItems']
const supportedObjectKeys = [...supportedBaseKeys, 'type', 'properties', 'required']
const filtered: Record<string, any> = {}
let keysToKeep: string[]
if (obj.type?.toLowerCase() === SchemaType.STRING) {
keysToKeep = supportedStringKeys
} else if (obj.type?.toLowerCase() === SchemaType.NUMBER) {
keysToKeep = supportedNumberKeys
} else if (obj.type?.toLowerCase() === SchemaType.INTEGER) {
keysToKeep = supportedNumberKeys
} else if (obj.type?.toLowerCase() === SchemaType.BOOLEAN) {
keysToKeep = supportedBooleanKeys
} else if (obj.type?.toLowerCase() === SchemaType.ARRAY) {
keysToKeep = supportedArrayKeys
} else {
// Default to object type
keysToKeep = supportedObjectKeys
}
// copy supported keys
for (const key of keysToKeep) {
if (obj[key] !== undefined) {
filtered[key] = obj[key]
}
}
return filtered
}
function filterPropertieAttributes(tool: MCPTool, filterNestedObj: boolean = false): Record<string, object> {
const properties = tool.inputSchema.properties
if (!properties) {
return {}
}
// For OpenAI, we don't need to validate as strictly
if (!filterNestedObj) {
return properties
}
const processedProperties = Object.fromEntries(
Object.entries(properties).map(([key, value]) => [key, ensureValidSchema(value as Record<string, any>)])
)
return processedProperties
}
export function mcpToolsToOpenAITools(mcpTools: MCPTool[]): Array<ChatCompletionTool> {
return mcpTools.map((tool) => ({
type: 'function',
name: tool.name,
function: {
name: tool.id,
description: tool.description,
parameters: {
type: 'object',
properties: filterPropertieAttributes(tool)
}
}
}))
}
export function openAIToolsToMcpTool(
mcpTools: MCPTool[] | undefined,
llmTool: ChatCompletionMessageToolCall
): MCPTool | undefined {
if (!mcpTools) {
return undefined
}
const tool = mcpTools.find(
(mcptool) => mcptool.id === llmTool.function.name || mcptool.name === llmTool.function.name
)
if (!tool) {
console.warn('No MCP Tool found for tool call:', llmTool)
return undefined
}
console.log(
`[MCP] OpenAI Tool to MCP Tool: ${tool.serverName} ${tool.name}`,
tool,
'args',
llmTool.function.arguments
)
// use this to parse the arguments and avoid parsing errors
let args: any = {}
try {
args = JSON.parse(llmTool.function.arguments)
} catch (e) {
console.error('Error parsing arguments', e)
}
return {
id: tool.id,
serverId: tool.serverId,
serverName: tool.serverName,
name: tool.name,
description: tool.description,
inputSchema: args
}
}
export async function callMCPTool(tool: MCPTool): Promise<any> {
console.log(`[MCP] Calling Tool: ${tool.serverName} ${tool.name}`, tool)
try {
const server = getMcpServerByTool(tool)
if (!server) {
throw new Error(`Server not found: ${tool.serverName}`)
}
const resp = await window.api.mcp.callTool({
server,
name: tool.name,
args: tool.inputSchema
})
console.log(`[MCP] Tool called: ${tool.serverName} ${tool.name}`, resp)
if (tool.serverName === '@cherry/mcp-auto-install') {
if (resp.data) {
const mcpServer: MCPServer = {
id: `f${nanoid()}`,
name: resp.data.name,
description: resp.data.description,
baseUrl: resp.data.baseUrl,
command: resp.data.command,
args: resp.data.args,
env: resp.data.env,
registryUrl: '',
isActive: false
}
store.dispatch(addMCPServer(mcpServer))
}
}
return resp
} catch (e) {
console.error(`[MCP] Error calling Tool: ${tool.serverName} ${tool.name}`, e)
return Promise.resolve({
isError: true,
content: [
{
type: 'text',
text: `Error calling tool ${tool.name}: ${JSON.stringify(e)}`
}
]
})
}
}
export function mcpToolsToAnthropicTools(mcpTools: MCPTool[]): Array<ToolUnion> {
return mcpTools.map((tool) => {
const t: Tool = {
name: tool.id,
description: tool.description,
// @ts-ignore no check
input_schema: tool.inputSchema
}
return t
})
}
export function anthropicToolUseToMcpTool(mcpTools: MCPTool[] | undefined, toolUse: ToolUseBlock): MCPTool | undefined {
if (!mcpTools) return undefined
const tool = mcpTools.find((tool) => tool.id === toolUse.name)
if (!tool) {
return undefined
}
// @ts-ignore ignore type as it it unknow
tool.inputSchema = toolUse.input
return tool
}
export function mcpToolsToGeminiTools(mcpTools: MCPTool[] | undefined): geminiTool[] {
if (!mcpTools || mcpTools.length === 0) {
// No tools available
return []
}
const functions: FunctionDeclaration[] = []
for (const tool of mcpTools) {
const properties = filterPropertieAttributes(tool, true)
const functionDeclaration: FunctionDeclaration = {
name: tool.id,
description: tool.description,
parameters: {
type: SchemaType.OBJECT,
properties:
Object.keys(properties).length > 0
? Object.fromEntries(
Object.entries(properties).map(([key, value]) => [key, ensureValidSchema(value as Record<string, any>)])
)
: { _empty: { type: SchemaType.STRING } as SimpleStringSchema }
} as FunctionDeclarationSchema
}
functions.push(functionDeclaration)
}
const tool: geminiTool = {
functionDeclarations: functions
}
return [tool]
}
export function geminiFunctionCallToMcpTool(
mcpTools: MCPTool[] | undefined,
fcall: FunctionCall | undefined
): MCPTool | undefined {
if (!fcall) return undefined
if (!mcpTools) return undefined
const tool = mcpTools.find((tool) => tool.id === fcall.name)
if (!tool) {
return undefined
}
// @ts-ignore schema is not a valid property
tool.inputSchema = fcall.args
return tool
}
export function upsertMCPToolResponse(
results: MCPToolResponse[],
resp: MCPToolResponse,
onChunk: ({ mcpToolResponse }: ChunkCallbackData) => void
) {
try {
for (const ret of results) {
if (ret.id === resp.id) {
ret.response = resp.response
ret.status = resp.status
return
}
}
results.push(resp)
} finally {
onChunk({
text: '\n',
mcpToolResponse: results
})
}
}
export function filterMCPTools(
mcpTools: MCPTool[] | undefined,
enabledServers: MCPServer[] | undefined
): MCPTool[] | undefined {
if (mcpTools) {
if (enabledServers) {
mcpTools = mcpTools.filter((t) => enabledServers.some((m) => m.name === t.serverName))
} else {
mcpTools = []
}
}
return mcpTools
}
export function getMcpServerByTool(tool: MCPTool) {
const servers = store.getState().mcp.servers
return servers.find((s) => s.id === tool.serverId)
}
export function parseToolUse(content: string, mcpTools: MCPTool[]): MCPToolResponse[] {
if (!content || !mcpTools || mcpTools.length === 0) {
return []
}
const toolUsePattern =
/<tool_use>([\s\S]*?)<name>([\s\S]*?)<\/name>([\s\S]*?)<arguments>([\s\S]*?)<\/arguments>([\s\S]*?)<\/tool_use>/g
const tools: MCPToolResponse[] = []
let match
let idx = 0
// Find all tool use blocks
while ((match = toolUsePattern.exec(content)) !== null) {
// const fullMatch = match[0]
const toolName = match[2].trim()
const toolArgs = match[4].trim()
// Try to parse the arguments as JSON
let parsedArgs
try {
parsedArgs = JSON.parse(toolArgs)
} catch (error) {
// If parsing fails, use the string as is
parsedArgs = toolArgs
}
// console.log(`Parsed arguments for tool "${toolName}":`, parsedArgs)
const mcpTool = mcpTools.find((tool) => tool.id === toolName)
if (!mcpTool) {
console.error(`Tool "${toolName}" not found in MCP tools`)
continue
}
// Add to tools array
tools.push({
id: `${toolName}-${idx++}`, // Unique ID for each tool use
tool: {
...mcpTool,
inputSchema: parsedArgs
},
status: 'pending'
})
// Remove the tool use block from the content
// content = content.replace(fullMatch, '')
}
return tools
}
export async function parseAndCallTools(
content: string,
toolResponses: MCPToolResponse[],
onChunk: CompletionsParams['onChunk'],
idx: number,
mcpTools?: MCPTool[]
): Promise<string[]> {
const toolResults: string[] = []
// process tool use
const tools = parseToolUse(content, mcpTools || [])
if (!tools || tools.length === 0) {
return toolResults
}
for (let i = 0; i < tools.length; i++) {
const tool = tools[i]
upsertMCPToolResponse(toolResponses, { id: `${tool.id}-${idx}-${i}`, tool: tool.tool, status: 'invoking' }, onChunk)
}
const toolPromises = tools.map(async (tool, i) => {
const toolCallResponse = await callMCPTool(tool.tool)
const result = `
<tool_use_result>
<name>${tool.id}</name>
<result>${JSON.stringify(toolCallResponse)}</result>
</tool_use_result>
`.trim()
upsertMCPToolResponse(
toolResponses,
{ id: `${tool.id}-${idx}-${i}`, tool: tool.tool, status: 'done', response: toolCallResponse },
onChunk
)
return result
})
toolResults.push(...(await Promise.all(toolPromises)))
return toolResults
}