最近 MCP 的概念很火,还有 A2A。但是发现很多文章都停留在了概念层面,没有深入去说如何构建一个 MCP 的服务,以及我觉得很多文章对 MCP 的理解其实也是有问题的,所以这篇文章算是站在工程师的角度,如何去构建一个 MCP 的服务。
会大概写三个系列,当前为第一系列 主要介绍 Stdio 的搭建。
在正式开始代码之前,先看一下官方给的架构图:
包含了以下部分:
- HostMCP ProtocolMCP ServerLocal ...
- 其中 host 其实就是 Claude Desktop、IDE 等这样的工具,可以理解成主应用MCP Protocol 是一个协议,用于在不同的应用之间进行通信MCP Server 是一个服务,也是我们开发者最应该关注的地方,host 通过 MCP Protocol 与 MCP Server 进行通信,所以你想让 LLM 完成什么功能,都需要在 MCP Server 中实现Local 则是一系列的数据等,用来提供给 MCP Server 进行使用
在很多文章中还会提及到 MCP Client,其实可以简单理解成就是主应用(Host)中的一部分,在一个问题中可能需要调用多个 MCP Server,所以需要多个 MCP Client。它负责发起 MCP 请求和接收 MCP 的响应。
MCP Server 相关概念
在编写一个正式的 MCP Server 之前,我们需要了解一些概念:
上面是官网文档中列举的相关概念,不过重点关注 Resources 和 Tools。
Tools 这个比较好理解就是工具,定义一系列工具等待被调用。而 Resources 就是资源,可以简单理解成就是给 LLM 的上下文填充使用的,让它更好的理解问题。
MCP Server 搭建
目前官网已经支持了 typescript-sdk 这里直接使用即可。
mkdir mcp-servercd mcp-serverpnpm initpnpm add @modelcontextprotocol/sdk zod zod-to-json-schema
编写的示例就是读取当前的 package.json 内容,然后返回给 LLM,可以让用户提问当前 package.json 的 name 和 version 是多少的时候正确回答。
先创建一个 server 实例
import { Server } from "@modelcontextprotocol/sdk/server/index.js";import packageData from "../package.json";const server = new Server( { name: "get-package", version: packageData.version, }, { capabilities: { tools: {}, resources: {}, }, });
上面的 name 应当保持唯一,下面定义两个工具用于返回 name 和 version。
Tools
const emptyInputSchema = z.object({});const emptyInputJsonSchema = zodToJsonSchema(emptyInputSchema);server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "get-name", description: "获取package.json中的name", inputSchema: emptyInputJsonSchema, }, { name: "get-version", description: "获取package.json中的version", inputSchema: emptyInputJsonSchema, }, ], };});
这里因为我们并不需要 inputSchema 输入,所以直接使用了 zodToJsonSchema 来生成一个空的 schema。但是如果你的工具依赖相关的字段,比如你有一个 add 的工具,计算两个数的相加,可能你就要定义一下 args 的 schema 了。
定义完成之后只是在 server 上声明了工具,但是当 MCP Client 发起请求调用工具的时候,我们还要写相关的逻辑来处理相对应的工具请求。
server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name } = request.params; switch (name) { case "get-name": { return { content: [ { type: "text", text: packageData.name, }, ], }; } case "get-version": { return { content: [ { type: "text", text: packageData.version, }, ], }; } default: throw new Error(`Unknown tool: ${name}`); }});
CallToolRequestSchema 表示调用工具的请求,这里我们根据 name 来返回不同的内容。这样对于工具的编写就完成了,但是最初的时候提到除了工具,还有 Resources 资源,我们还没有定义。
Resources
Resources 用于让 MCP client 读取,然后作为 LLM 上下文的内容。因为 LLM 的训练是有一个截止时间的,对于很多不公开或者最新的信息是没有的,现在比较火的 RAG 其实也是为了让 LLM 可以理解信息。
资源的定义和 Tools 类似,需要先声明资源,然后再定义资源的读取逻辑。
不过这里需要注意,因为不同的主应用(Host)实现不同,所以导致资源的读取逻辑并不相通,截取官方的一段话介绍。
server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: [ { name: "package devDependencies依赖", description: "返回 package.json 中的 devDependencies 依赖", // "返回整个 package.json 文件的内容,包括 name, version, dependencies, devDependencies, scripts 等。", uri: "package://devDependencies", }, ], };});server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; switch (uri) { case "package://devDependencies": return { contents: [ { uri, text: JSON.stringify(packageData, null, 2), }, ], }; default: throw new Error(`Unknown resource: ${uri}`); }});
最后我们使用 Stdio 来完成消息标准输入和输出。
const transport = new StdioServerTransport();server.connect(transport);
验证服务
客户端的话可以使用 DeepChat,安装好之后点击设置,找到 MCP 设置,新建一个自定义的 MCP 服务。
然后填写下面的内容用于配置解析
mcpServers: {"read-package": {"descriptions": "读取package.json文件相关内容并返回","icons": "📁","autoApprove": ["read"],"type": "stdio","command": "node","args": ["/Users/yliu/Desktop/my/mcp-ts/dist/src/index.js"],"env": {},"baseUrl": ""} }
提交之后启动新建的 MCP 服务。
之后新建对话框输入对话就可以看到工具被调用的效果了。
因为目前 DeepChat 并不支持 Resources 的读取,导致我们上面定义的 package 的 info 没有被返回,如果支持 package 的信息读取的话,就可以延伸问一下这个包的协议是什么之类的提问了。
完整代码
点击查看完整代码
#!/usr/bin/env nodeimport { Server } from "@modelcontextprotocol/sdk/server/index.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import { z } from "zod";import { zodToJsonSchema } from "zod-to-json-schema";import packageData from "../package.json";import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema,} from "@modelcontextprotocol/sdk/types.js";const emptyInputSchema = z.object({});const emptyInputJsonSchema = zodToJsonSchema(emptyInputSchema);const server = new Server( { name: "get-package", version: packageData.version, }, { capabilities: { tools: {}, resources: {}, }, });server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "get-name", description: "获取package.json中的name", inputSchema: emptyInputJsonSchema, }, { name: "get-version", description: "获取package.json中的version", inputSchema: emptyInputJsonSchema, }, ], };});server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name } = request.params; switch (name) { case "get-name": { return { content: [ { type: "text", text: packageData.name, }, ], }; } case "get-version": { return { content: [ { type: "text", text: packageData.version, }, ], }; } default: throw new Error(`Unknown tool: ${name}`); }});server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: [ { name: "package devDependencies依赖", description: "返回 package.json 中的 devDependencies 依赖", // "返回整个 package.json 文件的内容,包括 name, version, dependencies, devDependencies, scripts 等。", uri: "package://devDependencies", }, ], };});server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; switch (uri) { case "package://devDependencies": return { contents: [ { uri, text: JSON.stringify(packageData, null, 2), }, ], }; default: throw new Error(`Unknown resource: ${uri}`); }});const transport = new StdioServerTransport();server.connect(transport);
最后
如果文章有什么问题或者建议,欢迎讨论,下面一篇会介绍 SSE 的实现。