从零搭建MCP服务:基于Stdio的实践指南

最近 MCP 的概念很火,还有 A2A。但是发现很多文章都停留在了概念层面,没有深入去说如何构建一个 MCP 的服务,以及我觉得很多文章对 MCP 的理解其实也是有问题的,所以这篇文章算是站在工程师的角度,如何去构建一个 MCP 的服务。

会大概写三个系列,当前为第一系列 主要介绍 Stdio 的搭建。

在正式开始代码之前,先看一下官方给的架构图:

包含了以下部分:

  1. Host
  2. MCP Protocol
  3. MCP Server
  4. Local ...
  • 其中 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 这里直接使用即可。

sh 复制代码
mkdir mcp-server
cd mcp-server
pnpm init
pnpm add @modelcontextprotocol/sdk zod zod-to-json-schema

编写的示例就是读取当前的 package.json 内容,然后返回给 LLM,可以让用户提问当前 package.json 的 name 和 version 是多少的时候正确回答。

先创建一个 server 实例

ts 复制代码
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

ts 复制代码
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 发起请求调用工具的时候,我们还要写相关的逻辑来处理相对应的工具请求。

ts 复制代码
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)实现不同,所以导致资源的读取逻辑并不相通,截取官方的一段话介绍。

ts 复制代码
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 来完成消息标准输入和输出。

ts 复制代码
const transport = new StdioServerTransport();
server.connect(transport);

验证服务

客户端的话可以使用 DeepChat,安装好之后点击设置,找到 MCP 设置,新建一个自定义的 MCP 服务。

然后填写下面的内容用于配置解析

ts 复制代码
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 的信息读取的话,就可以延伸问一下这个包的协议是什么之类的提问了。

完整代码

点击查看完整代码

ts 复制代码
#!/usr/bin/env node

import { 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 的实现。

相关推荐
snakeshe10101 分钟前
入解析React性能优化策略:eagerState的工作原理
前端
六边形6662 分钟前
Vue中的 ref、toRef 和 toRefs 有什么区别
前端·vue.js·面试
kovli2 分钟前
红宝书第十八讲:详解JavaScript的async/await与错误处理
前端·javascript
前端付豪3 分钟前
🚀 React 应用国际化实战:深入掌握 react-i18next 的高级用法
前端·react.js·架构
代码小学僧3 分钟前
使用 Cloudflare workers 做一个定时发送消息的飞书机器人
前端·云原生·serverless
前端付豪4 分钟前
2、ArkTS 是什么?鸿蒙最强开发语言语法全讲解(附实操案例)
前端·后端·harmonyos
吃瓜群众i5 分钟前
javascript-对象及应用
前端·javascript
Oder_C6 分钟前
通用组件-字典组件优化思路
前端·性能优化
吃瓜群众i6 分钟前
Javascript的核心知识点-函数
前端·javascript
zhujiaming7 分钟前
鸿蒙端应用适配使用开源flutter值得注意的一些问题
前端·flutter·harmonyos