<!-- title: 造可视化MCP：模型直接看图表 -->

## 概述

本教程教你从零构建一个能返回**图像**的 MCP（Model Context Protocol）服务器，让 Claude Code 不仅能"算出数据"，还能"看到渲染好的图表"。绝大多数 MCP 教程只演示返回文本或 JSON，但 MCP 工具结果支持 `image` 内容块——把 base64 编码的 PNG 直接喂给多模态模型。本教程适合已跑通过第一个 MCP 服务器、想掌握**非文本返回**与本地调试、注册全流程的中级开发者。

## 前置条件

- Claude Code 已安装并能正常对话（`claude --version` 可用）
- Python 3.10+ 与 `uv`（推荐）或 `pip`
- 了解 MCP 三原语（Tools / Resources / Prompts）的基本概念
- 安装 Node.js（仅在使用 MCP Inspector 调试时需要）

## 第一步：理解"图像内容块"为什么是新鲜事

MCP 工具调用的返回值不是一个字符串，而是一个**内容块数组**（content blocks）。规范定义了三种核心块类型：

| 类型 | 字段 | 作用 |
|------|------|------|
| `text` | `text` | 普通文本，最常见 |
| `image` | `data`（base64）、`mimeType` | 让模型"看见"一张图 |
| `resource` | `resource`（含 uri） | 引用一段资源（如文件、数据库行） |

关键在于：当工具返回 `image` 块时，Claude 会把它当作真实图像来"看"，可以做视觉推理（"柱状图里 B 柱最高"、"散点呈正相关"）。这一能力在纯文本 MCP 里完全缺失。我们要造的 `chart-mcp` 正是利用它：接收数据 → matplotlib 渲染 → base64 → 以 `image` 块返回。

**预期结果**：你能在脑中画出"数据进来 → 图像出去"的数据流，并清楚 `data` 必须是 base64、`mimeType` 必须填写（如 `image/png`）。

**常见问题**：有人把图像 base64 拼进 `text` 块返回——模型会把这一大坨字符当文本，既费 token 又"看不见"。记住：图像必须走 `image` 块。

## 第二步：初始化项目与依赖

新建目录 `chart-mcp`，准备两个文件。

`requirements.txt`：

```text
fastmcp>=2.3.0
matplotlib>=3.8
```

用 `uv` 创建隔离环境（推荐）：

```bash
mkdir chart-mcp && cd chart-mcp
uv venv
uv pip install -r requirements.txt
```

或用 pip：

```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
```

**预期结果**：`uv pip list` 能看到 `fastmcp` 与 `matplotlib`。`matplotlib` 是重量级依赖，安装可能需要几十秒。

**常见问题**：
- 安装报 `externally-managed-environment`：改用 `uv` 或加 `--break-system-packages`（不推荐污染系统环境）。
- 网络慢导致 matplotlib 下载失败：换国内 PyPI 镜像。

## 第三步：编写服务器——把图表变成图像块

创建 `server.py`：

```python
import base64
import io
import logging

# 必须在 import pyplot 之前设置无头后端，否则在 stdio 进程里会报缺 display
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

from fastmcp import FastMCP
from mcp.types import TextContent, ImageContent

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("chart-mcp")

mcp = FastMCP("chart-mcp")


def _render(fig) -> str:
    """把 matplotlib Figure 渲染成 base64 PNG 字符串。"""
    buf = io.BytesIO()
    # dpi 控制清晰度，也直接决定 token 消耗；110 是清晰与体积的平衡点
    fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
    plt.close(fig)
    return base64.b64encode(buf.getvalue()).decode("ascii")


@mcp.tool()
def bar_chart(categories: list[str], values: list[float], title: str = "Bar Chart") -> list:
    """Render a bar chart from categories and values, returned as a viewable image."""
    if len(categories) != len(values):
        raise ValueError("categories 与 values 长度必须一致")
    fig, ax = plt.subplots(figsize=(6, 3.5))
    ax.bar(categories, values, color="#4C78A8")
    ax.set_title(title)
    ax.set_ylabel("value")
    b64 = _render(fig)
    logger.info("rendered bar_chart: %d bars", len(categories))
    return [
        TextContent(type="text", text=f"已生成柱状图，共 {len(categories)} 个类别。"),
        ImageContent(type="image", data=b64, mimeType="image/png"),
    ]


@mcp.tool()
def line_chart(x: list[float], y: list[float], title: str = "Line Chart") -> list:
    """Render a line chart, returned as a viewable image."""
    fig, ax = plt.subplots(figsize=(6, 3.5))
    ax.plot(x, y, marker="o", color="#F58518")
    ax.set_title(title)
    ax.grid(alpha=0.3)
    b64 = _render(fig)
    return [
        TextContent(type="text", text=f"折线图已生成，{len(x)} 个数据点。"),
        ImageContent(type="image", data=b64, mimeType="image/png"),
    ]


if __name__ == "__main__":
    mcp.run()
```

几个**必须注意**的细节：

1. **`matplotlib.use("Agg")` 要在 `import pyplot` 之前**——stdio 服务器没有显示设备，否则会报 `no display name`。
2. **不要 `print()` 到 stdout**——stdio 通道走的是 JSON-RPC，任何额外的 stdout 文本都会破坏协议。要用 `logging`（默认输出到 stderr）。
3. **`return` 一个内容块列表**：`TextContent` + `ImageContent` 混排是合规且推荐的，文字给上下文、图像给视觉。
4. **FastMCP 的 `Image` 快捷方式**：也可以 `from fastmcp.utilities.types import Image`，然后 `return Image(data=png_bytes, format="png")`，框架自动转成 `ImageContent`。但混排文字时用上面的列表写法更直观。

**预期结果**：`python server.py` 启动后会**阻塞等待**（监听 stdio），没有任何输出到终端，但 stderr 会出现（如果开了 logging）。这是正常状态。

**常见问题**：
- 中文标题显示成"豆腐块"方块：服务器所在机器缺中文字体。加上：
  ```python
  plt.rcParams["font.sans-serif"] = ["Noto Sans CJK SC", "Arial Unicode MS", "DejaVu Sans"]
  plt.rcParams["axes.unicode_minus"] = False
  ```
- 工具签名里的 `list[str]` 在旧 Python 上报错：要求 3.9+（3.10+ 更稳）。

## 第四步：用 MCP Inspector 本地调试

在把它接进 Claude Code 之前，先用 Inspector 单独验证图像块格式正确。FastMCP 自带开发模式：

```bash
fastmcp dev server.py
```

或用官方 Inspector（基于 Node）：

```bash
npx @modelcontextprotocol/inspector python server.py
```

打开浏览器中的 Inspector 面板 → `Tools` → 选 `bar_chart` → 填入 `categories=["A","B","C"]`、`values=[30,50,20]` → 点 `Run Tool`。

**预期结果**：返回 JSON 里能看到两个内容块，`image` 块的 `data` 是一大段 base64，`mimeType` 为 `image/png`。Inspector 通常会直接把图像渲染出来预览——如果图正常显示，说明图像块格式正确。

**常见问题**：
- Inspector 里图像不显示但 JSON 有数据：多半是 `mimeType` 拼错或漏写。
- 工具列表为空：确认函数用了 `@mcp.tool()` 装饰器，且类型注解齐全（FastMCP 靠注解生成 JSON Schema）。

## 第五步：注册到 Claude Code

调试通过后，把服务器注册进 Claude Code。进入任意工作目录执行：

```bash
claude mcp add chart-mcp -- uv run --directory /绝对路径/chart-mcp server.py
```

要点：
- `--` 是**分隔符**，其后才是真正启动服务器的命令与参数。
- 推荐用 `uv run`，它会自动用项目虚拟环境；如果用裸 `python`，要写绝对路径：`-- python /绝对路径/chart-mcp/server.py`。
- 作用域（scope）默认是 `local`（仅当前项目、当前用户）。若要团队共享，加 `--scope project`，配置会写进项目根的 `.mcp.json`，可提交到 git；若要全机器所有项目可用，加 `--scope user`，写入 `~/.claude.json`。

验证注册：

```bash
claude mcp list          # 列出所有服务器
claude mcp get chart-mcp # 查看某个服务器的详细配置
```

**预期结果**：`claude mcp list` 显示 `chart-mcp ✓ connected`。

**常见问题**：
- 状态是 `✗ failed`：八成是启动命令路径不对或依赖没装在 `uv run` 能找到的环境里。先在终端单独跑 `uv run --directory /path server.py`，确认能启动。
- 远程团队拉取 `.mcp.json` 后连不上：依赖未安装、字体缺失或 Python 版本不一致。在 `.mcp.json` 同级补一份 `requirements.txt` 并写好 README。

## 第六步：在对话中调用并验证

启动 Claude Code：

```bash
claude
```

进入对话后，先输入 `/mcp`，应该能看到 `chart-mcp` 及其两个工具 `bar_chart`、`line_chart`。然后直接用自然语言提问：

> 帮我画个柱状图：北京 32、上海 45、广州 28、深圳 38，标题"一线城市气温"。

**预期结果**：Claude 自动调用 `bar_chart`，传入对应参数，随后终端里会渲染出图像，模型还会基于"看到"的图做描述（"上海柱子最高，约 45"）。这正印证了图像块被真正送进了多模态推理。

**常见问题**：
- 模型调用了工具但"没看图"就开始编数据：确认返回的是 `image` 块而非把 base64 塞进 `text`。
- 上下文突然变得很贵：单张大 dpi 图的 base64 体积可观。对策是控制 `figsize`、`dpi`（80–110 足够），或在工具里先缩放再编码。

## 实战案例：把 chart-mcp 接进数据分析闭环

下面用一个最小闭环演示它的实战价值——把"算数据"和"看数据"合在一处。

在 Claude Code 里说：

> 我有一份 `sales.csv`（字段：month, revenue）。先读它，算出每月同比变化，再用 chart-mcp 画一条 revenue 折线图，最后根据图告诉我增长趋势是否在加速。

模型会：① 读 CSV → ② 计算 → ③ 调 `line_chart` 出图 → ④ 基于**图像**判断斜率变化。整个过程你只说了一句话，模型却在"看图说话"，而不是仅凭表格数字猜测。

你也可以扩展成多工具服务站：加 `scatter`、`histogram`、`pie`，再补一个 `Resources` 原语暴露预设配色主题、一个 `Prompts` 原语给出"如何选图"的建议模板。一个文件、几十行代码，就构成了完整的"可视化即工具"能力。

## 工作流程与架构图

整体数据流与调用链如下：

```mermaid
flowchart LR
    U[用户自然语言] --> CC[Claude Code]
    CC -->|选择工具| MC[MCP 客户端]
    MC -->|stdio JSON-RPC| SRV[chart-mcp 进程]
    SRV -->|调用工具函数| MPL[matplotlib 渲染]
    MPL --> B64[base64 编码]
    B64 -->|image 内容块| MC
    MC -->|多模态输入| CC
    CC -->|看图回答| U
```

一次完整工具调用的时序：

```mermaid
sequenceDiagram
    participant U as 用户
    participant CC as Claude Code
    participant S as chart-mcp
    participant M as matplotlib
    U->>CC: 画柱状图 A=30 B=50
    CC->>S: tools/call bar_chart
    S->>M: plt.bar + savefig
    M-->>S: PNG bytes
    S->>S: base64 编码
    S-->>CC: [TextContent, ImageContent]
    CC->>CC: 视觉理解图像
    CC-->>U: 输出图 + 文字解读
```

## 延伸阅读

- **MCP 规范·内容块类型**：`modelcontextprotocol.io` 官方对 `text` / `image` / `resource` 块的权威定义
- **FastMCP 文档与 DeepWiki**：`deepwiki.com/jlowin/fastmcp`，重点看 `Type Utilities and Content Conversion`，理解 `Image` 助手与返回值转换规则
- **matplotlib-mcp 参考**：`deepwiki.com/muse-mesh/mume-mcp-servers` 的 matplotlib 服务端实现，可作为多图型扩展的范本
- **Claude Code MCP 配置文档**：`code.claude.com/docs/en/mcp`，覆盖 scope、`claude mcp add` 全部参数与 `.mcp.json` 结构
- **Streamable HTTP 传输**：把本服务器的 `mcp.run()` 改为 `mcp.run(transport="http")` 即可升级为可远程访问的 HTTP MCP，配合 OAuth 实现团队共享

## 训练营适用性评估

- **适配类型**：MCP 开发进阶训练营、多模态 Agent 实战营、数据可视化自动化训练营
- **难度**：中级（需先掌握任一语言的入门级 MCP 服务器）
- **教学时长**：约 3–4 小时（含环境搭建 0.5h、编码 1.5h、调试与注册 1h、扩展练习 0.5–1h）
- **可拓展练习**：① 增加 `scatter`/`histogram` 工具；② 用 `Resources` 暴露配色主题；③ 加 `Prompts` 给"选图建议"；④ 改造为 Streamable HTTP 传输并加 OAuth；⑤ 接 Seaborn/Plotly 输出更精美图表

## 参考来源

1. [Connect to MCP servers — Claude Code Docs](https://code.claude.com/docs/en/mcp-quickstart) — `claude mcp add` 命令、scope 与配置文件官方说明
2. [Content Types and Responses — mcp-server-python template (DeepWiki)](https://deepwiki.com/pathintegral-institute/mcp-server-python-template/3.3-content-types-and-responses) — `TextContent`/`ImageContent` 内容块结构与返回值约定
3. [Type Utilities and Content Conversion — jlowin/fastmcp (DeepWiki)](https://deepwiki.com/jlowin/fastmcp/2.2.4-type-utilities-and-content-conversion) — FastMCP `Image` 助手与返回值自动转换为内容块的机制
4. [matplotlib-mcp Server — mume-mcp-servers (DeepWiki)](https://deepwiki.com/muse-mesh/mume-mcp-servers/5-matplotlib-mcp-server) — 基于 FastMCP 的 matplotlib 服务端参考实现
5. [Tool results Image content supports url — modelcontextprotocol Discussions #794](https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/794) — 图像内容块 base64 与 url 两种形式的社区讨论与规范演进

> [!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)
