前言
在上一篇文章 🧐什么是 MCP 服务?如何利用 cursor + MCP 快速将现有服务接入大模型? 中 ,我介绍了一下 MCP 的概念以及如何快速搭建一个 MCP 服务,文中搭建了一个简单的示例在本地进行调试并顺利调用了工具。
在将上篇文章中的示例项目进行完善并发布的过程中,我对搭建 MCP 服务有了新的心得,也踩了一些坑,这篇文章我将更详细的讲讲从 MCP 服务的开发到发布的全流程。
示例项目需求
这次我打算实现一个简化权限策略配置的工具,目标就是通过 AI 来简化权限策略的配置流程。大家应该有听过 RBAC (基于角色的访问控制),通过分配角色(如管理员、用户)来控制权限,而不是直接给每个人单独设置权限。
不过 RBAC 的配置还是比较简单的,除了 RBAC 之外,如果需要更加精细化的资源权限管理,还有一种叫做 IAM (身份与访问管理)权限策略,相较于 RBAC ,IAM 中的配置更加复杂一些,角色是用一个个的策略(Policy)去组合出来的,而在策略中是通过一套语法去描述 是否允许某个资源的某种操作,就像下图展示的:

像是一些云平台,比如阿里云,AWS 这种超多资源需要精细化管理的平台就会用到这套权限管理体系,听起来是不是很复杂?实际上配置起来也确实是挺麻烦的, 因此我们需要利用 AI 来简化这些配置流程。
我希望能够用对话的方式让 AI 去分析我们对某个用户需要哪些权限,然后自动调用 API 去创建权限策略和角色,并将角色分配到具体的用户身上。而我们这次的示例项目也是从我实现的这个工具中剥离出来的,我尽量不涉及到业务细节。
项目搭建
MCP 支持使用多种语言进行开发,包括 Node.js
, Python
, Java
, Kotlin
, Go
等,这里我选择前端同学们更加熟悉的 Node.js
进行开发。
首先我们搭建项目,创建一个新的目录,这里我们就叫 IAM-policy-mcp
,然后进入目录中执行 npm init
初始化项目,再安装一下需要的依赖:
pnpm add @modelcontextprotocol/sdk zod
pnpm add typescript @types/node -D
再创建一个 src
目录,写入一个 index.ts
文件:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new McpServer({
name: "iam-policy-mcp",
version: "0.0.1",
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("IAM Policy MCP Server running on stdio");
}
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});
这里是一个 MCP 服务的最小化实现,里面没有任何功能,仅仅只是以标准输入输出的把 MCP 服务给跑起来了,后续我们再加功能。在根目录创建一个 tsconfig.json
文件,写入如下配置:
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"~/*": ["./src/*"]
},
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
添加工具
在 MCP 中,有很多种资源:
但这其中最重要的就是工具 tools
,我们只使用工具资源就可以将一个完整的 MCP 服务给搭建起来,在我实际开发过程中,我也只使用过工具和资源(resource)这两种资源,并且目前不是每一个 AI 客户端都支持这些资源的,cursor 就只支持工具和资源这两种,所以我们先以这两种为主进行介绍。
tool
是 server
上的一个方法,用于让 MCP 服务向客户端公开可以执行的功能。通过工具,LLM 可以与外部系统交互、执行计算并在现实世界中采取行动,它的结构是这样的:
server.tool(工具名称,工具描述,入参格式,回调函数)
例如我想实现一个获取用户的权限策略数据的工具,我可以这么写:
import { z } from "zod";
import makeAPIRequest from "../request.js";
export const GetPermissionPolicySchema = z.object({
id: z.string().optional(),
search: z.string().optional(),
});
server.tool(
"get_permission_policy",
"基于 ID 获取权限策略详情,或根据筛选条件获取多个权限策略",
GetPermissionPolicySchema.shape,
async (args) => {
const policyId = args.id;
if (policyId) {
return await makeAPIRequest(`/permission_policies/${policyId}`, "GET");
} else {
return await makeAPIRequest(
`/permission_policies?search=${args.search}`,
"GET"
);
}
}
这里我们实现了一个名为 get_permission_policy
的工具,这个工具的作用我们在第二个参数工具描述
中用自然语言描述出来,第三个参数是如果要使用我这个工具,需要传入什么格式的参数。
这里的参数格式我们要用 Zod
来定义,Zod 是一个 TypeScript 优先的模式声明和验证库,主要用于在运行时对数据进行类型校验,它的语法大家如果不熟悉可以把 typescript 的类型或者 schema 直接给 AI 让它帮我们翻译成 Zod 的语法。
前面三个参数都是给 AI 看的,AI 会解析用户对话中的语意,再基于我们的工具名称和工具描述看看是不是能满足用户的需求,如果符合的话就看看这个工具需要传递什么参数。如果参数也决定好了,那就将参数传递到第四个参数中的回调函数中,回调函数中是我们要执行的具体操作,在这里我们执行了 makeAPIRequest
操作,根据 AI 有没有传入 ID 来决定我们请求权限列表还是请求权限详情。
这里我讲讲 makeAPIRequest
中的实现:
//request.ts
import axios from "axios";
import { SERVER_URL, SERVER_PORT, TOKEN } from "./env.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
async function makeAPIRequest(path: string, method: string = "GET", data?: object): Promise<CallToolResult> {
const baseUrl = SERVER_URL ? `${SERVER_URL}:${SERVER_PORT}` : SERVER_URL;
const url = `${baseUrl}/api${path}`;
try {
const response = await axios({
method,
url,
data,
headers: {
"X-API-KEY": TOKEN,
"Content-Type": "application/json",
},
});
return {
content: [
{
type: "text",
text: JSON.stringify(response.data, null, 2),
},
],
};
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(`Request failed: ${method} ${url}`);
console.error(`Status: ${error.response?.status}, Error: ${error.message}`);
if (error.response?.data) {
try {
const stringifiedData = JSON.stringify(error.response.data);
console.error(`Response data: ${stringifiedData}`);
} catch {
console.error(`Response data: [Cannot parse as JSON]`);
}
}
return {
isError: true,
content: [
{
type: "text",
text: JSON.stringify(
`Status: ${error.response?.status}\nMessage: ${error.message}
Data:\n${JSON.stringify(error.response?.data || {}, null, 2)}`,
null,
2
),
},
],
};
} else {
return {
isError: true,
content: [
{
type: "text",
text: JSON.stringify(error, null, 2),
},
],
};
}
}
}
export default makeAPIRequest;
这个函数 makeAPIRequest
是一个封装好的 API 请求工具,主要功能就是用 axios 发送 HTTP 请求并统一处理成功和错误的结果,这个返回结果的结构是 MCP 规定的标准格式,类似这样:
try {
// Tool operation
const result = performOperation();
return {
content: [
{
type: "text",
text: `Operation successful: ${result}`
}
]
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: `Error: ${error.message}`
}
]
};
}
这里面我们还引用了一个 env
文件,通过 process.env
获取环境变量:
export const SERVER_URL = process.env.SERVER__ADDRESS;
export const SERVER_PORT = process.env.SERVER__PORT ;
export const TOKEN = process.env.TOKEN;
环境变量在后续是可以从外部传入的,如何配置我们后面讲调试的时候介绍。实现了请求的函数后,如果用户在 AI 客户端中提到有关于获取用户权限策略的需求,AI 就会来调用我们这个工具了。同样的,如果我要实现一个用于更新角色的工具,可以这么写:
import { z } from "zod";
import makeAPIRequest from "../request.js";
export const CreateRoleSchema = z.object({
role: z.object({
name: z.string().min(1).max(256).describe("The role name."),
desc: z.string().max(65536).describe("The object description."),
labels: z.record(z.string().regex(/^.+$/).min(1).max(65536)).describe("The labels of the object."),
policies: z
.array(
z
.string()
.regex(/^[a-zA-Z0-9-_.]+$/)
.min(1)
.max(256)
.describe("The object ID.")
)
.describe("The polices attached to the role."),
}),
});
server.tool("create_role", "创建一个角色", CreateRoleSchema.shape, async (args) => {
return await makeAPIRequest(`/roles`, "POST", args.role);
});
相较于获取数据的工具,创建的工具中我们就要把入参类型给补充完整,入参的类型结构不仅会提供给 AI 用于生成参数,同时也会起到校验的作用,如果比较蠢的大模型随便编一个错误格式的入参,那么 MCP 服务校验失败会是不会调用这个工具的回调函数的,而且还会把正确的入参结构和错误的位置再次告诉大模型,帮助它构建正确的入参。
工具的概念其实很简单,通过描述工具的名称让 AI 知道我们的工具可以用来干什么,然后再描述一下工具需要的参数让 AI 去构建传入,最后我们自己来处理返回的结果,是不是还蛮容易理解的~
注意点!:有些中后台系统里面概念和资源有很多,因此我们可能会对应 API 精细化的写很多个工具,例如每个资源的增删查改都写一个,但实际上工具越多,效果反倒越差,因为我们的工具列表是要提供给 AI 选择的,过多的工具会导致过长的上下文,除了开销会更大,AI 的注意力也会被分散,导致最终的执行效果不如预期。
而且 AI 客户端支持的单个 MCP 服务工具数量是有上限的,以 cursor 为例,最多支持的工具数量是 40 个

实现资源
资源(Resources)允许 MCP 服务公开一些可以让客户端读取并作为 LLM 交互上下文的数据和内容,在我们前面实现的工具中其实也有用于给 AI 获取数据的功能,但那个权限策略的数据相对来说比较动态一点,用户可能随时对它进行编辑,因此需要每次对话的时候执行 API 去获取一下,而资源则是相对静态的资源,例如:
- 源代码
- 配置文件
- 日志文件
- JSON/XML 数据
- 纯文本
- 图片
- PDF
- 音频文件
- 视频文件
- 其他非文本格式
但是想这些内容似乎也不是非常静态,所以我对于资源和工具之间的选择其实还有点模糊,因为能用资源的地方用工具似乎也行,至少在我实际调试的时候没有感受出来太大的差别。不过我还是将我的一些静态的内容通过资源的方式提供给 AI 了。
resource
也是 server
上的一个方法,它的结构是这样的:
server.resource(资源的名称,资源的 URI,回调函数)
这里举个例子:
server.resource(
"权限策略配置示例和所有资源操作",
"text://permission-policy-config-reference.json",
() => {
return {
contents: [
{
uri: "text://permission-policy-config-reference.json",
text: JSON.stringify(policyResourcesActions, null, 2),
mimeType: "application/json",
},
],
};
}
);
这里的资源的 URI 并不需要真的指向某个文件或路径,只是以 URI 的形式组织一个类似静态资源的名称,最终我们返回的内容还是从回调函数中返回的,在这个示例中,我返回了一个静态的 json 结构,用于 AI 编辑权限策略的时候可以用于参考。
有时我们的工具中虽然可以定义入参的格式,但是这个格式只能限制参数整体的结构,而参数中包含的内容还是 AI 自己想的,比如我们有一个结构:
z.object({
code: z.string()
})
AI 会给我传入 {code: xxx}
,但是代码的内容还是没法规范,如果我们想让他阅读某个文档后,基于文档中的规范去传入代码,应该怎么做呢?
首先,目前 MCP 中的工具是没有什么依赖或者工作流的实现的,有时候我们选择比较聪明的模型,它可以基于我们提供的工具和资源,自己组织合适的步骤,先阅读文档,再执行操作,但大多数时候它还是会选择自己瞎编。
我自己摸索的一个方法是在工具的描述中告诉 AI 需要去先阅读某个资源后再进行操作,例如:
export const UpdatePermissionPolicySchema = z.object({
id: IDSchema,
permissionPolicy: PermissionPolicySchema,
alreadyReadResource: z.boolean(),
});
server.tool(
"update_permission_policy",
"更新权限策略,使用此工具前请阅读 mcp 资源 text://permission-policy-config-reference.json",
UpdatePermissionPolicySchema.shape,
async (args) => {
if (!args.alreadyReadResource) {
return {
content: [
{
type: "text",
text: "Please read the mcp resource text://permission-policy-config-reference.json before using this tool",
},
],
};
}
return await makeAPIRequest(
`/permission_policies/${args.id}`,
"PUT",
args.permissionPolicy
);
}
);
我利用 AI 会分析工具描述的特点,将前置依赖写在描述中,并且在入参格式中,我加了一个 alreadyReadResource
这个参数与业务无关,纯粹是一个补充描述,让 AI 生成参数的时候自己反思一下到底看没看,不过它就算没看有时候也会直接填写一个 true
┑( ̄Д  ̄)┍。
我自己实测的结果是像 Deepseek 或者 Claude Sonnet 3.5/3.7 这些模型在调用这个工具前确实会很大概率去看文档了,参考下面这个截图

优化项目结构
当我们的工具类型和资源比较多的时候,可以参考我的一个组织方式,我将资源,工具,类型分为三个目录,其中以不同的资源类型进行分类,然后相互导入使用,如下图:

在每一个类型的工具文件中,我会这样创建工具:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import makeAPIRequest from "../request.js";
import { AssignRoleToUserSchema, CreateRoleSchema, DeleteRoleSchema, GetRoleSchema } from "../types/role.js";
const setupRoleTools = (server: McpServer) => {
server.tool("get_role", "Get role details by ID or list roles based on filters", GetRoleSchema.shape, async (args) => {
const roleId = args.id;
if (roleId) {
return xxx;
} else {
return xx;
}
});
server.tool("create_role", "Create a role", CreateRoleSchema.shape, async (args) => {
return await makeAPIRequest(`/roles`, "POST", args.role);
});
server.tool("update_assigned_roles_for_user", "Update assigned roles for a user", AssignRoleToUserSchema.shape, async (args) => {
return xxx;
});
server.tool("delete_role", "Delete a role", DeleteRoleSchema.shape, async (args) => {
return await xxx;
});
};
export default setupRoleTools;
通过实现一个 setupXXXTools
函数,传入 server 去添加工具,然后在 index.ts
中这样使用:
//...
import setupRoleTools from "./tools/role.js";
const server = new McpServer({
name: "iam-policy-mcp",
version: "0.0.1",
});
setupRoleTools(server);
//...
当然这个大家可以基于自己的习惯来,并没有什么强制规范,只是一个思路,不要把全部业务都堆在一个文件,让整个项目可读性可维护性强一点。
调试服务
在我们的服务开发完成后,在 package.json
中写入如下配置:
{
"type": "module",
"name": "你的项目名称",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc "
}
//...
}
然后执行 pnpm build
构建一下项目,看到 dist
目录就是构建好了,接下来可以开始调试。
调试服务我们可以使用官方提供的一个调试工具 MCP Inspector,使用也很简单,在命令行输入:
npx @modelcontextprotocol/inspector <command> <arg1> <arg2>
其中 command
是启用我们 MCP 服务的命令, arg
是参数,当然你也可以不填写,直接执行 npx @modelcontextprotocol/inspector

浏览器打开 http://localhost:5173
后,左侧我们需要填写一些配置信息 Command
输入入 node
,Arguments
输入 MCP 服务构建后的脚本位置 项目地址/dist/index.js
。Environment Variables
添加我们需要的环境变量。

配置完成后,点击 Connect 连接,如果连接成功会亮一个小绿灯,右侧的标签栏我们选择 Tools 然后点击 List Tools 就可以看到我们 MCP 服务中所有的工具了。

此时我们点击任意一个工具,最右侧会有一个类似 API 调试的控制台,我们可以根据工具的入参格式输入参数,然后点击 Run Tool 就会调用工具并打印响应了。

注意点!:由于 MCP 服务是以标准输入输出方式运行在我们自己的主机上的,所以不同的服务可能对电脑的环境配置要求不同。
我在实际调试的过程中,macos 的电脑基本没有遇到啥问题,但是 windows 电脑上各种 MCP 服务无法正常配置,开发的时候调试也是很多问题,基本上都是 windows 上 node.js 的环境配置相关的,而且不同的 AI 客户端的实现有所不同,因此也会出现这个客户端可以配置而相同的配置另外一个客户端却报错的情况。
这里如果你是 windows 系统的用户,在开发 MCP 我推荐你直接在 wsl 的环境内进行开发,这样不论是开发还是调试都会更加顺畅。
在 AI 客户端调试服务
这里我们以 cline 为例讲讲如何调试本地项目,之所以选择 Cline 是因为每一次调试都需要消耗蛮多 token,如果使用 cursor 的话就要消耗快速查询次数,并且必须开 agent 模式,调试一次消耗两个额度。而使用 cline 我们可以配置例如 deepseek, chatgpt-4o-mini 这种比较经济的模型用于调试,整体成本会降低很多。
首先我们在 Vscode 中安装 Cline:

然后点击 Cline 右上角设置按钮,配置一下我们的 AI 提供商的 key ,这里我选择的是 openrouter
,这样可以很方便的切换不同的模型进行调试。

下一步我们点击第二个服务器的图标,然后切换到已安装的服务(Installed):

再点击 Configure MCP Servers,就会弹出一个配置文件的编辑,我们写入以下格式:
{
"mcpServers": {
"iam-policy-mcp": {
"command": "node",
"args": [
"E:\\projects\\IAM-policy-MCP\\dist\\index.js"
],
"env": {
"SERVER_URL": "https://xxx.com",
"TOKEN": "xxx"
}
}
}
}
将 args
中的脚本地址改成你自己服务打包后的产物地址,然后再把环境变量改一下,配置成功后 cline 中就会多一个 MCP 服务的展示,并且亮着小绿灯,如果配置失败在这个服务下方也会打印错误信息:

下一步我们点击右上角的 Done ,然后在 Cline 的对话框中输入可以调用到 MCP 中工具的指令,如果看到 Cline 中向我们申请调用 MCP 的权限时,说明我们的 MCP 服务配置成功了,后续我们只需要我们实际业务需求不断调试调用的结构即可。

Cline 的每次对话上方还会展示消耗的 token 数量,对应的金额,当前对话的上下文大小,我们也会发现 MCP 服务消耗的 token 数量是非常多的,它会把我们所有工具的信息都发送给大模型,如果调用了工具或者查看资源,还会将工具中响应的内容也提供给大模型,这也就是为什么推荐大家选择一些比较经济的模型去调试。

注意点!:每一次我们修改源代码之后,不仅要重新构建代码,还需要在对应的 AI 客户端中重启一下 MCP 服务,否则调试的时候还是用的上一次的代码跑起来的进程,虽然这是基础问题,但很容易被忽略。
发布服务
当我们的服务在本地 AI 客户端也调试完成之后,下一步我们可以将服务发布到线上。如果你有用过一些常用的 MCP 服务的话,应该会了解到在 AI 客户端配置已发布 MCP 服务的时候,我们需要在配置文件中写入如下配置:
{
"mcpServers": {
"apisix-mcp": {
"command": "npx",
"args": [
"-y",
"xxx-mcp"
]
}
}
}
这段配置中,我们告诉 AI 客户端需要通过 npx -y
的方式调用 MCP 服务的运行脚本,而具体的包名则需要我们发布到 npm 上才能正常访问。我们在我们的 MCP 服务开发完成后,就需要将包发布到 npm 库中。
为了发布后可以正常使用,我们要先做两件事,第一个是在 src/index.ts
中第一行添加 Shebang :
#!/usr/bin/env node
Node.js 的 Shebang(也称为 hashbang
)是一个特殊的注释行,通常出现在脚本文件的开头,用于告诉操作系统如何执行这个脚本文件。它的格式是 #!
后跟解释器的路径,例如:
#!/usr/bin/env node
console.log("Hello World!");
为什么需要 Shebang?
-
直接运行脚本:有了 Shebang,你可以直接在终端运行脚本文件,而不需要先输入 node
命令
- 没有 Shebang:
node my-script.js
- 有 Shebang:
./my-script.js
(需要先给文件可执行权限)
-
跨平台兼容:/usr/bin/env node
这种写法会查找系统 PATH 中的 node,比直接写 /usr/local/bin/node
更灵活
如果不添加你就会发现通过 npx 运行线上脚本的时候会提示我们缺少依赖,下一步配置一下 package.json
//...
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
+ "build": "tsc && shx chmod +x dist/*.js",
"start": "node dist/index.js",
"lint": "eslint . --fix"
},
+ "files": [
+ "dist"
+ ],
//...
build 命令中增加 && shx chmod +x dist/*.js
,要记得先 pnpm add shx -D
安装一下依赖,这一步是为了避免权限问题导致脚本无法执行,前面 Shebang 中也提到了这个问题,也是在发布调试的过程中遇到的,所以强调一下。
接下来我们还需要增加一个 files
字段,里面是一个数组,我们只需要写 dist
一项,files
用于指定我们发布时要选择的目录路径。
完成以上步骤后,在执行 npm pack
可以打一个测试包,看看里面是不是我们需要的内容,通常来说就是我们 dist
目录中的所有东西,还有 readme.md
和 package.json
了。
如果没有问题我们就执行 npm publish
进行发布,如果本地没有登陆 npm 需要执行 npm login
先登录一下再发布。
发布完成后,我们可以将前面在 cline 中写入的配置文件修改一下,改成线上的执行命令:
{
"mcpServers": {
"iam-policy-mcp": {
"command": "npx",
"args": [
"-y", "xxx-MCP"
],
"env": {
"SERVER_URL": "https://xxx.com",
"TOKEN": "xxx"
}
}
}
}
如果 Cline 中和本地服务一样亮了绿灯,就说明已经配置正确并发布成功了。
发布到导航站
目前 MCP 的热度很高,已经有很多的导航站了,如果需要增加曝光,让更多人用上你开发的 MCP ,可以投稿到以下几个我整理的导航站:
要注意这些站点都是要求项目开源的。
总结
实现一个 MCP 服务其实并不难,注意一下文中提到的容易踩坑的点,一两天的时间就可以完成开发到发布了,后续我会继续研究 MCP 服务如何做自动化测试,如何在和大模型对话的过程中减少 token 消耗以及优化执行效果。
如果文章对你有帮助不妨点个赞👍支持一下,respect~