Skip to content

4.2 案例 1:开发 AI 对话客户端

在本节内容,我们计划开发一个名为 ChatMCP 的 AI 对话客户端。通过这个案例来演示对话类 MCP 客户端开发的具体流程。

4.2.1 开发目标

按照“测试用例驱动开发”的思路,我们先来设计一个测试用例,梳理用户使用 ChatMCP 与大模型对话的流程。

  1. 用户打开 ChatMCP 的配置文件,写入 MCP 服务器配置

  2. 用户启动 ChatMCP,ChatMCP 从配置文件中读取 MCP 服务器列表,并获取所有 MCP 服务器提供的所有工具

  3. 用户在对话框输入提问,ChatMCP 带上支持的工具列表,请求大模型调度

  4. 大模型挑选工具,返回工具名称、调用参数和工具所属的服务器名称

  5. ChatMCP 调用工具,获得结果

  6. ChatMCP 带上工具列表、调用工具的结果,继续请求大模型调度

  7. ChatMCP 循环步骤 4、5、6,直到大模型回复内容不包含工具调用信息,或者已达程序设置的最大循环次数时退出循环

  8. ChatMCP 输出最终回复给用户

用 ChatMCP 网页版演示这个流程,如图 4-2 所示。

图 4-2 ChatMCP 网页版用户对话演示

参考 ChatMCP 网页版的对话流程,我们在接下来的案例中实现 ChatMCP 客户端的逻辑。

4.2.2 前置准备

在实现 ChatMCP 的交互逻辑之前,我们做一些前置准备,安装需要用到的外部依赖,实现通用的功能函数。

  1. 安装 SDK

我们选择用 Typescript 来开发 ChatMCP,需要先安装 Typescript 版本的 MCP 客户端 SDK:

Terminal window
npm install @modelcontextprotocol/sdk
  1. 实现获取工具列表的函数

我们来实现一个 listTools 函数,使用 SDK 创建 MCP 客户端实例,获取 MCP 服务器内部定义的工具列表。

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
async function listTools({
command,
args,
env = {},
}: {
command: string;
args: string[];
env?: Record<string, string>;
}) {
const transport = new StdioClientTransport({
command,
args,
env: {
...(process.env as Record<string, string>),
...env,
},
});
const client = new Client({
name: "chatmcp",
version: "1.0.0",
});
await client.connect(transport);
const tools = await client.listTools();
return tools;
}

使用第 3 章开发的天气查询 MCP 服务器配置,测试 listTools 函数,获得此服务器内部定义的工具列表,如图 4-3 所示:

图 4-3 测试获取 MCP 服务器工具列表的函数

  1. 实现调用工具的函数

我们来实现一个 callTool 函数,使用 SDK 创建 MCP 客户端实例,调用 MCP 服务器内部实现的工具。

async function callTool({
command,
args,
env = {},
name,
params,
}: {
command: string;
args: string[];
env?: Record<string, string>;
name: string;
params?: Record<string, unknown>;
}) {
const transport = new StdioClientTransport({
command,
args,
env: {
...(process.env as Record<string, string>),
...env,
},
});
const client = new Client({
name: "chatmcp",
version: "1.0.0",
});
await client.connect(transport);
const result = await client.callTool({
name,
arguments: params,
});
return result;
}

使用上一步的天气查询 MCP 服务器配置、工具名和请求参数,测试 callTool 函数,获得目标工具调用结果,如图 4-4 所示:

图 4-4 测试调用 MCP 服务器工具的函数

在准备好这两个功能函数之后,接下来就可以实现 ChatMCP 的交互逻辑了。

4.2.3 读取用户配置的 MCP 服务器列表

ChatMCP 需要设置一个本地文件来保存用户配置的 MCP 服务器列表。在 ChatMCP 启动时,从此文件中读取 MCP 服务器列表。

为了方便实现,我们把 Cursor 的 MCP 配置文件作为 ChatMCP 的配置文件。

Mac 系统下的配置文件路径是:/Users/$USER/.cursor/mcp.json

用户在此文件内配置要使用的 MCP 服务器列表,比如把我们在第 3 章开发的两个 MCP 服务器配置进来,配置内容如下:

{
"mcpServers": {
"weather-mcp": {
"command": "npx",
"args": ["-y", "@chatmcp/weather-mcp"],
"env": {
"WEATHER_API_KEY": "xxx"
}
},
"flomo-mcp": {
"command": "npx",
"args": ["-y", "@chatmcp/flomo-mcp"],
"env": {
"FLOMO_API_URL": "https://flomoapp.com/iwh/xxx/xxxxxx/"
}
}
}
}

其中,WEATHER_API_KEY 和 FLOMO_API_URL 需要根据实际情况填写,此处脱敏处理,用占位符替代。

在开发 ChatMCP 时,按以下步骤实现读取用户配置的 MCP 服务器列表的逻辑。

  1. 定义 MCP 服务器数据类型
interface McpServer {
name: string;
command: string;
args?: string[];
env?: Record<string, string>;
}
  1. 实现一个函数,从给定的配置内容中解析 MCP 服务器列表
async function getMcpServers(
config: string
): Promise<Record<string, McpServer>> {
const mcpConfig = JSON.parse(config);
const mcpServers = Object.entries(mcpConfig.mcpServers).reduce(
(acc, [key, value]) => {
acc[key] = {
name: key,
command: (value as any).command,
args: (value as any).args,
env: (value as any).env,
};
return acc;
},
{} as Record<string, McpServer>
);
return mcpServers;
}

此函数返回一个 key-value 对象,key 是 MCP 服务器的名称,value 是步骤 1 定义的 MCP 服务器信息。

  1. 读取配置文件,从配置内容中获得 MCP 服务器列表
const configFile = `/Users/idoubi/.cursor/mcp.json`;
const config = await fs.readFile(configFile, "utf-8");
const mcpServers = await getMcpServers(config);

此处的 configFile 根据实际情况填写用户本地的 ChatMCP 配置文件地址。

  1. 调试接口,输出配置的 MCP 服务器列表,如图 4-5 所示。

4.2.4 从 MCP 服务器获取工具列表

ChatMCP 启动时,从配置文件中读取了用户配置的 MCP 服务器列表,需要通过 SDK 与每个 MCP 服务器建立连接,发送请求获取每个服务器内部定义的工具列表。

在开发 ChatMCP 时,按以下步骤实现获取 MCP 服务器工具列表的逻辑。

  1. 定义工具数据类型
interface McpTool {
server_name: string;
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
  1. 实现一个函数,从 MCP 服务器列表中获取工具列表
async function getMcpTools(
mcpServers: Record<string, McpServer>
): Promise<McpTool[]> {
const allTools = await Promise.all(
Object.entries(mcpServers).map(async ([name, server]) => {
const tools = await listTools({
command: server.command,
args: server.args || [],
env: server.env || {},
});
return tools.tools.map((tool) => ({
server_name: name,
name: tool.name,
description: tool.description || "",
inputSchema: tool.inputSchema,
}));
})
);
return allTools.flat();
}

此函数遍历 MCP 服务器列表,并行调用前面步骤实现的 listTools 函数,获取每个 MCP 服务器内部定义的工具列表,最后合并返回所有工具列表。

  1. 读取配置文件,解析 MCP 服务器列表,获取所有工具
const configFile = `/Users/idoubi/.cursor/mcp.json`;
const config = await fs.readFile(configFile, "utf-8");
const mcpServers = await getMcpServers(config);
const mcpTools = await getMcpTools(mcpServers);

此处的 configFile 根据实际情况填写用户本地的 ChatMCP 配置文件地址。

  1. 调试接口,输出获取到的所有工具列表,如图 4-6 所示。

4.2.5 请求大模型挑选工具

ChatMCP 启动时,获取了用户配置的 MCP 服务器列表,并通过与每个 MCP 服务器连接,获取到了所有可用的工具。

用户在 ChatMCP 的对话框输入问题,ChatMCP 根据用户勾选启用的 MCP 服务器名称过滤工具列表,跟用户的提问一起发送给大模型挑选工具。

在开发 ChatMCP 时,按以下步骤实现请求大模型挑选工具的逻辑。

  1. 设置系统提示词

为了让大模型更好地理解用户意图,在请求大模型挑选工具时,需要设置系统提示词。

提示词示例:

你是 ChatMCP,由 ThinkAny AI 开发的 AI 对话助手。
# 通用指令
请结合可用的上下文信息(CONTEXT_MESSAGES)和上一次工具调用结果(PREVIOUS_TOOL_RESULTS),针对用户的查询(USER_QUERY),写出准确、详细且全面的回复。
- 回答应精确、高质量、专业且公正。
- 回答必须与提问语言一致。
- 禁止使用道德化或模棱两可的语言,避免如下表达:
- “重要的是……”
- “不适当的是……”
- “这是主观的……”
# 格式要求
- 使用 markdown 格式化段落、列表、表格和引用。
- 用二级、三级标题分隔内容,如 “## 标题”,但**不要**以标题开头。
- 列表项之间用单个换行,段落之间用双换行。
- 如有图片,使用 markdown 渲染。
- 不要写 URL 或链接。
# 工具调用
如需调用工具,请遵循以下流程:
1. 判断是否需要工具,依据当前用户查询、已有工具结果和可用工具列表(AVAILABLE_TOOLS)。
2. 返回的 tool_name 仅能使用可用工具列表中明示的工具,tool_params 中的参数必须完全匹配 inputSchema。
3. 返回的 server_name 必须跟 tool_name 完全匹配。
4. 工具调用格式如下,且必须放在回复最后一行:
<<tool-start>>
{
"server_name": "提供工具的服务器名称",
"tool_name": "工具名称",
"tool_params": {
"参数1": "参数1的值",
"参数2": "参数2的值"
}
}
<<tool-end>>
- 一次只能调用一个工具,不能提前给出答案。
- 工具调用后等待结果,再继续回复。
- 工具调用失败两次后,不再重试,直接基于已有信息作答并说明原因。
如无需工具,直接完整作答,不要包含工具调用区块。
----
以下为本次请求的变量信息:
用户查询:USER_QUERY={USER_QUERY}
上下文:CONTEXT_MESSAGES={CONTEXT_MESSAGES}
上一次工具调用结果:PREVIOUS_TOOL_RESULTS={PREVIOUS_TOOL_RESULTS}
可用工具列表:AVAILABLE_TOOLS={AVAILABLE_TOOLS}
请用你的推理能力,分析信息,生成最有帮助的回复。

此提示词的核心逻辑是:把可用的工具列表通过参数:AVAILABLE_TOOLS 传递给大模型,让大模型从中选择应该调用的工具来补充上下文。把历史消息和上一次工具调用结果通过 CONTEXT_MESSAGES、PREVIOUS_TOOL_RESULTS 传递给大模型,让大模型在多轮对话或连续调用多个工具时,有足够的信息作为参考。

如果判断需要调用工具来补充上下文,大模型会返回应该调用的工具信息,此提示词约定用特殊标签 <<tool-start>><<tool-end>> 包裹需要调用的工具信息。

  1. 实现与大模型对话的函数

在开发 ChatMCP 时,通过 aisdk 库实现与大模型对话的逻辑,并使用 OpenRouter 作为大模型接口供应商。安装相关依赖的命令如下:

Terminal window
npm install ai
npm install @openrouter/ai-sdk-provider

然后我们来实现一个 chatWithLLM 函数,通过此函数请求大模型,并获得大模型的响应内容。chatWithLLM 函数的实现逻辑如下:

import { openrouter } from "@openrouter/ai-sdk-provider";
import { streamText } from "ai";
async function chatWithLLM({
query,
contextMessages,
tools,
toolResults,
}: {
query: string;
contextMessages?: string;
tools?: string;
toolResults?: string;
}) {
const prompt = mcpPrompt
.replace("{USER_QUERY}", query)
.replace("{CONTEXT_MESSAGES}", contextMessages || "")
.replace("{AVAILABLE_TOOLS}", tools || "")
.replace("{PREVIOUS_TOOL_RESULTS}", toolResults || "");
const result = await streamText({
model: openrouter("anthropic/claude-3.5-sonnet"),
prompt,
});
return result;
}

在此函数中,mcpPrompt 是前面步骤定义的系统提示词,query 是用户输入的问题,contextMessages 是历史消息,tools 是可用的工具列表,toolResults 是上一次工具调用结果,调用 aisdk 库的 streamText 函数,获取大模型的流式响应内容。

  1. 调试输出结果

把 ChatMCP 启动时获取到的工具列表作为 tools 参数,设置用户 query,请求 chatWithLLM 函数,调试大模型的输出结果,如图所示:

可以看到,大模型返回了应该调用的工具信息,包裹在指定标签中。

4.2.6 解析大模型响应的工具信息

在上一步骤请求大模型挑选工具,大模型响应了应该调用的工具信息,包裹在特殊标签中。接下来的步骤,需要从大模型的响应内容中,解析出需要调用的工具信息。

  1. 定义混合内容数据类型

大模型响应的内容,是在文本中包裹了特殊标签,因此我们可以定义一个混合内容数据类型,把文本内容和特殊标签包裹的工具信息区分开来。

interface MixContent {
type: "text" | "tool";
text?: string;
tool?: {
server_name: string;
tool_name: string;
tool_params?: Record<string, unknown>;
};
}
  1. 实现一个函数,解析混合内容
function parseMixContents(input: string): MixContent[] {
const result: MixContent[] = [];
const regex = /<<tool-start>>\s*([\s\S]*?)\s*<<tool-end>>/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(input)) !== null) {
// 前面的文本
if (match.index > lastIndex) {
const text = input.slice(lastIndex, match.index).trim();
if (text) {
result.push({ type: "text", text });
}
}
// tool 块
const toolJson = match[1].trim();
try {
const tool = JSON.parse(toolJson);
result.push({ type: "tool", tool });
} catch (e) {
// 解析失败可忽略或抛出
}
lastIndex = regex.lastIndex;
}
// 剩余文本
if (lastIndex < input.length) {
const text = input.slice(lastIndex).trim();
if (text) {
result.push({ type: "text", text });
}
}
return result;
}

此函数通过正则表达式,从文本内容提取特殊标签包裹的工具信息,并返回一个混合内容数组。

  1. 调试解析工具信息

把之前步骤大模型返回的包含工具调用信息的内容作为输入,调用 parseMixContents 函数,得到混合内容数组。通过调试接口,输出结果如图 4-7 所示。

图 4-7 调试解析工具信息

可以看到,混合内容数组中,文本内容和工具信息已经区分开,在后续的步骤中,可以分别处理文本内容和工具信息。

4.2.7 调用工具

在上一步骤,ChatMCP 解析出来大模型返回的混合内容,包含了应该调用的工具信息,如下所示:

{
"type": "tool",
"tool": {
"server name": "weather-mcp",
"tool name": "query-weather",
"tool_params": {
"city": "广州"
}
}
}

我们先使用解析出来的工具信息,作为 callTool 函数的固定参数,跟前面读取 MCP 服务器列表的逻辑结合起来,实现调用工具的逻辑,如下:

const configFile = `/Users/idoubi/.cursor/mcp.json`;
const config = await fs.readFile(configFile, "utf-8");
const mcpServers = await getMcpServers(config);
const mcpTools = await getMcpTools(mcpServers);
const mixContents = parseMixContents(pickToolResults);
const callToolResult = await callTool({
command: mcpServers["weather-mcp"].command,
args: mcpServers["weather-mcp"].args || [],
env: mcpServers["weather-mcp"].env || {},
name: "query-weather",
params: {
city: "广州",
},
});

然后把上一次模型响应的内容作为上下文,带上工具调用结果,再次请求大模型回答用户最初的问题。实现逻辑如下:

const result = await chatWithLLM({
query: "广州在下雨吗?",
contextMessages: JSON.stringify(mixContents),
tools: JSON.stringify(mcpTools),
toolResults: JSON.stringify(callToolResult),
});

调试接口,输出结果如图 4-8 所示。

图 4-8 调试大模型回复结果

可以看到,大模型有了工具调用结果作为上下文,回复内容包含了实时信息。

至此,ChatMCP 的核心逻辑算是基本开发完成。可以根据用户的提问和配置的 MCP 服务器,请求大模型调度工具,由客户端执行工具调用,实现为大模型补充上下文的目的,最终让大模型的回复内容更加准确和实时。

4.2.8 优化交互逻辑

在前面的内容,我们拆解了几个步骤,实现了 ChatMCP 服务端读取 MCP 服务器工具列表,与大模型交互的基本流程。在此基础上,我们继续优化交互逻辑,把服务端的接口写完整。

一、实现服务端接口,处理用户请求

我们在 ChatMCP 实现一个 POST 接口,包含以下几部分逻辑:

  1. 接收用户输入的 query 参数,获取用户配置的 MCP 服务器提供的工具列表,请求大模型挑选一个工具
  2. 解析工具信息,调用工具,拿到工具调用结果,再次请求大模型回答用户问题
  3. 输出接口响应内容

此接口的实现逻辑如下:

export async function POST(req: Request) {
let { query } = await req.json();
let contextMessages: MixContent[] = [];
// load mcp servers and tools
const configFile = `/Users/idoubi/.cursor/mcp.json`;
const config = await fs.readFile(configFile, "utf-8");
const mcpServers = await getMcpServers(config);
const mcpTools = await getMcpTools(mcpServers);
// pick tool
const pickToolResult = await chatWithLLM({
query,
contextMessages: JSON.stringify(contextMessages),
tools: JSON.stringify(mcpTools),
toolResults: "",
});
let content = "";
for await (const chunk of pickToolResult.textStream) {
content += chunk;
}
const mixContents = parseMixContents(content);
let callToolParams = null;
for (const mixContent of mixContents) {
if (mixContent.type === "tool") {
const tool = mixContent.tool;
if (
tool &&
tool.tool_name &&
tool.server_name &&
mcpServers[tool.server_name] &&
mcpServers[tool.server_name].command
) {
callToolParams = {
command: mcpServers[tool.server_name].command,
args: mcpServers[tool.server_name].args || [],
env: mcpServers[tool.server_name].env || {},
name: tool.tool_name,
params: tool.tool_params,
};
break;
}
}
}
if (callToolParams) {
const callToolResult = await callTool(callToolParams);
const result = await chatWithLLM({
query,
contextMessages: JSON.stringify(mixContents),
tools: JSON.stringify(mcpTools),
toolResults: JSON.stringify(callToolResult),
});
return result.toTextStreamResponse();
}
return pickToolResult.toTextStreamResponse();
}

模拟用户输入,调试此接口,输出结果如图 4-9 所示。

图 4-9 调试接口响应内容

可以看到,接口响应符合预期。ChatMCP 服务端通过调用天气查询 MCP 服务器内部的工具,为大模型补充了实时天气信息,给用户响应了正确的内容。

二、循环调用多个工具,实现自动工作流

上面实现的接口逻辑,最多只能调用一次工具,在把第一次工具调用结果传给大模型之后,就算大模型继续返回工具调用信息,也不会再调用工具,而是直接回复用户了。

然而,实际情况中,用户的提问有时候会很复杂,需要调用多个工具才能补充足够的信息。因此我们可以继续优化接口,实现循环调用工具的逻辑,让大模型根据传递的工具列表和用户查询问题,自动编排工作流。

设置最大循环次数为 10 次,当大模型响应内容不包含工具调用信息或者循环次数已达到最大次数时,退出循环。

优化后的接口逻辑如下:

export async function POST(req: Request) {
let { query } = await req.json();
// load mcp servers and tools
const configFile = `/Users/idoubi/.cursor/mcp.json`;
const config = await fs.readFile(configFile, "utf-8");
const mcpServers = await getMcpServers(config);
const mcpTools = await getMcpTools(mcpServers);
let contextMessages: MixContent[] = [];
let toolResults = "";
let reply = "";
// loop for max 10 times
for (let i = 0; i < 10; i++) {
// pick tool
const pickToolResult = await chatWithLLM({
query,
contextMessages: JSON.stringify(contextMessages),
tools: JSON.stringify(mcpTools),
toolResults: toolResults,
});
// parse content
let content = "";
for await (const chunk of pickToolResult.textStream) {
content += chunk;
}
const mixContents = parseMixContents(content);
contextMessages.push(...mixContents);
reply += content;
// parse tool
let callToolParams = null;
for (const mixContent of mixContents) {
if (mixContent.type === "tool") {
const tool = mixContent.tool;
if (
tool &&
tool.tool_name &&
tool.server_name &&
mcpServers[tool.server_name] &&
mcpServers[tool.server_name].command
) {
callToolParams = {
command: mcpServers[tool.server_name].command,
args: mcpServers[tool.server_name].args || [],
env: mcpServers[tool.server_name].env || {},
name: tool.tool_name,
params: tool.tool_params,
};
break;
}
}
}
// need to call tool
if (callToolParams) {
const callToolResult = await callTool(callToolParams);
toolResults = JSON.stringify(callToolResult);
reply += `\n\n${toolResults}\n\n`;
continue;
}
// no need to call tool, end loop
break;
}
return new Response(reply);
}

我们使用一个新的 query 调试接口,输出结果如图 4-10 所示。

可以看到,当用户的查询意图涉及到多个工具调用时,大模型会自动编排工作流,依次返回多个工具的调用信息。每次由客户端执行工具调用,把工具调用结果作为上下文补充给大模型,大模型再返回下一个工具调用信息。

4.2.9 小结

本节内容通过一个 AI 对话客户端的例子,讲解了开发 MCP 客户端的基本流程和核心逻辑。

通过这个例子,我们演示了如何读取用户配置的 MCP 服务器列表,如何获得 MCP 服务器提供的工具,如何设置提示词让大模型挑选合适的工具,如何调用工具为大模型补充上下文等知识。

在实际工程实践中,要开发一个面向用户的 MCP 客户端,还涉及到前端 UI 交互、流式数据解析、工具调用错误处理、失败重试、调度流程优化等内容,鉴于篇幅有限,不在此处展开。

理解了本节内容,就可以开始动手开发自己的 MCP 客户端了。