<!-- title: MCP资源原语：开放只读数据 -->

## 概述

绝大多数 MCP 教程都在教你怎么造「工具（Tools）」——让模型执行动作。但 MCP 体系里还有一个被严重低估的原语：**Resources（资源）**。它的定位是**只读上下文**：把文件、配置、数据库 schema、运行日志、文档片段等数据，用标准 URI 暴露给模型，让模型在你授权后"阅读"，而不是"调用"。

本教程聚焦**唯一一个面向"数据"的 MCP 原语**。学完你将掌握：Resources 与 Tools 的本质区别、`@mcp.resource` 装饰器与 URI 模板（RFC 6570）、静态资源与动态资源模板的写法、`resources/list` 与 `resources/read` 协议握手、列表变更与订阅通知，并在最后落地一个可运行的「项目知识库」MCP 服务器。适合已经写过 Tool、想补齐全套原语的开发者。

---

## 一、前置条件

| 项 | 要求 | 说明 |
|---|---|---|
| Python | 3.10+ | 资源函数支持类型注解即可 |
| FastMCP | `pip install fastmcp` | 官方维护的高层 SDK，资源 API 最简洁 |
| Claude Code | 已安装并跑通过至少一个 MCP | 本教程假设你用过 `/mcp` 命令 |
| MCP Inspector | `npx @modelcontextprotocol/inspector` | 用于离线验证资源是否正确暴露 |

你应当已经理解 MCP 的客户端/服务器架构，并写过至少一个 Tool。如果还没有，先补「首个 MCP 工具」再回来——本文不重复 Tool 的基础。

---

## 二、核心认知：Resources 到底解决什么问题

### 2.1 一句话区分三大原语

MCP 的服务端能力被拆成三类，对应模型与"世界"互动的三种姿势：

- **Tools（工具）**：模型**主动执行**的动作，有副作用——发邮件、写文件、调接口。
- **Resources（资源）**：模型/客户端**被动读取**的数据，无副作用——读一份配置、查一张表的结构。
- **Prompts（提示词）**：由**人或服务端发起**的、预置好的对话模板。

一个高频误区是把所有东西都做成 Tool。后果是：模型会"自作主张"地反复调用，浪费 token、放大幻觉风险，而且用户失去对"哪些数据进入了上下文"的控制权。Resources 的设计哲学是 **application-driven（由应用/用户驱动）**：哪些资源进入上下文，由宿主应用（如 Claude Code 的资源选择器、`@` 提及）决定，而不是模型自己拍板。这一条决定了它的安全语义——**只读、幂等、可审计**。

### 2.2 协议层面：能力声明与四类消息

服务端若支持资源，必须在初始化握手里声明 `resources` 能力：

```json
{
  "capabilities": {
    "resources": {
      "subscribe": true,
      "listChanged": true
    }
  }
}
```

两个子能力都是可选的：

- `subscribe`：客户端能否订阅某个资源，在其变更时收到通知；
- `listChanged`：资源清单变化时，服务端是否会主动广播。

围绕这两个开关，协议定义了一组消息：

| 方法 | 方向 | 作用 |
|---|---|---|
| `resources/list` | 客户端→服务端 | 列出所有静态资源（支持分页 `cursor`） |
| `resources/read` | 客户端→服务端 | 按 URI 读取资源内容 |
| `resources/templates/list` | 客户端→服务端 | 列出动态资源模板 |
| `resources/subscribe` | 客户端→服务端 | 订阅某 URI 的变更 |
| `notifications/resources/updated` | 服务端→客户端 | 某资源内容变了 |
| `notifications/resources/list_changed` | 服务端→客户端 | 资源清单变了，请重新 `list` |

读懂这张表，你就掌握了资源的全部交互面。下面把它落到代码里。

---

## 三、动手：第一个静态资源

### 3.1 最小可运行示例

新建 `kb_server.py`：

```python
import json
from fastmcp import FastMCP

mcp = FastMCP(name="ProjectKB")

# 静态资源：固定 URI，内容在读取时求值（懒加载）
@mcp.resource("kb://manifest")
def get_manifest() -> str:
    """项目清单：名称、版本、模块入口，供模型理解项目骨架。"""
    return json.dumps({
        "name": "order-service",
        "version": "2.4.1",
        "modules": ["api", "worker", "scheduler"],
        "entry": "api/main.py"
    }, ensure_ascii=False, indent=2)
```

启动方式有两种，二选一：

```bash
# 方式 A：FastMCP CLI（开发期最省事）
fastmcp dev kb_server.py

# 方式 B：直接以 stdio 模式跑（Claude Code 接入用）
fastmcp run kb_server.py
```

### 3.2 预期结果

用 MCP Inspector 连上后，切到 **Resources** 标签页，你会看到一个 URI 为 `kb://manifest` 的条目。点击它，右侧返回：

```json
{
  "contents": [
    {
      "uri": "kb://manifest",
      "mimeType": "text/plain",
      "text": "{\n  \"name\": \"order-service\", ... }"
    }
  ]
}
```

对应的底层 JSON-RPC 就是协议里的 `resources/read`：

```json
{ "jsonrpc": "2.0", "id": 2, "method": "resources/read",
  "params": { "uri": "kb://manifest" } }
```

### 3.3 常见问题

- **看不到 Resources 标签？** 说明能力声明没生效。确认 FastMCP 版本 ≥ 2.x；老版本把资源叫法不同。可在 Inspector 的 Server Capabilities 面板确认 `resources: true`。
- **返回的 `text` 是 Python 对象的 `repr`？** 资源函数必须返回 `str`/`bytes`/`ResourceResult` 三者之一。返回 dict 不会自动序列化——务必 `json.dumps`。
- **中文乱码？** `json.dumps` 加 `ensure_ascii=False`；二进制资源（如图片）返回 `bytes`，FastMCP 会自动 base64 编码为 `BlobResourceContents`。

---

## 四、动态资源模板：参数化 URI

静态资源适合「全局唯一」的数据（清单、版本）。真实世界更多是**按参数取**的数据：某一天的报告、某个模块的文档、某个指标的当前值。这时用**资源模板（Resource Template）**，本质是带占位符的 URI 模板（遵循 RFC 6570）。

```python
from pathlib import Path
import json

NOTES_DIR = Path("notes")  # 存放 markdown 文档的目录
METRICS = {                                # 模拟一个"指标数据库"
    "qps": {"value": 1280, "unit": "req/s"},
    "p99": {"value": 42, "unit": "ms"},
    "error_rate": {"value": 0.03, "unit": "%"},
}

@mcp.resource("kb://guide/{topic}")
def get_guide(topic: str) -> str:
    """按主题读取一篇指南（notes/<topic>.md）。"""
    # 安全校验：topic 只允许字母数字与短横线，杜绝路径穿越
    if not topic.replace("-", "").isalnum():
        raise ValueError(f"非法主题名: {topic}")
    path = (NOTES_DIR / f"{topic}.md").resolve()
    if not str(path).startswith(str(NOTES_DIR.resolve())):
        raise ValueError("禁止访问 notes 目录之外的文件")
    if not path.exists():
        raise FileNotFoundError(f"未找到主题: {topic}")
    return path.read_text(encoding="utf-8")

@mcp.resource("kb://metric/{name}")
def get_metric(name: str) -> str:
    """按名称读取一个运行指标。"""
    if name not in METRICS:
        raise KeyError(f"未知指标: {name}")
    return json.dumps({"name": name, **METRICS[name]}, ensure_ascii=False)
```

关键点：URI 里的 `{topic}`、`{name}` 与函数参数名**必须同名**，FastMCP 会把模板参数自动注入。模板注册后，客户端通过 `resources/templates/list` 拿到 `uriTemplate: "kb://guide/{topic}"`，然后用 `kb://guide/deploy` 这样的具体 URI 去 `read`。

### 4.1 预期结果

Inspector 的 **Resource Templates** 标签会列出两个模板。在地址栏输入 `kb://metric/qps`，得到：

```json
{ "name": "qps", "value": 1280, "unit": "req/s" }
```

输入 `kb://guide/deploy`（前提是 `notes/deploy.md` 存在）则返回该文档全文。

### 4.2 常见问题

- **模板参数需要多段路径怎么办？** 单段 `{topic}` 不能匹配含 `/` 的值。FastMCP 提供**通配参数**（RFC 6570 的保留展开 `{+path}`）来捕获剩余路径。若版本不支持，退而求其次：把多段路径编码成单段（如用 `__` 代替 `/`），或干脆拆成多个具名参数 `kb://guide/{section}/{slug}`。
- **`*args` 报错？** 资源模板不支持 `*args`，但支持 `**kwargs`——因为 URI 模板本身就定义了具名参数集。
- **404 和 400 怎么传给客户端？** 抛异常即可。FastMCP 会把它转成标准的 JSON-RPC error，模型据此判断"该资源不存在"而不是反复重试。

---

## 五、注解：把语义告诉客户端

资源可以挂**注解（Annotations）**，提示客户端如何呈现、如何缓存。默认情况下，资源的 `readOnlyHint` 和 `idempotentHint` 都是 `true`——这正反映了"只读幂等"的核心承诺。显式声明能让客户端做更好的 UI 与访问优化：

```python
@mcp.resource(
    "kb://changelog",
    name="变更日志",
    description="最近一次发布的功能与修复清单。",
    mime_type="text/markdown",
    annotations={"readOnlyHint": True, "idempotentHint": True},
)
def get_changelog() -> str:
    return Path("CHANGELOG.md").read_text(encoding="utf-8")
```

| 注解 | 默认 | 含义 |
|---|---|---|
| `readOnlyHint` | `true` | 只提供数据，不修改环境 |
| `idempotentHint` | `true` | 多次读取等价于一次 |
| `meta` | 空 | 透传给客户端的自定义元数据，可用于版本号、归属等 |

注意：注解是**建议性的**，不会在协议层强制执行。它的价值在于让 Claude Code 这类客户端把只读资源和有副作用的工具分开渲染、分别授权。

---

## 六、列表变更与订阅：让数据"活"起来

很多教程止步于静态读取。但真实场景里数据会变——指标在更新、文档被改写。两个通知机制让资源具备时效性。

### 6.1 listChanged：清单变了就喊一声

FastMCP 在你运行时增删资源时，会自动广播 `notifications/resources/list_changed`，无需手写。运行期动态注册一个资源，客户端会自动刷新清单：

```python
@mcp.tool()
def publish_runbook(name: str, content: str) -> str:
    """把一段手册发布成资源（带副作用的动作，所以用 Tool）。"""
    # 注意：这是 Tool，不是 Resource——它改变了世界
    mcp.add_resource_fn(lambda: content, uri=f"kb://runbook/{name}",
                        name=name)
    return f"已发布 {name}"
```

### 6.2 subscribe：盯住某个资源

声明 `subscribe` 能力后，客户端可订阅特定 URI，服务端在内容变化时推送 `notifications/resources/updated`：

```python
from fastmcp import Context

@mcp.resource("kb://live-qps")
async def live_qps(ctx: Context) -> str:
    """实时 QPS（演示订阅语义）。"""
    return json.dumps({"qps": await fetch_qps()})
```

> **设计提示**：把"读当前值"做成 Resource（`kb://live-qps`），把"发布一个值"做成 Tool。这是 Resources/Tools 边界最清晰的实战体现——**读用资源，写用工具**。

---

## 七、实战案例：项目知识库 MCP 服务器

把前面的零件组装成一个完整服务。目录结构：

```
project-kb/
├── kb_server.py          # MCP 服务器
├── notes/
│   ├── deploy.md         # 部署指南
│   └── rollback.md       # 回滚手册
├── metrics.json          # 指标数据源
├── CHANGELOG.md
└── .mcp.json             # Claude Code 接入配置
```

`kb_server.py` 完整版：

```python
import json
from pathlib import Path
from fastmcp import FastMCP

mcp = FastMCP(name="ProjectKB")
ROOT = Path(__file__).parent

# ① 静态资源：项目骨架
@mcp.resource("kb://manifest")
def manifest() -> str:
    return json.dumps({
        "name": "order-service", "version": "2.4.1",
        "modules": ["api", "worker", "scheduler"],
    }, ensure_ascii=False)

# ② 静态资源：变更日志（markdown）
@mcp.resource("kb://changelog",
              mime_type="text/markdown",
              description="最近发布的功能与修复。")
def changelog() -> str:
    return (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")

# ③ 动态模板：按主题读文档（带路径穿越防护）
@mcp.resource("kb://guide/{topic}")
def guide(topic: str) -> str:
    if not topic.replace("-", "").isalnum():
        raise ValueError("非法主题名")
    p = (ROOT / "notes" / f"{topic}.md").resolve()
    if not str(p).startswith(str((ROOT / "notes").resolve())):
        raise ValueError("越界访问")
    return p.read_text(encoding="utf-8")

# ④ 动态模板：按名读指标
@mcp.resource("kb://metric/{name}")
def metric(name: str) -> str:
    data = json.loads((ROOT / "metrics.json").read_text(encoding="utf-8"))
    if name not in data:
        raise KeyError(f"未知指标: {name}")
    return json.dumps({"name": name, **data[name]}, ensure_ascii=False)

if __name__ == "__main__":
    mcp.run()  # 默认 stdio 传输
```

`.mcp.json`（放在项目根，Claude Code 启动时自动加载）：

```json
{
  "mcpServers": {
    "project-kb": {
      "command": "fastmcp",
      "args": ["run", "kb_server.py"]
    }
  }
}
```

在项目目录里启动 Claude Code，运行 `/mcp` 应看到 `project-kb` 已连接。然后让模型读数据——**资源由你（用户）显式喂入，而非模型自己调**：

> 提示词示例：`@kb://manifest 结合 @kb://guide/deploy，说明本次部署要改哪些模块的配置。`

Claude Code 会把这两个资源的内容作为上下文读进来，再基于它们作答。若想确认资源是否真被读到，可用 `/context` 查看当前上下文里的资源片段。

### 验证清单

- [ ] `/mcp` 显示 `project-kb: connected`，且能力含 `resources`
- [ ] Inspector 里 `resources/list` 返回 2 个静态资源、`resources/templates/list` 返回 2 个模板
- [ ] `@kb://metric/qps` 能把指标读进上下文
- [ ] 故意请求 `kb://guide/../../etc/passwd` 被拒绝（路径穿越防护生效）

---

## 八、工作流程图

资源从声明到被消费的完整链路：

```mermaid
sequenceDiagram
    participant CC as Claude Code（客户端）
    participant SRV as MCP 服务器

    Note over CC,SRV: 初始化握手
    CC->>SRV: initialize（声明期望）
    SRV-->>CC: capabilities.resources = {subscribe, listChanged}

    Note over CC,SRV: ① 发现
    CC->>SRV: resources/list（cursor 分页）
    SRV-->>CC: 静态资源清单
    CC->>SRV: resources/templates/list
    SRV-->>CC: 动态模板清单 kb://guide/{topic}

    Note over CC,SRV: ② 用户用 @ 显式选择
    CC->>SRV: resources/read（uri=kb://guide/deploy）
    SRV->>SRV: 校验 topic → 读 notes/deploy.md
    SRV-->>CC: TextResourceContents

    Note over CC,SRV: ③ 变更通知
    SRV-->>CC: notifications/resources/list_changed
    CC->>SRV: resources/list（重新拉取）
```

何时选 Resource、何时选 Tool，用下面这张决策图收口：

```text
flowchart TD
    A[要给模型的东西] --> B{是动作还是数据?}
    B-- 动作/有副作用 --> T[Tool 工具]
    B-- 数据/只读 --> C{由谁决定读不读?}
    C-- 模型自主调用 --> T
    C-- 用户/应用显式喂入 --> R[Resource 资源]
    R --> D{数据是唯一的还是按参数取?}
    D-- 唯一 --> R1[静态资源 kb://manifest]
    D-- 按参数 --> R2[资源模板 kb://guide/{topic}]
```

---

## 九、安全与最佳实践

1. **路径穿越是头号风险**。任何把 URI 参数映射到文件系统的资源模板，都必须做 `resolve()` + 前缀校验，并拒绝 `..` 与绝对路径。上面的 `guide()` 就是模板写法。
2. **尊重只读语义**。不要在资源函数里做写操作。要写，就开一个 Tool——语义干净，授权也清晰。
3. **控制体量**。单次 `read` 别返回超长内容；超大文件应按段切片成多个 URI（如 `kb://log/2024-06-24/p1`）。
4. **二进制用 `bytes`**。返回 `bytes` 会被 base64 成 `BlobResourceContents`，记得显式给 `mime_type`（如 `image/png`）。
5. **幂等优先**。指标类资源即使每次值不同，读取行为本身也不应有副作用——这正是 `idempotentHint` 的本意。

---

## 十、延伸阅读

- MCP 官方规范——Resources（2025-06-18）：`modelcontextprotocol.io/specification/2025-06-18/server/resources`，权威协议定义与 JSON-RPC 细节。
- FastMCP 官方文档——Resources & Templates：`gofastmcp.com/servers/resources`，`@mcp.resource` 装饰器与模板参数的完整参考。
- RFC 6570——URI 模板：`datatracker.ietf.org/doc/html/rfc6570`，理解 `{name}` 与 `{+path}` 展开规则。
- MCP C# SDK 资源实现：`csharp.sdk.modelcontextprotocol.io/concepts/resources`，跨语言对照资源原语写法。

---

## 十一、训练营适用性评估

| 维度 | 评估 |
|---|---|
| 适合训练营 | MCP 进阶营、企业 AI 工程营、内部知识库自动化营 |
| 学员基础 | 已能独立写出至少一个 MCP Tool |
| 教学时长 | 讲授 45 分钟 + 动手 60 分钟 + 联调 30 分钟，合计约 2.5 小时 |
| 验收标准 | 学员独立交付一个含 1 个静态资源 + 2 个资源模板的 KB 服务器，并通过 Inspector 与 Claude Code `@` 引用双重验证 |
| 延伸作业 | 给资源加上 `subscribe` 通知；把指标数据源从 JSON 换成真实数据库只读视图 |

---

## 参考来源

1. [Resources — Model Context Protocol Specification 2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18/server/resources) — 官方资源原语规范，含能力声明、四类协议消息与消息流时序图。
2. [Resources & Templates — FastMCP 官方文档](https://gofastmcp.com/servers/resources) — `@mcp.resource` 装饰器、URI 模板参数、返回类型与注解的权威 API 参考。
3. [MCP Resource Templates Deep Dive — ChatForest](https://chatforest.com/guides/mcp-resource-templates-deep-dive/) — 静态资源与参数化模板的对比实战，覆盖 RFC 6570 与通配参数。
4. [RFC 6570: URI Template](https://datatracker.ietf.org/doc/html/rfc6570) — URI 模板标准，`{name}` 与 `{+path}` 保留展开的语法定义。
5. [Resources — MCP C# SDK](https://csharp.sdk.modelcontextprotocol.io/concepts/resources) — 跨语言实现对照，用于验证资源原语在不同 SDK 中的一致性。

> [!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学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
