以下文章來源于阿里云開發(fā)者,作者無棄
Anthropic開源了一套MCP協(xié)議,它為連接AI系統(tǒng)與數(shù)據(jù)源提供了一個(gè)通用的、開放的標(biāo)準(zhǔn),用單一協(xié)議取代了碎片化的集成方式。本文教你從零打造一個(gè)MCP客戶端。
一、背景
如何讓大語言模型與外部系統(tǒng)交互,一直是AI系統(tǒng)需要解決的問題:
Plugins:OpenAI推出ChatGPT Plugins,首次允許模型通過插件與外部應(yīng)用交互。插件功能包括實(shí)時(shí)信息檢索(如瀏覽器訪問)、代碼解釋器(Code Interpreter)執(zhí)行計(jì)算、第三方服務(wù)調(diào)用(如酒店預(yù)訂、外賣服務(wù)等)
Function Calling:Function Calling技術(shù)逐步成熟,成為大模型與外部系統(tǒng)交互的核心方案。
Agent框架 Tools: 模型作為代理(Agent),動(dòng)態(tài)選擇工具完成任務(wù),比如langchain的Tool。
一個(gè)企業(yè),面對(duì)不同的框架或系統(tǒng),可能都需要參考他們的協(xié)議,去開發(fā)對(duì)應(yīng)Tool,這其實(shí)是一個(gè)非常重復(fù)的工作。
面對(duì)這種問題,Anthropic開源了一套MCP協(xié)議(Model Context Protocol),
https://www.anthropic.com/news/model-context-protocol?
https://modelcontextprotocol.io/introduction?
它為連接AI系統(tǒng)與數(shù)據(jù)源提供了一個(gè)通用的、開放的標(biāo)準(zhǔn),用單一協(xié)議取代了碎片化的集成方式。其結(jié)果是,能以更簡單、更可靠的方式讓人工智能系統(tǒng)獲取所需數(shù)據(jù)。
二、架構(gòu)
MCP Hosts:像 Claude Desktop、Cursor這樣的程序,它們通過MCP訪問數(shù)據(jù)。
MCP Clients:與服務(wù)器保持 1:1 連接的協(xié)議客戶端。
MCP Servers:輕量級(jí)程序,每個(gè)程序都通過標(biāo)準(zhǔn)化的模型上下文協(xié)議公開特定功能。
結(jié)合AI模型,以一個(gè)Java應(yīng)用為例,架構(gòu)是這樣:
可以看到傳輸層有兩類:
StdioTransport
HTTP SSE
三、實(shí)現(xiàn)MCP Server
首先看一個(gè)最簡單的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 server const server = new McpServer({ name: "Demo", version: "1.0.0" }); // Add an addition tool server.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這塊,這個(gè)聲明了一個(gè)做加法的工具。這就是一個(gè)最簡單的可運(yùn)行的Server了。
同時(shí)也可以使用官方的腳手架,來創(chuàng)建一個(gè)完整復(fù)雜的Server:
npx @modelcontextprotocol/create-server my-server
3.1 使用SDK
從上面代碼可以看到很多模塊都是從@modelcontextprotocol/sdk 這個(gè)SDK里導(dǎo)出的。
SDK封裝好了協(xié)議內(nèi)部細(xì)節(jié)(JSON-RPC 2.0),包括架構(gòu)分層,開發(fā)者直接寫一些業(yè)務(wù)代碼就可以了。
https://github.com/modelcontextprotocol/typescript-sdk?
MCP服務(wù)器可以提供三種主要功能類型:
Resources:可以由客戶端讀取的類似文件的數(shù)據(jù)(例如API響應(yīng)或文件內(nèi)容)
Tools:LLM可以調(diào)用的功能(在用戶批準(zhǔn)下)
Prompts:可幫助用戶完成特定任務(wù)的預(yù)先編寫的模板
Resources和Prompts可以讓客戶端喚起,供用戶選擇,比如用戶所有的筆記,或者最近訂單。
重點(diǎn)在Tools,其他很多客戶端都不支持。
3.2 調(diào)試
如果寫好了代碼,怎么調(diào)試這個(gè)Server呢?官方提供了一個(gè)調(diào)試器:
npx @modelcontextprotocol/inspector
1.連接Server
2.獲取工具
3.執(zhí)行調(diào)試
3.3 在客戶端使用
如果運(yùn)行結(jié)果沒錯(cuò),就可以上架到支持MCP協(xié)議的客戶端使用了,比如Claude、Cursor,這里以Cursor為例:
在Cursor Composer中對(duì)話,會(huì)自動(dòng)識(shí)別這個(gè)Tool,并尋求用戶是否調(diào)用
點(diǎn)擊運(yùn)行,就可以調(diào)用執(zhí)行:
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 }) => { // 使用固定匯率進(jìn)行演示,實(shí)際應(yīng)該調(diào)用匯率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}人民幣等于: ${usd}美元 ${hkd}港幣` }] } }, ); const app = express(); const sessions: Record= {} 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);
核心的差別在于需要提供一個(gè)sse服務(wù),對(duì)于Tool基本一樣,但是sse類型就可以部署在服務(wù)端了。上架也和command類型相似:
3.5 一個(gè)復(fù)雜一點(diǎn)的例子
操作瀏覽器執(zhí)行自動(dòng)化流程。
可以操作瀏覽器,Cursor秒變Devin。想象一下,寫完代碼,編輯器自動(dòng)打開瀏覽器預(yù)覽效果,然后截圖給視覺模型,發(fā)現(xiàn)樣式不對(duì),自動(dòng)修改。
如果對(duì)接好內(nèi)部系統(tǒng),貼一個(gè)需求地址,自動(dòng)連接瀏覽器,打開網(wǎng)頁,分析需求,分析視覺稿,然后自己寫代碼,對(duì)比視覺稿,你就喝杯咖啡,靜靜的看著它工作。
3.6 MCP Server資源
有很多寫好的Server,可以直接復(fù)用。
https://github.com/modelcontextprotocol/servers?
https://github.com/punkpeye/awesome-mcp-servers/blob/main/README-zh.md?
四、實(shí)現(xiàn)MCP Client
一般MCP Host以一個(gè)Chat box為入口,對(duì)話形式去調(diào)用。
那我們怎么在自己的應(yīng)用里支持MCP協(xié)議呢?這里需要實(shí)現(xiàn)MCP Client。
4.1 配置文件
使用配置文件來標(biāo)明有哪些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 確認(rèn)交互形態(tài)
MCP Client主要還是基于LLM,識(shí)別到需要調(diào)用外部系統(tǒng),調(diào)用MCP Server提供的Tool,所以還是以對(duì)話為入口,可以方便一點(diǎn),直接在terminal里對(duì)話,使用readline來讀取用戶輸入。大模型可以直接使用openai,Tool的路由直接使用function calling。
4.3 編寫Client
大致的邏輯:
1.讀取配置文件,運(yùn)行所有Server,獲取可用的Tools 2.用戶與LLM對(duì)話(附帶所有Tools名稱描述,參數(shù)定義) 3.LLM識(shí)別到要執(zhí)行某個(gè)Tool,返回名稱和參數(shù) 4.找到對(duì)應(yīng)Server的Tool,調(diào)用執(zhí)行,返回結(jié)果 5.把工具執(zhí)行結(jié)果提交給LLM 6.LLM返回分析結(jié)果給用戶
使用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"; // 初始化環(huán)境變量 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= new Map(); private transports: Map = new Map(); private openai: OpenAI; constructor() { this.openai = new OpenAI({ apiKey: OPENAI_API_KEY }); } async connectToServer(serverName: string): Promise { 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(` Connected to server '${serverName}' with tools:`, response.tools.map((tool: Tool) => tool.name)); } private async createCommandTransport(shell: string): Promise { const [command, ...shellArgs] = shell.split(' '); if (!command) { throw new Error("Invalid shell command"); } // 處理參數(shù)中的波浪號(hào)路徑 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 }; return new StdioClientTransport(serverParams); } private async createSSETransport(url: string): Promise { return new SSEClientTransport(new URL(url)); } async processQuery(query: string): Promise { if (this.sessions.size === 0) { throw new Error("Not connected to any server"); } const messages: ChatCompletionMessageParam[] = [ { role: "user", content: query } ]; // 獲取所有服務(wù)器的工具列表 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); } // 調(diào)用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的響應(yīng) 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); // 執(zhí)行工具調(diào)用 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); // 繼續(xù)與工具結(jié)果的對(duì)話 messages.push({ role: "assistant", content: "", tool_calls: [toolCall] }); messages.push({ role: "tool", tool_call_id: toolCall.id, content: toolResult.content }); // 獲取下一個(gè)響應(yīng) 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(" "); } async chatLoop(): Promise { console.log(" MCP 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 ((resolve) => { readline.question(" Query: ", resolve); }); }; try { while (true) { const query = (await askQuestion()).trim(); if (query.toLowerCase() === 'quit') { break; } try { const response = await this.processQuery(query); console.log(" " + response); } catch (error) { console.error(" Error:", error); } } } finally { readline.close(); } } async cleanup(): Promise { for (const transport of this.transports.values()) { await transport.close(); } this.transports.clear(); this.sessions.clear(); } hasActiveSessions(): boolean { return this.sessions.size > 0; } } // 主函數(shù) async function main() { const openServers = MCPClient.getOpenServers(); console.log("Connecting to servers:", openServers.join(", ")); const client = new MCPClient(); try { // 連接所有開啟的服務(wù)器 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(); } } // 運(yùn)行主函數(shù) main().catch(console.error);?
4.4 運(yùn)行效果
NODE_TLS_REJECT_UNAUTHORIZED=0 node build/client.js
NODE_TLS_REJECT_UNAUTHORIZED=0 可以忽略(不校驗(yàn)證書)
4.5 時(shí)序圖?
五、總結(jié)
總體來說解決了Client和Server數(shù)據(jù)交互的問題,但是沒有解決LLM到Tool的對(duì)接:不同模型實(shí)現(xiàn)function call支持度不一樣,比如DeepSeek R1不支持,那么如何路由到工具就成了問題。
不足:
1.開源時(shí)間不長,目前還不是很完善,語言支持度不夠,示例代碼不多。
2.Server質(zhì)量良莠不齊,缺乏一個(gè)統(tǒng)一的質(zhì)量保障體系和包管理工具,很多Server運(yùn)行不起來,或者經(jīng)常崩。
3.本地的Server還是依賴Node.js或者Python環(huán)境,遠(yuǎn)程Server支持的很少。
如果未來都開始接入MCP協(xié)議,生態(tài)起來了,能力就會(huì)非常豐富了,使用的人多了,就會(huì)有更多的系統(tǒng)愿意來對(duì)接,寫一套代碼就可以真正所有地方運(yùn)行了。
個(gè)人認(rèn)為MCP還是有前途的,未來可期!
-
開源
+關(guān)注
關(guān)注
3文章
3486瀏覽量
43016 -
模型
+關(guān)注
關(guān)注
1文章
3428瀏覽量
49529 -
客戶端
+關(guān)注
關(guān)注
1文章
293瀏覽量
16846 -
MCP
+關(guān)注
關(guān)注
0文章
256瀏覽量
14049
原文標(biāo)題:從零開始教你打造一個(gè)MCP客戶端
文章出處:【微信號(hào):OSC開源社區(qū),微信公眾號(hào):OSC開源社區(qū)】歡迎添加關(guān)注!文章轉(zhuǎn)載請(qǐng)注明出處。
發(fā)布評(píng)論請(qǐng)先 登錄
相關(guān)推薦
一個(gè)服務(wù)器,多個(gè)客戶端,怎么向指定的客戶端發(fā)數(shù)據(jù)
websocket客戶端性能很差是什么原因?
用Delphi開發(fā)OPC客戶端工具的方法研究
基于USB的加密視頻客戶端的設(shè)計(jì)與實(shí)現(xiàn)
CoolpyCould客戶端
iOS端淘寶客戶端應(yīng)用名稱發(fā)生變化 Android客戶端應(yīng)用名稱尚未更改
MQTT中服務(wù)端和客戶端
ROS是如何設(shè)計(jì)的 ROS客戶端庫

評(píng)論