从0手搓MCP服务,开发、部署、配置Server,实现MCP自由

✍️ AI Coder 码农王

一、这事值得做,而且不难

我用 AI 工具的时候,里面挂了不少现成的 MCP——它们让 AI 能查数据库、搜代码、操作浏览器。用得多了我就想:这些到底是怎么写出来的?不如自己写一个试试。

写个什么好?挑了最朴素的:一个记忆服务。跟 AI 说一句"记一下:决定用 pnpm 代替 npm",它把这条追加写进本地 txt 文件;回头问"之前记过什么",它原样读出来。功能不花哨,但"写出来、配上去、真的用起来"这条链路走通了,写别的服务也就是换里面的逻辑。

这篇带你走一遍我自己刚走完的路:用 60 行 TypeScript 写一个记忆 MCP 服务,配置进 ZCode,跑通"写入 → 读出来"的完整闭环。会用 npm、写过一点代码就能跟上。

先看这篇都写什么:

读完你会带走三样东西:一个能用的记忆服务、一份 ZCode 配置、以及我踩过的坑。npm 发布是下一步的事,这篇先解决"自己用起来"。

二、写一个记录决策的 MCP 服务

先建项目。找个空目录,npm init -y,然后装包:@modelcontextprotocol/sdk 是 MCP 官方 SDK,zod 用来描述工具参数,TypeScript 负责编译(装成 devDependencies)。

npm init -y
npm i @modelcontextprotocol/sdk zod
npm i -D typescript @types/node

编译用 TypeScript。tsconfig 三件事:目标 ES2022、模块 NodeNext(输出格式跟着 package.json 的 type 字段走)、产物输出到 dist。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "types": ["node"],
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true
  },
  "include": ["src"]
}

注意 types 那行:我装的 TypeScript 7 默认不带 node 类型声明,不写 "types": ["node"] 编译时 node:os、node:fs 全报"找不到声明"。这是我 build 第一脚就撞上的坑,先照抄。

package.json 只加两处:"type": "module"——告诉 Node 这是 ESM 项目,NodeNext 模式下 TS 按这个字段决定把 import 编译成 ESM 还是 require;build 脚本。

{
  "name": "mcp-memory",
  "version": "1.0.0",
  "type": "module",
  "scripts": { "build": "tsc" }
}

然后写 src/index.ts。整个服务 60 行,按执行顺序看:先决定数据存哪。

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { mkdir, appendFile, readFile } from "node:fs/promises";

// 记录存在 ~/.mcp-memory/notes.txt,也可以靠环境变量换位置
const noteFile =
  process.env.MCP_MEMORY_FILE ?? join(homedir(), ".mcp-memory", "notes.txt");

第一行的 #!/usr/bin/env node 是给 Unix 用的:npm 全局安装后生成的命令靠它找到 node 来执行;Windows 上 npm 会另生成 .cmd 版,两边都能跑。

noteFile 从环境变量读:ZCode 配置里可以写 env,把 MCP_MEMORY_FILE 传进这个进程;不传就落到用户目录下,跟项目在哪无关。

接着创建服务实例:

const server = new McpServer({ name: "mcp-memory", version: "1.0.0" });

name 和 version 会在握手时上报给客户端,工具清单里能看到是谁提供的。

然后是第一个工具,写。

server.registerTool(
  "save-note",
  {
    description:
      "把一条信息追加记录到本地笔记文件。适合记录项目决策、约定、排查结论等。",
    inputSchema: {
      content: z.string().describe("要记录的内容,例如:决定用 pnpm 代替 npm"),
    },
  },
  async ({ content }) => {
    await mkdir(dirname(noteFile), { recursive: true });
    const line = `[${new Date().toISOString()}] ${content}`;
    await appendFile(noteFile, line + "\n", "utf8");
    return { content: [{ type: "text", text: `已记录:${line}` }] };
  },
);

registerTool 三个参数:工具名、工具的"自我介绍"、干活的函数。

description 不是给人看的注释——它会进模型的上下文,模型靠它判断什么时候该调这个工具。写具体点模型才调得准:什么时候用、参数长什么样,都写在这句话里。

inputSchema 用 zod 描述参数,模型生成参数时对着这个 schema 填,schema 错了模型就填错。

干活函数里三件事:mkdir 是因为 appendFile 不会自动建目录;带个时间戳方便以后翻;返回值必须是 { content: [{ type: "text", text }] } 这个形状,模型读到的是 text 字段。

第二个工具,读。

server.registerTool(
  "list-notes",
  {
    description:
      "读取本地笔记文件的全部内容。在用户问起之前记过什么决策、约定时使用。",
    inputSchema: {},
  },
  async () => {
    try {
      const text = await readFile(noteFile, "utf8");
      const body = text.trim();
      return {
        content: [{ type: "text", text: body || "(还没有任何记录)" }],
      };
    } catch {
      return { content: [{ type: "text", text: "(还没有任何记录)" }] };
    }
  },
);

一个服务想有几个工具,registerTool 就调几次,工具之间共享这个进程。读工具没有参数,inputSchema 给空对象。

文件不存在的情况要接住:返回"(还没有任何记录)"而不是报错。如果标成 isError,模型会当成调用失败,可能反复重试——没记过东西是正常状态,不是错误。

最后两行把服务挂到 stdio 上:

const transport = new StdioServerTransport();
await server.connect(transport);

到这一行进程就活着了,等客户端从 stdin 发消息。握手、消息格式这些 SDK 全处理了,服务端代码不用碰。

执行 npm run build,tsc 产出 dist/index.js。服务到此写完。

三、装完先自己测

先别急着配 ZCode。先证明这个进程自己就能跑——回头配置出问题时,范围能缩小到"配置写错了"而不是"服务本身有问题"。

测它的办法是写一个测试客户端。ZCode 连上服务后干三件事:拉起进程并握手、列工具、调工具。我们拿 SDK 自带的 Client 把这三件事原样干一遍:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

// 拉起 dist/index.js 并握手,env 把记录文件指到临时目录
const transport = new StdioClientTransport({
  command: "node",
  args: ["dist/index.js"],
  env: {
    MCP_MEMORY_FILE:
      "C:/Users/wangkai/AppData/Local/Temp/mcp-memory-test/notes.txt",
  },
});
const client = new Client({ name: "test-client", version: "0.0.1" });
await client.connect(transport);

// 列工具
const tools = await client.listTools();
console.log("工具:", tools.tools.map((t) => t.name).join(", "));

// 写两条
const r1 = await client.callTool({
  name: "save-note",
  arguments: { content: "决定用 pnpm 代替 npm" },
});
console.log(r1.content[0].text);
const r2 = await client.callTool({
  name: "save-note",
  arguments: { content: "图标统一用 SVG,不引入图标库" },
});
console.log(r2.content[0].text);

// 读出来
const r3 = await client.callTool({ name: "list-notes", arguments: {} });
console.log(r3.content[0].text);

await client.close();

我把 MCP_MEMORY_FILE 指到了临时目录——测试数据不混进真实笔记,顺便验证了第二章那行环境变量真的生效。跑起来,输出长这样:

工具: save-note, list-notes
已记录:[2026-09-04T08:30:46.234Z] 决定用 pnpm 代替 npm
已记录:[2026-09-04T08:30:46.244Z] 图标统一用 SVG,不引入图标库
[2026-09-04T08:30:46.234Z] 决定用 pnpm 代替 npm
[2026-09-04T08:30:46.244Z] 图标统一用 SVG,不引入图标库

工具列表出来了,写和读都通了。最后一步核对落盘——打开那个 txt 文件本体:

[2026-09-04T08:30:46.234Z] 决定用 pnpm 代替 npm
[2026-09-04T08:30:46.244Z] 图标统一用 SVG,不引入图标库

文件内容和 list-notes 返回的完全一致。到这一步才算真跑通:数据确实写进了磁盘,不是只在对话里回显一遍。服务本体没问题了,可以往 ZCode 里配。

四、配进 ZCode,让它真能写进读出

ZCode 的配置在用户目录的 ~/.zcode/cli/config.json 里,mcp.servers 下面每加一条,就多一个 MCP 服务。也可以在设置界面里点着加,落地的就是这份 JSON:

{
  "mcp": {
    "servers": {
      "memory": {
        "type": "stdio",
        "command": "node",
        "args": ["C:/dev/mcp-memory/dist/index.js"],
        "env": {
          "MCP_MEMORY_FILE": "C:/Users/你的用户名/.mcp-memory/notes.txt"
        }
      }
    }
  }
}

路径换成你自己机器的,逐行说:

type: "stdio"——本地进程型。还有一种是 http 型,连远程服务用的,本地用不上。

command: "node" 加 args——客户端会原样执行 node C:/dev/mcp-memory/dist/index.js 把进程拉起来。这里 command 写 node 而不是 npx:Windows 上 npx 是个 .cmd 批处理包装,直接 spawn 会报 ENOENT。

env——传给服务进程的环境变量。第二章那行 process.env.MCP_MEMORY_FILE 等的就是它。配置里的 env 就是"参数怎么进服务"的通道,跟你在终端敲 MCP_MEMORY_FILE=xxx node index.js 是一回事。

想给服务传自定义参数,只有 env 这一条指定通道。stdio 型配置的字段是固定的:type、command、args、env。你不能在配置里随手加一个 "noteFile": "..." 指望服务读得到——服务拿不到这份 JSON,它只能拿到环境变量。所以两边约定同一个变量名:配置里写 env,服务里读 process.env。

配合得好不好,看服务端怎么读。我写的是:

const noteFile =
  process.env.MCP_MEMORY_FILE ?? join(homedir(), ".mcp-memory", "notes.txt");

?? 给默认值:配置里不写 env,服务落到用户目录下的固定位置;写了 env,就用配置指定的文件。参数可选,配置才有最小形态。

这个通道的价值不止省事。同一份 dist/index.js,我可以配两条:

"memory-work": {
  "type": "stdio",
  "command": "node",
  "args": ["C:/dev/mcp-memory/dist/index.js"],
  "env": { "MCP_MEMORY_FILE": "C:/dev/notes/work.txt" }
},
"memory-life": {
  "type": "stdio",
  "command": "node",
  "args": ["C:/dev/mcp-memory/dist/index.js"],
  "env": { "MCP_MEMORY_FILE": "C:/dev/notes/life.txt" }
}

工作决策和生活琐事各记各的,两个实例互不干扰。我本机实测过:两个客户端按这两份 env 分别拉起同一份代码,写进去的内容各进各的文件,读出来互不串。

服务代码一行没改,行为差异全由配置决定。这就是配置和服务配合的完整链路:配置的 env 进环境变量,服务的 process.env 读环境变量,变量名是两边唯一的约定。

ZCode 读配置、拉起进程、说 JSON-RPC、模型想用时替它调用,整条链路长这样:

ZCode 读配置 command / args / env
↓
spawn 拉起服务进程 node dist/index.js(mcp-memory)
↓
stdin / stdout 说 JSON-RPC 一行一个 JSON,模型按需调用工具
↓
工具执行,写入 txt save-note → ~/.mcp-memory/notes.txt

配完怎么确认生效,三步:

💡 配置生效三步 ✅ config.json 的 mcp.servers 里加条目,保存 ✅ 重启 ZCode 或开新会话——MCP 在会话启动时连接 ✅ 问一句"记一下:测试",再问"之前记过什么"核对读出

有个容易懵的点先打预防针:MCP 服务是会话启动时连接的,改完配置当前会话不会变。重启 ZCode 或开个新会话,memory 才会出现在 MCP 列表里。我本机实测到了"按配置拉起进程、握手、列出两个工具"这一步;让模型真的说一句记一句,要等新会话——这不是配置写错了。

五、我踩的坑

这一章是我整个过程中卡住过或者差点卡住的地方,按踩到的时间顺序排。

TS 7 不写 types 编译不过

第一次 npm run build 就撞上了:

error TS2591: Cannot find name 'node:os'.
Do you need to install type definitions for node?

node:os、node:path、node:fs/promises 全报,说找不到声明。我装的 TypeScript 7 默认不带 node 的类型声明,tsconfig 里显式写 "types": ["node"] 才通过。老教程里没这行,是因为 TS 5 时代会自动带上 @types/node——按老教程抄到这里会莫名卡住。

Windows 上别把 command 写成 npx

客户端拉服务是直接 spawn 子进程,不经过你的 shell。Windows 上 npm 装的命令是 .cmd 批处理包装,spawn 不认识。我本机跑了三个对照实验:

command 写法实测结果原因
"npx"❌ ENOENTnpx 是 .cmd 批处理包装,spawn 不认
"cmd" + ["/c","npx",…]⚠️ 能拉起借 cmd.exe 拉批处理(发布篇再展开)
"node" + 绝对路径✅ 正常node.exe 是原生可执行,本篇推荐

结论:直接写 npx 报 ENOENT,包一层 cmd /c 就行;uvx 因为是编译出来的原生 exe,直接写没事。所以本篇配置的 command 写的是 node——node 是真 exe,不用绕。

appendFile 不会建目录

appendFile 能创建文件,但不会创建目录——目录不存在直接抛错。所以 save-note 里那行 mkdir(dirname(noteFile), { recursive: true }) 不能省——少了它,第一次写入就会失败(SDK 会把 handler 抛的异常转成 isError 的报错结果),模型看到的就是一条失败。

"文件不存在"不是错误

list-notes 遇到文件不存在,返回"(还没有任何记录)"而不是标 isError。这两者对模型差别很大:标 isError,模型会把正常状态当成调用失败,可能反复重试,或者干脆不再用这个工具;返回正常文本,模型就知道"确实还没记过东西",该继续继续。

description 决定模型怎么用你的工具

description 会进模型上下文,是模型判断"什么时候该调这个工具"的唯一依据。写"读取本地笔记文件的全部内容。在用户问起之前记过什么决策、约定时使用"比写"read notes"明确得多——后者模型可能一次都不调。工具不止一个时,两个 description 要把"写"和"读"分清楚,不然模型容易拿错工具。

改了配置,当前会话不会生效

我配完 config.json,在原会话里左等右等,memory 一直不出现,愣是没想明白哪里出了问题。坑不在配置,在时机:MCP 服务是会话启动时连接的,改完必须重启 ZCode 或开新会话。知道这一点后,调配置的节奏就变成"改完重启验证",而不是边改边等。

macOS 的 GUI 应用找不到 npx

这一条不是本机踩的(我用 Windows),是社区里反复出现的问题,写在这里供 mac 用户参考:Claude Desktop 这类 GUI 应用启动时不继承 shell 的 PATH,nvm、homebrew 装的 node 不在里面,报经典的 "npx not found"。解法是把 node 写成绝对路径,或者改用 docker 型配置。

六、什么时候不适合

先说边界。stdio 型 MCP 是本机单用户工具:进程跟着你的 AI 工具走,记录落在你自己磁盘上。自己用没问题;想让团队共享一份记忆,就得把服务改成 http 型部署到服务器,那是另一套玩法。

txt 方案也一样。记决策、约定、排查结论这种轻量内容够用;要结构化查询、按标签检索、多人协作,txt 扛不住——该上数据库或者专门的笔记工具。我把 txt 定位成轻量留痕,重的记录走别的工具。

还有一个取舍:某个能力如果只在一个 AI 工具里用,写那个工具的原生插件可能更省事。MCP 的价值在"写一次,ZCode、Cursor、Claude Desktop 都能用",冲着这个去写才不亏。

想继续往深处走,有几条路:把传输从 stdio 换成 streamable HTTP,服务就能部署给团队用;MCP 除了 Tool 还能暴露 Resource(只读数据)和 Prompt(提示词模板);调试工具官方有个 Inspector(npx @modelcontextprotocol/inspector),我这次是拿测试客户端自测的,没用过它,不好替你评价。发布到 npm 是下一步最顺的方向——服务写好了,没道理只自己用。发布过程本身的坑,我另写一篇。

七、要点回顾

照着这篇走完,你手里就有一个能用的记忆服务。把 save-note 里的 appendFile 换成你想干的任何事,就是一个新的 MCP 服务——骨架不变。

— 图文来源:AI Coder 码农王(微信公众号),内容转载排版 —