聊聊MCP Client及其实践

最近MCP风头正盛,技术圈都在讨论这个玩意儿。笔者所在的项目组也跟风想要搞MCP,作为资深牛马,这个任务自然而然又落到自己身上。关于什么是MCP,以及如何构建一个MCP Server已经有很多文章写了,本文就重点聊一聊MCP Client

MCP Client

先看官方文档的MCP协议架构图,MCP Client通过 MCP Protocol实现跟MCP Server的通信。好的,那这个MCP Client实现了跟MCP Server的通信后会做些什么呢?请接着往下看

1. 跟MCP Server建立连接

根据最新的官方typescript-sdk仓库信息,目前Client可以使用四种通信方法建立跟Server的连接。

  1. sse方式,需要node.js >= 18,因为使用了原生的fetch API;
  2. stdio方式,让Server作为一个本地进程运行在MCP Client所在的Host里,然后里进程间I/O方法通信;
  3. websocket方式,通过websocket协议建立双向通信
  4. http方式,还未正式发布(截止到2025.4.26)

上面几种方式各有优势,开发者可以自行决定使用哪种方式(当然,也跟Server端的支持紧密相关,如果Server只支持其中一种,那你也只能乖乖配合了)。

2. 获取tools列表

tools是什么?

Enable LLMs to perform actions through your server

Tools are a powerful primitive in the Model Context Protocol (MCP) that enable servers to expose executable functionality to clients. Through tools, LLMs can interact with external systems, perform computations, and take actions in the real world.

Form Google 翻译:

tools使 LLM 能够通过服务器执行操作

tools是MCP中一个强大的原语,它使服务器能够向客户端公开可执行功能。通过工具,LLM 可以与外部系统交互、执行计算并在现实世界中采取行动。

准确来讲,这句话有一定的误导性,单从字面意思上看很容易让人以为大模型会"亲自"使用tool。其实不是这样的,所以这句话更准确的理解是LLM可以通过Server知道外界有哪些tools,然后对于一个复杂task,LLM可以告诉你使用哪些tools来解决这个问题。LLM不会自己下场去调用tools!!!

3. 携带tools信息跟LLM通信

为什么要携带tools信息跟LLM通信?

大哥,你不携带tools信息LLM怎么知道你手里有哪些工具,不知道有哪些工具LLM又怎么知道你使用工具呢?

做个简单的比喻,LLM是一个大脑,脖子以下都没有(让我想起《黑侠II》里被大反派只保留头部的摩洛克博士)。所以ta只能告诉你下一步的动作,自己是无法直接去执行这个动作的。

所以,调用tools的任务只能 MCP Client 来执行。

4. 调用tools

MCP Client在得到LLM的指引后,仍然会通过跟MCP Serve的连接去调用tool。没错,这里又回到了 Client 跟 Server 的交互。

5. 让LLM整理tools结果

这一步是可选的,一些操作类的工具没有返回结果,也就不需要LLM进行二次加工。一些结果类、文书类的结果可以扔给LLM,让LLM二次加工,返回最终整理的内容。

顺着上面的思路,整理得到 MCP Client 跟LLM和MCP Server调用时序图:

实践

上面给出MCP Client跟MCP Serve和LLM交互的主要流程,接下来详细看看两个实际的产品是如何实现 MCP Client的能力的。

Cline

cline是一个开源 AI 编码助手,具有双重计划/行动模式、终端执行和用于 VS Code 的模型上下文协议 (MCP)。在 VS Code 里安装了cline 插件后,就可以看到cline的聊天界面:

这里不细究cline的具体功能,而是直接去看ta的源码,重点关注ta是如何集成MCP的。

去github上clonecline的源码,可以很快找到 McpHub.ts这个文件,没错,这个文件就实现了 MCP Client的所有能力:

上面类图列举了 McpHub所有的方法,需要重点关注的就这三个方法:connectToServer,fetchToolsList,callTool

connectToServer

这个方法主要作用是建立起跟 MCP Server的通信链路

typescript 复制代码
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"

private async connectToServer(
	name: string,
	config: z.infer<typeof StdioConfigSchema> | z.infer<typeof SseConfigSchema>,
): Promise<void> {
   // ...
	const client = new Client(
		{
			name: "Cline",
			version: this.clientVersion,
		},
		{
			capabilities: {},
		},
	)

	let transport: StdioClientTransport | SSEClientTransport

	if (config.transportType === "sse") {
		transport = new SSEClientTransport(new URL(config.url), {})
	} else {
		transport = new StdioClientTransport({
			command: config.command,
			args: config.args,
			env: {
				...config.env,
				...(process.env.PATH ? { PATH: process.env.PATH } : {}),
				// ...(process.env.NODE_PATH ? { NODE_PATH: process.env.NODE_PATH } : {}),
			},
			stderr: "pipe", // necessary for stderr to be available
		})
	}
}
// ...

核心逻辑是上面这段,根据配置决定是使用sse方法还是stdio方式跟MCP Server进行通信。

fetchToolsList

该方法是在建立跟Server端的通信后,去获取当前MCP Server支持的tools列表:

typescript 复制代码
private async fetchToolsList(serverName: string): Promise<McpTool[]> {
		try {
			const connection = this.connections.find((conn) => conn.server.name === serverName)

			if (!connection) {
				throw new Error(`No connection found for server: ${serverName}`)
			}

			const response = await connection.client.request({ method: "tools/list" }, ListToolsResultSchema, {
				timeout: DEFAULT_REQUEST_TIMEOUT_MS,
			})

			// Get autoApprove settings
			const settingsPath = await this.getMcpSettingsFilePath()
			const content = await fs.readFile(settingsPath, "utf-8")
			const config = JSON.parse(content)
			const autoApproveConfig = config.mcpServers[serverName]?.autoApprove || []

			// Mark tools as always allowed based on settings
			const tools = (response?.tools || []).map((tool) => ({
				...tool,
				autoApprove: autoApproveConfig.includes(tool.name),
			}))

			// console.log(`[MCP] Fetched tools for ${serverName}:`, tools)
			return tools
		} catch (error) {
			// console.error(`Failed to fetch tools for ${serverName}:`, error)
			return []
		}
	}

callTool

callTool方法是最关键的一个方法,通过该方法调用具体的tool,完成某一项功能:

ts 复制代码
async callTool(serverName: string, toolName: string, toolArguments?: Record<string, unknown>): Promise<McpToolCallResponse> {
		const connection = this.connections.find((conn) => conn.server.name === serverName)
		if (!connection) {
			throw new Error(
				`No connection found for server: ${serverName}. 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`)
		}

		let timeout = secondsToMs(DEFAULT_MCP_TIMEOUT_SECONDS) // sdk expects ms

		try {
			const config = JSON.parse(connection.server.config)
			const parsedConfig = ServerConfigSchema.parse(config)
			timeout = secondsToMs(parsedConfig.timeout)
		} catch (error) {
			console.error(`Failed to parse timeout configuration for server ${serverName}: ${error}`)
		}

		return await connection.client.request(
			{
				method: "tools/call",
				params: {
					name: toolName,
					arguments: toolArguments,
				},
			},
			CallToolResultSchema,
			{
				timeout,
			},
		)
	}

基本上来讲,所有的 MCP Client都有类似的三个方法来建立连接、获取tools列表、调用tool。可能实现有稍许差别,但基本的方法结构跟上面的方法是一致的。

如何使用

那么最关键的一个问题:Cline如何使用具体的tool呢?

答案是:Prompts!!! Cline的驱动大模型完成任务的核心便是一系列内置的Prompts,系统 Prompts 主入口是这个文件 system.ts,从中可以看到这段提示词:

markdown 复制代码
====

MCP SERVERS

The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.

# Connected MCP Servers

When a server is connected, you can use the server's tools via the \`use_mcp_tool\` tool, and access the server's resources via the \`access_mcp_resource\` tool.

${
	mcpHub.getServers().length > 0
		? `${mcpHub
				.getServers()
				.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}\n${schemaStr}`
						})
						.join("\n\n")

					const templates = server.resourceTemplates
						?.map((template) => `- ${template.uriTemplate} (${template.name}): ${template.description}`)
						.join("\n")

					const resources = server.resources
						?.map((resource) => `- ${resource.uri} (${resource.name}): ${resource.description}`)
						.join("\n")

					const config = JSON.parse(server.config)

					return (
						`## ${server.name} (\`${config.command}${config.args && Array.isArray(config.args) ? ` ${config.args.join(" ")}` : ""}\`)` +
						(tools ? `\n\n### Available Tools\n${tools}` : "") +
						(templates ? `\n\n### Resource Templates\n${templates}` : "") +
						(resources ? `\n\n### Direct Resources\n${resources}` : "")
					)
				})
				.join("\n\n")}`
		: "(No MCP servers currently connected)"
}

====

是的,就是这么简单粗暴,Cline通过提示词将当前MCP Server提供的tools丢给LLM,让LLM自动决策使用哪些tools,并且约定当需要执行MCP tools时,通过 use_mcp_tool 指令返回具体的工具信息,然后调用tools并将结果重新添加到对话上下文:

ts 复制代码
// now execute the tool
		await this.say("mcp_server_request_started") // same as browser_action_result
		const toolResult = await this.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:\n" : "") +
				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") || "(No response)"
		await this.say("mcp_server_response", toolResultPretty)
		pushToolResult(formatResponse.toolResult(toolResultPretty))

CherryStudio

Cherry Studio AI 是一款支持多模型 AI 助手,支持 iOS、macOS 和 Windows 平台。可以快速切换多个先进的 LLM 模型,提升LLM使用效率。

Cherry Studio也支持MCP调用,那么ta是如何做到的呢?

这里需要做一个版本划分,因为通过笔者的分析,其使用 MCP Server的方式可以按版本分成两个阶段,在 v1.2.2及之前使用的是 Function Calling,在 v1.2.2之后则切换为了 Prompts!

版本 <= v1.2.2

依旧是看源码,跟Cline类似,Cherry Studio把对MCP所有的操作方法集中到了 MCPService里。

ts 复制代码
class McpService {
 // ...
 constructor() {
    this.initClient = this.initClient.bind(this)
    this.listTools = this.listTools.bind(this)
    this.callTool = this.callTool.bind(this)
    this.closeClient = this.closeClient.bind(this)
    this.removeServer = this.removeServer.bind(this)
    this.restartServer = this.restartServer.bind(this)
    this.stopServer = this.stopServer.bind(this)
    this.cleanup = this.cleanup.bind(this)
  }
  
  // ...
}

具体的方法实现大家有兴趣可以去github上看源码,本文不再列举(其实跟Cline的实现很类似,也是 建立通信连接-获取tools列表-调用tool 这三步)。

那如何告知LLM有哪些tools可以调用以让其进行决策呢?

答案是:Function Calling!!!

这里省去中间过程,直接看Cherry Studio是如何把 mcpTools 转换为 Function Calling 的:

ts 复制代码
// 适用于Anthropic大模型的mcpTools转换方法
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
  })
}

// 适用于Gemini大模型的mcpTools转换方法
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
}

// 适用于OpenAI大模型的mcpTools转换方法
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)
      }
    }
  }))
}

上面的三个转换方法会把 tools 通过Function Calling让大模型感知到,然后进行决策调用。请注意,上面的大模型不是一个,而是一类。

PS: 国内流程的 DeepSeek,Qwen,Doubao 都属于OpenAI类大模型

所以之前有言论称MCP的出现会替代 Function Calling,完全是胡扯,这两者本来就不是一个维度的东西,根本没有谁替代谁这一说。

版本 > v1.2.2

截止到 2025-4-27Cherry Studio最新版本已经来到 v1.2.9。除了能力上的升级,其对 MCP 的支持兼容度也有较大的提升。其中一个重点改进便是用 Prompts 代替 Function Calling 实现对 MCP tools的调用。

ts 复制代码
  public async completions({
    messages,
    assistant,
    mcpTools,
    onChunk,
    onFilterMessages
  }: CompletionsParams): Promise<void> {
    // ...
    let systemInstruction = assistant.prompt

    if (mcpTools && mcpTools.length > 0) {
      systemInstruction = buildSystemPrompt(assistant.prompt || '', mcpTools)
    }
    // ...
  }
  
  /**
  * 核心的组装 Prompt 方法
  */
  export const buildSystemPrompt = (userSystemPrompt: string, tools: MCPTool[]): string => {
  if (tools && tools.length > 0) {
    return SYSTEM_PROMPT.replace('{{ USER_SYSTEM_PROMPT }}', userSystemPrompt)
      .replace('{{ TOOL_USE_EXAMPLES }}', ToolUseExamples)
      .replace('{{ AVAILABLE_TOOLS }}', AvailableTools(tools))
  }

  return userSystemPrompt
}

export const AvailableTools = (tools: MCPTool[]) => {
  const availableTools = tools
    .map((tool) => {
      return `
<tool>
  <name>${tool.id}</name>
  <description>${tool.description}</description>
  <arguments>
    ${tool.inputSchema ? JSON.stringify(tool.inputSchema) : ''}
  </arguments>
</tool>
`
    })
    .join('\n')
  return `<tools>
${availableTools}
</tools>`
}

之前针对不同类LLM分别转换的问题不存在了,统统收敛到 buildSystemPrompt 方法,将 mcpTools 的调用方法跟规则注入到 Prompts 里传递给 LLM ,让其决策是否调用,以及调用哪些 tools。 以上就是关于ClineCherry Studio如何实现 MCP 的完整分析。

总结

  1. 在MCP协议出来后,尤其是通用AI Agent的兴起,各类AI客户端工具都开始支持 MCP,将其客户端打造成一个 MCP Client实现对更广泛工具的调用能力;
  2. LLM 在MCP协议中充当大脑角色,不会"亲自"去调用mcpTools,这些脏活累活都是在MCP Client完成的;
  3. MCP Client充当MCP ServerLLM之间的翻译者,将MCP Server提供的tools翻译成LLM可以识别的内容,让其决策。MCP Client翻译的方式有两种,一种是"翻译"成Function Calling,第二种是"翻译"成格式化的 Prompts
  4. 从目前来看,"翻译"成Function Calling的方式存在一定局限性(部分大模型不支持Function Calling或对Function Calling的支持度不是很好)。通过Cherry Studio的实现演进可以发现"翻译"成格式化的Prompts似乎是个更好的选择;
  5. MCP的优势在于:开放标准利于服务商开发API,避免开发者重复造轮子,可利用现有MCP服务增强Agent。所以还是那句话------Open Source Forever!
相关推荐
一 乐31 分钟前
民宿|基于java的民宿推荐系统(源码+数据库+文档)
java·前端·数据库·vue.js·论文·源码
testleaf1 小时前
前端面经整理【1】
前端·面试
好了来看下一题1 小时前
使用 React+Vite+Electron 搭建桌面应用
前端·react.js·electron
啃火龙果的兔子1 小时前
前端八股文-react篇
前端·react.js·前端框架
小前端大牛马1 小时前
react中hook和高阶组件的选型
前端·javascript·vue.js
刺客-Andy1 小时前
React第六十二节 Router中 createStaticRouter 的使用详解
前端·javascript·react.js
自由鬼2 小时前
企业架构框架深入解析:TOGAF、Zachman Framework、FEAF与Gartner EA Framework
程序人生·架构
jiedaodezhuti2 小时前
EFK架构的数据安全性
架构
蓝色天空的银码星3 小时前
SpringCloud微服务架构下的日志可观测解决方案(EFK搭建)
spring cloud·微服务·架构
萌萌哒草头将军3 小时前
🚀🚀🚀VSCode 发布 1.101 版本,Copilot 更全能!
前端·vue.js·react.js