cover_image

从零开始教你打造一个MCP客户端

无弃 阿里云开发者
2025年03月07日 10:00

图片

阿里妹导读


Anthropic开源了一套MCP协议,它为连接AI系统与数据源提供了一个通用的、开放的标准,用单一协议取代了碎片化的集成方式。本文教你从零打造一个MCP客户端。

一、背景

如何让大语言模型与外部系统交互,一直是AI系统需要解决的问题:

  • Plugins:OpenAI推出ChatGPT Plugins,首次允许模型通过插件与外部应用交互。插件功能包括实时信息检索(如浏览器访问)、代码解释器(Code Interpreter)执行计算、第三方服务调用(如酒店预订、外卖服务等)

图片

  • Function Calling:Function Calling技术逐步成熟,成为大模型与外部系统交互的核心方案。

图片

  • Agent框架 Tools: 模型作为代理(Agent),动态选择工具完成任务,比如langchain的Tool。

图片

一个企业,面对不同的框架或系统,可能都需要参考他们的协议,去开发对应Tool,这其实是一个非常重复的工作。

面对这种问题,Anthropic开源了一套MCP协议(Model Context Protocol),

https://www.anthropic.com/news/model-context-protocol

https://modelcontextprotocol.io/introduction

它为连接AI系统与数据源提供了一个通用的、开放的标准,用单一协议取代了碎片化的集成方式。其结果是,能以更简单、更可靠的方式让人工智能系统获取所需数据。

二、架构

图片

  • MCP Hosts:像 Claude Desktop、Cursor这样的程序,它们通过MCP访问数据。

  • MCP Clients:与服务器保持 1:1 连接的协议客户端。

  • MCP Servers:轻量级程序,每个程序都通过标准化的模型上下文协议公开特定功能。

结合AI模型,以一个Java应用为例,架构是这样:

图片

可以看到传输层有两类:

  • StdioTransport
  • HTTP SSE

图片

三、实现MCP Server

首先看一个最简单的MCP Server例子:

import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import { z } from "zod";
// Create an MCP serverconst server = new McpServer({  name: "Demo",  version: "1.0.0"});
// Add an addition toolserver.tool("add",  'Add two numbers',  { a: z.number(), b: z.number() },  async ({ a, b }) => ({    content: [{ type: "text", text: String(a + b) }]  }));
async function main() {  // Start receiving messages on stdin and sending messages on stdout  const transport = new StdioServerTransport();  await server.connect(transport);}
main()

代码头部和底部都是一些样板代码,主要变化的是在tool这块,这个声明了一个做加法的工具。这就是一个最简单的可运行的Server了。

同时也可以使用官方的脚手架,来创建一个完整复杂的Server:

npx @modelcontextprotocol/create-server my-server


3.1 使用SDK

从上面代码可以看到很多模块都是从@modelcontextprotocol/sdk 这个SDK里导出的。

图片

SDK封装好了协议内部细节(JSON-RPC 2.0),包括架构分层,开发者直接写一些业务代码就可以了。

https://github.com/modelcontextprotocol/typescript-sdk

MCP服务器可以提供三种主要功能类型:

  • Resources:可以由客户端读取的类似文件的数据(例如API响应或文件内容)

  • Tools:LLM可以调用的功能(在用户批准下)

  • Prompts:可帮助用户完成特定任务的预先编写的模板

ResourcesPrompts可以让客户端唤起,供用户选择,比如用户所有的笔记,或者最近订单。

图片

重点在Tools,其他很多客户端都不支持。

图片


3.2 调试

如果写好了代码,怎么调试这个Server呢?官方提供了一个调试器:

npx @modelcontextprotocol/inspector

1.连接Server

图片

2.获取工具

图片

3.执行调试

图片


3.3 在客户端使用

如果运行结果没错,就可以上架到支持MCP协议的客户端使用了,比如Claude、Cursor,这里以Cursor为例:

图片

在Cursor Composer中对话,会自动识别这个Tool,并寻求用户是否调用

图片

点击运行,就可以调用执行:

图片


3.4 HTTP SSE类型Server

import express from "express";import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";import { z } from "zod";
const server = new McpServer({  name: "demo-sse",  version: "1.0.0"});
server.tool("exchange",  '人民币汇率换算',  { rmb: z.number() },  async ({ rmb }) => {    // 使用固定汇率进行演示,实际应该调用汇率API    const usdRate = 0.14; // 1人民币约等于0.14美元    const hkdRate = 1.09; // 1人民币约等于1.09港币        const usd = (rmb * usdRate).toFixed(2);    const hkd = (rmb * hkdRate).toFixed(2);        return {      content: [{        type: "text",        text: `${rmb}人民币等于:\n${usd}美元\n${hkd}港币`      }]    }  },);const app = express();const sessions: Record<string, { transport: SSEServerTransport; response: express.Response }> = {}app.get("/sse", async (req, res) => {  console.log(`New SSE connection from ${req.ip}`);  const sseTransport = new SSEServerTransport("/messages", res);  const sessionId = sseTransport.sessionId;  if (sessionId) {    sessions[sessionId] = { transport: sseTransport, response: res }  }  await server.connect(sseTransport);});
app.post("/messages", async (req, res) => {  const sessionId = req.query.sessionId as string;  const session = sessions[sessionId];  if (!session) {    res.status(404).send("Session not found");    return;  }
 await session.transport.handlePostMessage(req, res);});
app.listen(3001);

核心的差别在于需要提供一个sse服务,对于Tool基本一样,但是sse类型就可以部署在服务端了。上架也和command类型相似:

图片

图片


3.5 一个复杂一点的例子

操作浏览器执行自动化流程。

视频加载失败,请刷新页面再试

刷新

可以操作浏览器,Cursor秒变Devin。想象一下,写完代码,编辑器自动打开浏览器预览效果,然后截图给视觉模型,发现样式不对,自动修改。

如果对接好内部系统,贴一个需求地址,自动连接浏览器,打开网页,分析需求,分析视觉稿,然后自己写代码,对比视觉稿,你就喝杯咖啡,静静的看着它工作。


3.6 MCP Server资源

有很多写好的Server,可以直接复用。

  • https://github.com/modelcontextprotocol/servers

  • https://github.com/punkpeye/awesome-mcp-servers/blob/main/README-zh.md

四、实现MCP Client

一般MCP Host以一个Chat box为入口,对话形式去调用。

图片

那我们怎么在自己的应用里支持MCP协议呢?这里需要实现MCP Client。


4.1 配置文件

使用配置文件来标明有哪些MCP Server,以及类型。

const config = [  {    name: 'demo-stdio',    type: 'command',    command: 'node ~/code-open/cursor-toolkits/mcp/build/demo-stdio.js',    isOpen: true  },  {    name: 'weather-stdio',    type: 'command',    command: 'node ~/code-open/cursor-toolkits/mcp/build/weather-stdio.js',    isOpen: true  },  {    name: 'demo-sse',    type: 'sse',    url: 'http://localhost:3001/sse',    isOpen: false  }];export default config;


4.2 确认交互形态

MCP Client主要还是基于LLM,识别到需要调用外部系统,调用MCP Server提供的Tool,所以还是以对话为入口,可以方便一点,直接在terminal里对话,使用readline来读取用户输入。大模型可以直接使用openai,Tool的路由直接使用function calling。


4.3 编写Client

大致的逻辑:

1.读取配置文件,运行所有Server,获取可用的Tools
2.用户与LLM对话(附带所有Tools名称描述,参数定义)
3.LLM识别到要执行某个Tool,返回名称和参数
4.找到对应Server的Tool,调用执行,返回结果
5.把工具执行结果提交给LLM
6.LLM返回分析结果给用户

使用SDK编写Client代码

import { Client } from "@modelcontextprotocol/sdk/client/index.js";import { StdioClientTransport, StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js";import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";import OpenAI from "openai";import { Tool } from "@modelcontextprotocol/sdk/types.js";import { ChatCompletionMessageParam } from "openai/resources/chat/completions.js";import { createInterface } from "readline";import { homedir } from 'os';import config from "./mcp-server-config.js";// 初始化环境变量const OPENAI_API_KEY = process.env.OPENAI_API_KEY;if (!OPENAI_API_KEY) {    throw new Error("OPENAI_API_KEY environment variable is required");}interface MCPToolResult {    content: string;}interface ServerConfig {    name: string;    type: 'command' | 'sse';    command?: string;    url?: string;    isOpen?: boolean;}class MCPClient {    static getOpenServers(): string[] {        return config.filter(cfg => cfg.isOpen).map(cfg => cfg.name);    }    private sessions: Map<string, Client> = new Map();    private transports: Map<string, StdioClientTransport | SSEClientTransport> = new Map();    private openai: OpenAI;    constructor() {        this.openai = new OpenAI({            apiKey: OPENAI_API_KEY        });    }    async connectToServer(serverName: string): Promise<void> {        const serverConfig = config.find(cfg => cfg.name === serverName) as ServerConfig;        if (!serverConfig) {            throw new Error(`Server configuration not found for: ${serverName}`);        }        let transport: StdioClientTransport | SSEClientTransport;        if (serverConfig.type === 'command' && serverConfig.command) {            transport = await this.createCommandTransport(serverConfig.command);        } else if (serverConfig.type === 'sse' && serverConfig.url) {            transport = await this.createSSETransport(serverConfig.url);        } else {            throw new Error(`Invalid server configuration for: ${serverName}`);        }        const client = new Client(            {                name: "mcp-client",                version: "1.0.0"            },            {                capabilities: {                    prompts: {},                    resources: {},                    tools: {}                }            }        );        await client.connect(transport);                this.sessions.set(serverName, client);        this.transports.set(serverName, transport);        // 列出可用工具        const response = await client.listTools();        console.log(`\nConnected to server '${serverName}' with tools:`, response.tools.map((tool: Tool) => tool.name));    }    private async createCommandTransport(shell: string): Promise<StdioClientTransport> {        const [command, ...shellArgs] = shell.split(' ');        if (!command) {            throw new Error("Invalid shell command");        }        // 处理参数中的波浪号路径        const args = shellArgs.map(arg => {            if (arg.startsWith('~/')) {                return arg.replace('~', homedir());            }            return arg;        });                const serverParams: StdioServerParameters = {            command,            args,            env: Object.fromEntries(                Object.entries(process.env).filter(([_, v]) => v !== undefined)            ) as Record<string, string>        };        return new StdioClientTransport(serverParams);    }    private async createSSETransport(url: string): Promise<SSEClientTransport> {        return new SSEClientTransport(new URL(url));    }    async processQuery(query: string): Promise<string> {        if (this.sessions.size === 0) {            throw new Error("Not connected to any server");        }        const messages: ChatCompletionMessageParam[] = [            {                role: "user",                content: query            }        ];        // 获取所有服务器的工具列表        const availableTools: any[] = [];        for (const [serverName, session] of this.sessions) {            const response = await session.listTools();            const tools = response.tools.map((tool: Tool) => ({                type: "function" as const,                function: {                    name: `${serverName}__${tool.name}`,                    description: `[${serverName}] ${tool.description}`,                    parameters: tool.inputSchema                }            }));            availableTools.push(...tools);        }        // 调用OpenAI API        const completion = await this.openai.chat.completions.create({            model: "gpt-4-turbo-preview",            messages,            tools: availableTools,            tool_choice: "auto"        });        const finalText: string[] = [];                // 处理OpenAI的响应        for (const choice of completion.choices) {            const message = choice.message;                        if (message.content) {                finalText.push(message.content);            }            if (message.tool_calls) {                for (const toolCall of message.tool_calls) {                    const [serverName, toolName] = toolCall.function.name.split('__');                    const session = this.sessions.get(serverName);                                        if (!session) {                        finalText.push(`[Error: Server ${serverName} not found]`);                        continue;                    }                    const toolArgs = JSON.parse(toolCall.function.arguments);                    // 执行工具调用                    const result = await session.callTool({                        name: toolName,                        arguments: toolArgs                    });                    const toolResult = result as unknown as MCPToolResult;                    finalText.push(`[Calling tool ${toolName} on server ${serverName} with args ${JSON.stringify(toolArgs)}]`);                    console.log(toolResult.content);                    finalText.push(toolResult.content);                    // 继续与工具结果的对话                    messages.push({                        role: "assistant",                        content: "",                        tool_calls: [toolCall]                    });                    messages.push({                        role: "tool",                        tool_call_id: toolCall.id,                        content: toolResult.content                    });                    // 获取下一个响应                    const nextCompletion = await this.openai.chat.completions.create({                        model: "gpt-4-turbo-preview",                        messages,                        tools: availableTools,                        tool_choice: "auto"                    });                    if (nextCompletion.choices[0].message.content) {                        finalText.push(nextCompletion.choices[0].message.content);                    }                }            }        }        return finalText.join("\n");    }    async chatLoop(): Promise<void> {        console.log("\nMCP Client Started!");        console.log("Type your queries or 'quit' to exit.");        const readline = createInterface({            input: process.stdin,            output: process.stdout        });        const askQuestion = () => {            return new Promise<string>((resolve) => {                readline.question("\nQuery: ", resolve);            });        };        try {            while (true) {                const query = (await askQuestion()).trim();                if (query.toLowerCase() === 'quit') {                    break;                }                try {                    const response = await this.processQuery(query);                    console.log("\n" + response);                } catch (error) {                    console.error("\nError:", error);                }            }        } finally {            readline.close();        }    }    async cleanup(): Promise<void> {        for (const transport of this.transports.values()) {            await transport.close();        }        this.transports.clear();        this.sessions.clear();    }    hasActiveSessions(): boolean {        return this.sessions.size > 0;    }}// 主函数async function main() {    const openServers = MCPClient.getOpenServers();    console.log("Connecting to servers:", openServers.join(", "));    const client = new MCPClient();        try {        // 连接所有开启的服务器        for (const serverName of openServers) {            try {                await client.connectToServer(serverName);            } catch (error) {                console.error(`Failed to connect to server '${serverName}':`, error);            }        }        if (!client.hasActiveSessions()) {            throw new Error("Failed to connect to any server");        }        await client.chatLoop();    } finally {        await client.cleanup();    }}// 运行主函数main().catch(console.error);


4.4 运行效果

NODE_TLS_REJECT_UNAUTHORIZED=0 node build/client.js

NODE_TLS_REJECT_UNAUTHORIZED=0 可以忽略(不校验证书)

视频加载失败,请刷新页面再试

刷新


4.5 时序图

图片





五、总结

总体来说解决了Client和Server数据交互的问题,但是没有解决LLM到Tool的对接:不同模型实现function call支持度不一样,比如DeepSeek R1不支持,那么如何路由到工具就成了问题。

不足:

1.开源时间不长,目前还不是很完善,语言支持度不够,示例代码不多。

2.Server质量良莠不齐,缺乏一个统一的质量保障体系和包管理工具,很多Server运行不起来,或者经常崩。

3.本地的Server还是依赖Node.js或者Python环境,远程Server支持的很少。

如果未来都开始接入MCP协议,生态起来了,能力就会非常丰富了,使用的人多了,就会有更多的系统愿意来对接,写一套代码就可以真正所有地方运行了。

个人认为MCP还是有前途的,未来可期!


继续滑动看下一个
阿里云开发者
向上滑动看下一个