首页 › tutorials › 三原语合一的MCP服务器实战

概述

本教程面向已经能写出"只会暴露 Tools"的 MCP 服务器、却总觉得还差点意思的开发者。我们将把 MCP 协议里最被忽视的两个原语——Resources(资源)与Prompts(提示)——和 Tools 一起,装进同一个本地知识库服务器里,让你彻底搞懂"什么时候该用哪一种",并跑通一个可直接接入 Claude Code 的完整工程。

前置条件

如果你还没写过任何 MCP 服务器,建议先补一节"用 FastMCP 构建首个 Tool-only 服务器",再回到本篇。


一、为什么只懂 Tools 还不够:三原语的心智模型

绝大多数 MCP 教程止步于 Tools,于是很多开发者形成了一个误区:MCP = 给大模型挂一堆可调用的函数。这其实只用了协议的三分之一。MCP 把服务器能力显式分成了三种"原语",控制权归属各不相同:

原语 控制方 类比 语义
Tools 模型控制(Model-controlled) POST /action 做一件事,可能带副作用,返回结果
Resources 应用控制(Application-controlled) GET /data 通过稳定 URI 暴露可读数据
Prompts 用户控制(User-controlled) 模板库 用参数化的消息模板结构化一次请求

一句话记忆:Tools 是动作,Resources 是数据,Prompts 是意图。

为什么这个区分重要?因为它决定了"谁在什么时候触发":

把这三者搞混,集成体验就会很别扭。最常见的两个错误:把只读数据做成 Tool(白白放弃了缓存与可寻址性,且每次都要模型主动去"取");把 Prompt 写得像死命令(太死板,输出像机器人)。本教程的工程会同时演示三者的正确用法。

二、工程骨架:用 TypeScript SDK 搭起服务器

我们用官方 @modelcontextprotocol/sdk 的高层 McpServer API,它把三种原语都收敛成一行注册,最省样板代码。

2.1 初始化项目

mkdir kb-server && cd kb-server
npm init -y
npm i @modelcontextprotocol/sdk zod
npm i -D typescript @types/node

把 package.json 的关键字段改成下面这样(关键是 "type": "module",SDK 走 ESM):

{
  "name": "kb-server",
  "version": "1.0.0",
  "type": "module",
  "bin": { "kb-server": "dist/index.js" },
  "scripts": { "build": "tsc", "start": "node dist/index.js" }
}

2.2 tsconfig.json

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

⚠️ 常见坑:moduleResolution 必须是 NodeNext(或 Bundler),否则 TypeScript 解析不到 .../server/mcp.js 这种带 .js 后缀的子路径导出,报 Cannot find module。

2.3 准备数据目录

mkdir -p ~/notes
printf -- '---\ntags: [release, ops]\n---\n\n# 发布检查清单\n上线前确认:回滚方案、灰度比例、告警阈值。\n' > ~/notes/release-checklist.md
printf -- '# 欢迎笔记\n这是知识库的第一篇笔记。\n' > ~/notes/hello.md

这个 ~/notes 目录就是我们的"知识库",服务器会把里面的 Markdown 暴露成资源。

三、第一原语 Tools:检索与写笔记

新建 src/index.ts,先写骨架和两个工具。注意高层 API 的签名是 server.tool(名称, 描述, zodSchema, 回调)。

import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fs from "node:fs/promises";
import path from "node:path";

const KB_DIR = process.env.KB_DIR ?? path.join(process.env.HOME ?? ".", "notes");

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

// ---------- 工具:全文检索 ----------
server.tool(
  "search_notes",
  "对本地笔记做不区分大小写的全文检索,返回命中的文件名与上下文片段",
  { query: z.string().describe("关键词"), limit: z.number().int().positive().default(5) },
  async ({ query, limit }) => {
    const files = (await fs.readdir(KB_DIR)).filter((f) => f.endsWith(".md"));
    const q = query.toLowerCase();
    const hits: { file: string; snippet: string }[] = [];
    for (const f of files) {
      const text = await fs.readFile(path.join(KB_DIR, f), "utf8");
      const idx = text.toLowerCase().indexOf(q);
      if (idx >= 0) {
        const start = Math.max(0, idx - 40);
        hits.push({ file: f, snippet: text.slice(start, idx + q.length + 60) });
      }
      if (hits.length >= limit) break;
    }
    return { content: [{ type: "text", text: JSON.stringify(hits, null, 2) }] };
  }
);

// ---------- 工具:新增笔记 ----------
server.tool(
  "add_note",
  "向知识库新增一篇笔记,标签写入 frontmatter",
  {
    title: z.string().describe("文件名(不含 .md)"),
    body: z.string().describe("Markdown 正文"),
    tags: z.array(z.string()).default([]).describe("标签列表"),
  },
  async ({ title, body, tags }) => {
    const fm = tags.length ? `---\ntags: [${tags.join(", ")}]\n---\n\n` : "";
    const file = path.join(KB_DIR, `${title}.md`);
    await fs.writeFile(file, fm + body, "utf8");
    return { content: [{ type: "text", text: `已写入:${file}` }] };
  }
);

预期结果

npm run build 后用 inspector 运行:

npx @modelcontextprotocol/inspector node dist/index.js

在弹出的 inspector 面板里调用 search_notes,入参 {"query":"回滚"},应返回:

[
  { "file": "release-checklist.md", "snippet": "上线前确认:回滚方案、灰度比例、告警阈值。" }
]

常见问题

四、第二原语 Resources:把笔记目录变成可寻址资源

这是大多数教程跳过的部分,也是本教程的重头戏。我们要做两件事:暴露一个静态资源 notes://index(笔记索引),以及一个动态资源模板 notes://{filename}(按 URI 读任意一篇笔记,并支持列出全部)。

继续在 src/index.ts 里追加:

// ---------- 资源:静态索引 ----------
server.resource(
  "kb-index",
  "notes://index",
  async (uri) => {
    const files = (await fs.readdir(KB_DIR)).filter((f) => f.endsWith(".md"));
    const list = files.map((f) => `- [${f.replace(/\.md$/, "")}](notes://${f})`).join("\n");
    return {
      contents: [
        { uri: uri.href, mimeType: "text/markdown", text: `# 笔记索引\n\n${list}` },
      ],
    };
  }
);

// ---------- 资源:动态模板 notes://{filename} ----------
const noteTemplate = new ResourceTemplate("notes://{filename}", {
  // list 回调:让客户端能"发现"有哪些笔记
  list: async () => {
    const files = (await fs.readdir(KB_DIR)).filter((f) => f.endsWith(".md"));
    return {
      resources: files.map((f) => ({
        uri: `notes://${f}`,
        name: f.replace(/\.md$/, ""),
        mimeType: "text/markdown",
      })),
    };
  },
});

server.resource("note", noteTemplate, async (uri, { filename }) => {
  const abs = path.join(KB_DIR, `${filename}.md`);
  const text = await fs.readFile(abs, "utf8");
  return { contents: [{ uri: uri.href, mimeType: "text/markdown", text }] };
});

关键点解读

预期结果

在 inspector 的 Resources 面板,应能看到:

💡 客户端支持差异(重要):Tools 是所有 MCP 客户端都会让模型自动调用的;但 Resources 与 Prompts 的 UI 呈现因客户端而异。Claude Desktop、inspector 会把 Resources 列在附件/资源面板,把 Prompts 列为可挑选的模板;Claude Code CLI 主要自动驱动工具调用,Resources/Prompts 的可见性取决于版本。因此调试 Resources/Prompts 时,请优先用 inspector 验证协议层正确性,再回到你的目标客户端确认展现。

五、第三原语 Prompts:可复用的分析模板

Prompts 解决的是"我希望 AI 每次都用同一套姿势干某件事"。它返回的是一个 messages 数组(结构化消息),客户端会把它作为一次新对话的起点。我们写两个模板:一个把笔记总结成"要点/待办/术语",一个带来源标注做问答。

// ---------- Prompt:结构化总结一篇笔记 ----------
server.prompt(
  "summarize-note",
  "把一篇笔记总结为:三句话摘要 / 待办清单 / 术语表",
  { filename: z.string().describe("笔记文件名,如 release-checklist") },
  ({ filename }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `请阅读资源 notes://${filename}.md,然后输出:\n1. 三句话摘要\n2. 待办事项清单\n3. 文中出现的专业术语表`,
        },
      },
    ],
  })
);

// ---------- Prompt:带来源标注的问答 ----------
server.prompt(
  "qa-with-context",
  "基于若干笔记回答问题,每条结论后用 [文件名] 标注来源",
  {
    question: z.string().describe("要回答的问题"),
    notes: z.array(z.string()).describe("相关笔记文件名数组"),
  },
  ({ question, notes }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `依据以下笔记回答问题,每条结论后用 [文件名] 标注来源。\n\n相关笔记:\n${notes
            .map((n) => `- notes://${n}.md`)
            .join("\n")}\n\n问题:${question}`,
        },
      },
    ],
  })
);

设计取舍

六、启动入口

最后把服务器接上 stdio 传输:

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

npm run build,编译产物在 dist/index.js。完整工程到此就齐了——一个文件、三原语、可直接运行。

七、接入 Claude Code 并验证

7.1 注册服务器(两种等价方式)

方式 A:命令行注册

claude mcp add kb-server -e KB_DIR=$HOME/notes -- node "$(pwd)/dist/index.js"
claude mcp list          # 应能看到 kb-server

方式 B:项目级 .mcp.json(适合随仓库分发)

{
  "mcpServers": {
    "kb-server": {
      "command": "node",
      "args": ["./dist/index.js"],
      "env": { "KB_DIR": "/home/me/notes" }
    }
  }
}

-e KB_DIR=... 把环境变量注入子进程。如果你忘了设,服务器会回退到 ~/notes——这是 KB_DIR ?? path.join(HOME, "notes") 的兜底逻辑,避免直接崩。

7.2 在会话里验证

进入 Claude Code 会话后输入 /mcp,应看到 kb-server 已连接,并列出其工具/资源/提示。试着下指令:

搜一下我笔记里关于"回滚"的内容

模型会自主调用 search_notes 工具并返回片段。再试:

帮我新建一篇笔记,标题 oncall,正文写"夜间值班三件事",标签 ops

模型会调用 add_note,文件立刻出现在 ~/notes。

7.3 用 inspector 做协议级体检

npx @modelcontextprotocol/inspector node dist/index.js

在三个面板分别核对:Tools 能否调用成功、Resources 的 list 是否枚举出全部笔记、Prompts 选定后能否正确渲染出 messages。这是定位"到底是服务器没暴露,还是客户端没渲染"的最快手段。

八、常见问题速查

现象 原因 解决
Cannot find module .../mcp.js 模块解析配置错 moduleResolution: "NodeNext"
资源在客户端不可见 客户端未实现 Resources,或未调用 resources/list 先用 inspector 确认 list 回调有返回;再确认目标客户端支持
add_note 写到了 ~/notes 而非预期目录 KB_DIR 未注入子进程 .mcp.json 的 env 或 claude mcp add -e 显式设置
Prompt 输出像机器人 模板写得太死 只规定"分析什么/什么格式",不逐字规定措辞
动态资源读取 404 文件名带了 .md 后缀又被模板再拼 模板 URI 用 notes://{filename}.md 或回调里统一去后缀,二选一别重复

九、整体工作流

flowchart LR
    subgraph Client["MCP 客户端 (Claude Code / Inspector / Desktop)"]
        UI[用户输入]
    end

    UI -- "stdio (JSON-RPC)" --> Server

    subgraph Server["kb-server (Node + TS SDK)"]
        T[Tools<br/>search_notes · add_note]
        R[Resources<br/>notes://index · notes://{filename}]
        P[Prompts<br/>summarize-note · qa-with-context]
    end

    T --> FS[("~/notes/*.md")]
    R --> FS
    P -. "引用 URI" .-> R

    style T fill:#e1f5ff,stroke:#039
    style R fill:#e8f5e9,stroke:#2e7d32
    style P fill:#fff4e1,stroke:#ef6c00

三种原语用不同颜色区分:Tools(蓝)写、Resources(绿)读、Prompts(橙)指挥,三者最终都落在同一个文件系统知识库上,且 Prompts 通过 URI 指针复用 Resources——这正是"合一"的含义。

再看一条典型的三原语协同时序:当你问"发布检查清单能直接上线吗",理想链路是——客户端先用 qa-with-context 这个 Prompt 给出"带来源标注回答"的意图框架,框架里引用了 notes://release-checklist.md 这个 Resource,模型按需读取该资源拿到数据,必要时再调 search_notes 这个 Tool 补充检索其他相关笔记,最后给出每条结论都带 [文件名] 的结构化答案。

十、实战案例小结

我们用一个 kb-server 串起了 MCP 的全部三原语:

把它接入 Claude Code 后,你就拥有了一个真正"懂你的笔记"的本地知识助手——而且它的能力边界由协议三原语清晰界定,而不是一个什么都往里塞的臃肿工具集。

延伸阅读

训练营适用性评估

参考来源

  1. MCP Server Patterns: Tools vs Resources vs Prompts (When to Use Each) — DEV Community — 三原语"动作/数据/意图"心智模型与 TypeScript API 示例,本文代码骨架的主要依据。
  2. Understanding MCP features: Tools, Resources, Prompts, Sampling, Roots, and Elicitation — WorkOS — MCP 六大特性的系统讲解,含控制权归属与安全考量。
  3. Understanding MCP servers — Model Context Protocol 官方文档 — 服务器概念、原语定义与上下文感知 Prompt 的权威来源。
  4. Model Context Protocol TypeScript SDK — McpServer、ResourceTemplate、StdioServerTransport 的官方实现与用法。
  5. MCP Resources explained (and how they differ from MCP Tools) — Medium — 专论"只读数据为何应建模为 Resource 而非 Tool"。

[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。

51学通信知识星球海报-扫码加入