<!-- title: 造可视化MCP：数据秒变图表 -->

# 造可视化MCP：让 Claude Code 把数据秒变 ECharts 图表

## 概述

本教程带你用 **Model Context Protocol（MCP）** 的 TypeScript SDK，从零构建一个「图表 MCP 服务器」：Claude Code 只要把一串数据交给它，它就能吐出一张可交互的 ECharts 图表。学完你将掌握 MCP 工具（Tool）的完整开发闭环——项目骨架、Zod 输入校验、stdio 传输、错误处理、Inspector 调试、以及接入 Claude Code 实际调用。适合已会基本 TypeScript、想把 Claude Code 能力从「能聊天」升级到「能干活」的开发者。

---

## 一、先搞懂一件事：MCP 到底解决了什么

很多新手会问：我直接让 Claude 写一段 ECharts 代码不就行了，为什么要折腾一个 MCP 服务器？

区别在于**可靠性边界**。让模型每次现场生成 HTML，会面临三个不稳定因素：ECharts 的 option 字段名偶尔写错、CDN 地址版本飘移、数据序列化时引号转义出错。而把「生成图表」封装成一个**确定性工具**——输入固定、输出固定、逻辑用代码写死——模型只负责决定「调用哪个工具、传什么参数」，脏活累活由代码兜底。这正是 MCP 的设计哲学：**把「提供上下文/执行动作」这件事，从 LLM 的概率世界里剥离出来，交给确定性的服务端。**

MCP 在 2026 年已经从「Anthropic 的实验」长成了开放标准，TypeScript SDK 每月下载量过千万，公共服务器过万。本文使用的官方 SDK 信息如下（截至 2026 年 7 月）：

- **v2（beta）**：包已拆分为 `@modelcontextprotocol/server` 与 `@modelcontextprotocol/client`，实现 2026-07-28 规范候选版，预计 7 月 28 日转正。
- **v1.x（`@modelcontextprotocol/sdk`）**：仍是**生产推荐版**，v2 转正后还会持续接收 bug 修复与安全更新至少 6 个月。

本教程统一采用 **v2 API**（`registerTool` + `serveStdio`），并在文末给出 v1 的等价写法，方便你在两条路径间迁移。

---

## 二、前置条件

| 项目 | 要求 |
|------|------|
| Node.js | **20 或更高版本**（SDK 仅发布 ESM 包，老版本 Node 会报 import 错误） |
| 运行时 | 任选：Node.js / Bun / Deno |
| Claude Code | 已安装并能正常启动 |
| 浏览器 | 用于打开生成的 HTML 图表 |
| 基础 | 会读 TypeScript、了解 `async/await` |

执行 `node -v` 确认版本 ≥ 20。若版本过低，用 nvm 升级：`nvm install 20 && nvm use 20`。

---

## 三、第一步：搭项目骨架

MCP 服务器本质上就是一个 Node 程序，骨架和普通脚手架没区别，唯一重点是 `type=module`。

```bash
mkdir chart-mcp && cd chart-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod tsx
mkdir src
```

**三条命令逐条解释：**

- `npm pkg set type=module`：在 `package.json` 写入 `"type": "module"`。v2 SDK 只发布 ES 模块，缺这一行会报 `ERR_REQUIRE_ESM`。
- `npm install @modelcontextprotocol/server zod tsx`：装三样东西——SDK 主体、Zod v4（用于声明工具入参 schema）、`tsx`（直接跑 `.ts`，免去编译步骤）。
- `mkdir src`：源码目录。

> **预期结果**：`package.json` 出现 `"type": "module"`，`node_modules` 下能找到 `@modelcontextprotocol/server`。

**常见问题**

- *报错 `Cannot find package '@modelcontextprotocol/server'`*：你装成了 v1 的 `@modelcontextprotocol/sdk`。v2 拆包后，服务端能力在 `/server`。重装即可。
- *装包特别慢*：换国内镜像 `npm config set registry https://registry.npmmirror.com`。

---

## 四、第二步：注册第一个工具（Tool）

工具是 MCP 最核心的原语。一个工具 = 一个名字 + 一份入参 schema + 一个异步处理函数。创建 `src/index.ts`：

```ts
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
import { writeFile, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';

const server = new McpServer({ name: 'charts', version: '1.0.0' });

server.registerTool(
  'render_chart',
  {
    description:
      '根据数据生成一张可交互的 ECharts 图表，保存为独立 HTML 文件。返回文件绝对路径。',
    inputSchema: z.object({
      title: z.string().default('Chart').describe('图表标题'),
      type: z.enum(['bar', 'line', 'pie']).describe('图表类型：柱状/折线/饼图'),
      labels: z.array(z.string()).min(1).describe('分类标签数组'),
      values: z.array(z.number()).min(1).describe('与 labels 一一对应的数值'),
      outputPath: z.string().optional().describe('自定义输出路径（.html）；不填则写临时目录'),
    }),
  },
  async ({ title, type, labels, values, outputPath }) => {
    // 1. 业务校验：标签数与数值数必须一致
    if (labels.length !== values.length) {
      return {
        isError: true,
        content: [{
          type: 'text',
          text: `labels 与 values 数量不一致：${labels.length} vs ${values.length}`,
        }],
      };
    }
    // 2. 拼 ECharts option
    const option = buildOption(type, title, labels, values);
    // 3. 渲染独立 HTML
    const html = renderHtml(option);
    // 4. 落盘
    const absPath = outputPath ?? join(tmpdir(), `chart-${randomBytes(4).toString('hex')}.html`);
    await mkdir(join(absPath, '..'), { recursive: true });
    await writeFile(absPath, html, 'utf8');
    // 5. 回传结果
    return {
      content: [{
        type: 'text',
        text: `✅ 图表已生成：${absPath}\n类型：${type}｜数据点：${labels.length}\n在浏览器打开即可查看交互式图表。`,
      }],
    };
  }
);
```

**这段代码的关键认知点：**

1. **`inputSchema` 是你唯一要写的 schema。** 它是 Zod 对象。SDK 会自动把它转成模型能看懂的 JSON Schema，并在调用你的 handler **之前**完成参数校验。也就是说，模型如果把 `type` 传成了 `"scatter"`，SDK 会直接挡掉，你的 handler 根本不会执行。
2. **handler 返回 `content`，一个类型化块数组。** 这里用 `text` 块；如果你返回的是图片，可以用 `{ type:'image', data: base64, mimeType:'image/png' }`。
3. **`isError: true` 不是抛异常，而是「告诉模型这次失败了」。** 模型能读到失败原因并决定是否重试。这比抛 `throw new Error()` 优雅得多——抛异常会让连接进入异常状态。

`buildOption` 与 `renderHtml` 两个纯函数放到下一步实现。先在文件末尾接上启动逻辑：

```ts
function buildOption(type, title, labels, values) {
  if (type === 'pie') {
    return {
      title: { text: title, left: 'center' },
      tooltip: {},
      legend: { bottom: 0 },
      series: [{
        type: 'pie',
        radius: '60%',
        data: labels.map((l, i) => ({ name: l, value: values[i] })),
      }],
    };
  }
  return {
    title: { text: title, left: 'center' },
    tooltip: { trigger: 'axis' },
    xAxis: { type: 'category', data: labels },
    yAxis: { type: 'value' },
    series: [{ type, data: values, smooth: type === 'line' }],
  };
}

void serveStdio(() => server);
console.error('charts MCP server running on stdio');
```

> **预期结果**：文件能通过类型检查（`npx tsx --eval "import './src/index.ts'"` 不报语法错）。

**常见问题**

- *`serveStdio` 找不到*：v2 中它从 `@modelcontextprotocol/server/stdio` 导入。v1 里等价物是 `new StdioServerTransport()` + `await server.connect(transport)`。
- *`.describe()` 有什么用*：它会被转成 JSON Schema 的 `description`，直接影响模型是否愿意调用你的工具——描述越清晰，触发越准。

---

## 五、第三步：把数据变成一张真正的图

`renderHtml` 负责把 option 塞进一个自带 ECharts 的独立 HTML：

```ts
function renderHtml(option) {
  const json = JSON.stringify(option);
  return `<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<title>${option.title?.text ?? 'Chart'}</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>html,body,#main{margin:0;height:100%}</style>
</head><body><div id="main"></div>
<script>
  const chart = echarts.init(document.getElementById('main'));
  chart.setOption(${json});
  window.addEventListener('resize', () => chart.resize());
</script></body></html>`;
}
```

**设计取舍说明：** 这里用 CDN 引入 ECharts，是为了让产物保持「单文件、零依赖、双击即看」。如果你的部署环境不能联网，可改成 `npm install echarts` 后用打包器内联，或预生成 PNG（需要 headless 浏览器，超出本文范围）。

---

## 六、第四步：跑起来——stdio 协议的黄金法则

```bash
npx tsx src/index.ts
```

**预期结果：** 终端打印一行：

```
charts MCP server running on stdio
```

然后就「卡住」了。这是**正常的**——stdio 服务器在 stdin 上等待客户端发起握手，没有客户端连进来它就永远等。

这里有一条会让 90% 新手翻车的铁律，官方文档原话警告：

> **stdout 是协议通道。日志请用 `console.error`——一次 `console.log` 就会污染 JSON-RPC 流。**

stdio 服务器把请求读自 stdin、响应写到 stdout。一旦你手贱 `console.log('调试一下')`，这行字符串就会和合法的 JSON-RPC 报文混在一起，客户端解析时报错，连接直接崩。所以全文唯一安全的打印方式是 `console.error`（走 stderr，不进协议流）。

**常见问题**

- *客户端报 `Unexpected token ... is not valid JSON`*：99% 是你在某处写了 `console.log`。全局搜一遍，全改成 `console.error`。

---

## 七、第五步：用 MCP Inspector 调试，免装客户端

在你把服务器接进 Claude Code 之前，先用**官方调试器**单独验证工具逻辑。Inspector 是个本地 Web 应用，能直接调用任何 stdio 服务器：

```bash
npx @modelcontextprotocol/inspector npx tsx src/index.ts
```

**操作步骤：**

1. 命令会启动一个本地服务并自动打开浏览器（通常在 `http://localhost:6274`）。
2. 点 **Connect**，握手成功后左侧出现服务器信息。
3. 切到 **Tools** 标签，选中 `render_chart`。
4. 在右侧表单填入测试参数：

```json
{
  "title": "Q1 各产品线营收（万元）",
  "type": "bar",
  "labels": ["网优", "核心网", "传输", "数据通信"],
  "values": [320, 580, 210, 460]
}
```

5. 点 **Run**。

**预期结果：** 返回一段文本，类似：

```
✅ 图表已生成：/tmp/chart-a3f1b2c9.html
类型：bar｜数据点：4
```

把这个路径在浏览器打开，就能看到一张带 tooltip 的柱状图。

**调试技巧：**

- Inspector 会**实时重载**——你改了 `index.ts`，重启 Inspector 即可看到新 schema。
- 故意传错参数（如 `"type": "scatter"`），你能看到 SDK 在 handler 之前就拦截并返回标准校验错误，这正是你想要的「确定性边界」。
- 切到 **Resources** / **Prompts** 标签会是空的——本教程只实现了工具（Tool），这两个原语不在本篇范围内。

---

## 八、第六步：接入 Claude Code

服务器调通了，接下来让它真正被 Claude Code 调用。两种方式任选其一。

**方式 A：命令行注册（推荐，最快）**

```bash
claude mcp add chart-mcp -- npx tsx /绝对路径/chart-mcp/src/index.ts
```

注意 `--` 后面是启动命令本身。用绝对路径，避免 Claude Code 的工作目录不在项目里时找不到文件。

**方式 B：项目级 `.mcp.json`（适合团队共享）**

在项目根目录创建 `.mcp.json`：

```json
{
  "mcpServers": {
    "chart-mcp": {
      "command": "npx",
      "args": ["tsx", "/绝对路径/chart-mcp/src/index.ts"]
    }
  }
}
```

提交进 Git，队友 clone 后 Claude Code 会自动发现该服务器（首次需确认信任）。

**验证接入：** 启动 Claude Code，输入 `/mcp`，应看到 `chart-mcp` 状态为 `connected`，并列出 `render_chart` 工具。

**实战对话：**

```
你：帮我模拟一组 7 天的接口调用量数据，画成折线图。

Claude Code：（决定调用 render_chart）
→ render_chart(type=line, labels=["周一"..."周日"], values=[1200,1500,...])
← /tmp/chart-7d3e9a01.html

Claude Code：已生成折线图，路径如上， depicts 本周调用量趋势……
```

你打开那个 HTML，就是一张可缩放、带 tooltip 的折线图。整个过程中，模型只负责「编数据 + 决定图表类型」，确定性的渲染逻辑由你的 MCP 服务器保证——这就是 MCP 的价值兑现。

---

## 九、实战案例：完整可运行项目

把前面所有片段拼起来，这是一个**复制即可跑**的最小完整项目。文件清单：

```
chart-mcp/
├── package.json        # type=module，已装 server/zod/tsx
└── src/
    └── index.ts        # 完整服务器
```

`src/index.ts` 完整版：

```ts
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
import { writeFile, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';

const server = new McpServer({ name: 'charts', version: '1.0.0' });

function buildOption(type, title, labels, values) {
  if (type === 'pie') {
    return {
      title: { text: title, left: 'center' },
      tooltip: {}, legend: { bottom: 0 },
      series: [{ type: 'pie', radius: '60%',
        data: labels.map((l, i) => ({ name: l, value: values[i] })) }],
    };
  }
  return {
    title: { text: title, left: 'center' },
    tooltip: { trigger: 'axis' },
    xAxis: { type: 'category', data: labels },
    yAxis: { type: 'value' },
    series: [{ type, data: values, smooth: type === 'line' }],
  };
}

function renderHtml(option) {
  const json = JSON.stringify(option);
  return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<title>${option.title?.text ?? 'Chart'}</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>html,body,#main{margin:0;height:100%}</style></head>
<body><div id="main"></div><script>
const c=echarts.init(document.getElementById('main'));
c.setOption(${json});
window.addEventListener('resize',()=>c.resize());
</script></body></html>`;
}

server.registerTool(
  'render_chart',
  {
    description: '根据数据生成可交互 ECharts 图表，保存为独立 HTML，返回路径。',
    inputSchema: z.object({
      title: z.string().default('Chart'),
      type: z.enum(['bar', 'line', 'pie']),
      labels: z.array(z.string()).min(1),
      values: z.array(z.number()).min(1),
      outputPath: z.string().optional(),
    }),
  },
  async ({ title, type, labels, values, outputPath }) => {
    if (labels.length !== values.length) {
      return { isError: true, content: [{ type: 'text',
        text: `labels 与 values 数量不一致：${labels.length} vs ${values.length}` }] };
    }
    const html = renderHtml(buildOption(type, title, labels, values));
    const absPath = outputPath ??
      join(tmpdir(), `chart-${randomBytes(4).toString('hex')}.html`);
    await mkdir(join(absPath, '..'), { recursive: true });
    await writeFile(absPath, html, 'utf8');
    return { content: [{ type: 'text',
      text: `✅ 图表已生成：${absPath}\n类型：${type}｜数据点：${labels.length}` }] };
  }
);

void serveStdio(() => server);
console.error('charts MCP server running on stdio');
```

**一键验证流程：**

```bash
npx tsx src/index.ts                                  # 确认能启动
npx @modelcontextprotocol/inspector npx tsx src/index.ts   # Inspector 调用
claude mcp add chart-mcp -- npx tsx $(pwd)/src/index.ts    # 接入 Claude Code
```

---

## 十、进阶：错误处理、输出类型与安全清单

工具从「能跑」到「能上线」，差在三件事：

1. **分级错误。** 参数错（schema 挡）→ 业务错（`isError:true` + 文本说明）→ 系统错（`throw`，仅用于真正不可恢复的故障，如磁盘满）。三者不要混用。
2. **善用多种 content 块。** 除 `text` 外，`image` 块可直接把 PNG 喂给模型看（需配合 headless 渲染）；`resource` 块可让模型按 URI 取更大数据。本例用 text 回传路径，是权衡「产物可复用」与「协议轻量」后的选择。
3. **路径安全。** `outputPath` 直接来自模型输入——务必警惕**路径穿越**（如 `../../../etc/xxx`）。生产化时应对路径做 `path.resolve` + 前缀白名单校验，限制只能写到指定目录。临时文件名用 `randomBytes` 而非时间戳，避免并发碰撞与可猜测。

---

## 十一、工作流程与架构图

```text
flowchart LR
    U[用户: 画个柱状图] --> CC[Claude Code]
    CC -->|JSON-RPC over stdio| SVR[chart-mcp 服务器]
    SVR -->|Zod 校验参数| OK{合法?}
    OK -->|否| REJ[SDK 拦截返回校验错误]
    OK -->|是| HDL[render_chart handler]
    HDL --> OPT[拼 ECharts option]
    OPT --> FS[写独立 HTML 文件]
    FS --> PATH[/tmp/chart-xxxx.html]
    PATH -->|text 块回传路径| CC
    CC --> U2[用户: 浏览器打开图表]
```

```mermaid
sequenceDiagram
    participant CC as Claude Code
    participant S as chart-mcp
    participant FS as 文件系统
    CC->>S: tools/call render_chart {type,labels,values}
    S->>S: Zod 校验 + 一致性检查
    alt 参数非法
        S-->>CC: isError:true（文本原因）
    else 合法
        S->>S: buildOption + renderHtml
        S->>FS: writeFile(/tmp/chart-xxx.html)
        FS-->>S: ok
        S-->>CC: text 块（文件路径）
    end
```

---

## 十二、v1 等价写法（生产稳定版参考）

如果你的项目还在 v1.x（`@modelcontextprotocol/sdk`），核心差异是包名与注册 API：

```ts
// v1.x 写法
import { Server } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const server = new Server({ name: 'charts', version: '1.0.0' });
server.tool('render_chart', { title: z.string(), type: z.enum(['bar','line','pie']), /* ... */ },
  async (args) => ({ content: [{ type: 'text', text: '...' }] }));

await server.connect(new StdioServerTransport());
```

v2 把 `Server` 改名 `McpServer`、`server.tool` 改为 `server.registerTool`、手动 `connect(transport)` 封装成 `serveStdio(factory)`。迁移时可参考官方 v2 文档的迁移章节。

---

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

| 维度 | 评估 |
|------|------|
| 适合的训练营类型 | AI 应用开发、LLM 工具链、前端工程化、自动化测试 |
| 学员基础 | 会 TypeScript 基本语法、用过 npm |
| 预计教学时长 | **2.5～3 小时**（搭骨架 30min + 写工具 60min + Inspector 调试 45min + 接入 Claude Code 30min） |
| 动手产出 | 每人一个可运行的图表 MCP，带回自己电脑继续用 |
| 进阶扩展作业 | 加 PNG 输出（headless 渲染）、加 Resources 提供「图表主题模板」、改成 Streamable HTTP 远程部署 |

本教程刻意只聚焦 **Tool** 一个原语并讲透，避免和「三原语」「远程 HTTP」等主题重叠。适合放在训练营的**第二阶段**——学员已装通 Claude Code、做完入门工具后，用它建立「从工具到协议」的完整心智模型。

---

## 延伸阅读

- MCP TypeScript SDK v2 文档站（含十分钟服务端教程）：`https://ts.sdk.modelcontextprotocol.io/v2/`
- 官方「Build your first server」分步指南（仓库内 `docs/get-started/first-server.md`）
- MCP 规范与概念总览：`https://modelcontextprotocol.io/docs`
- 把服务器注册进 VS Code / Cursor / Claude Code 的「Plug into a real host」章节

---

## 参考来源

1. [MCP TypeScript SDK 官方仓库 README（v2 beta）](https://github.com/modelcontextprotocol/typescript-sdk) — 权威说明了 v2 拆包、`@modelcontextprotocol/server`、`registerTool` API、Zod v4 schema 与 `serveStdio`，以及 2026-07-28 规范时间线
2. [Build your first server — 官方分步教程](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/get-started/first-server.md) — 提供 `npm init` 到工具调用的完整流程、stdio「stdout 即协议」警告、Inspector 调用方式与错误返回约定
3. [MCP Inspector — 官方文档](https://modelcontextprotocol.io/docs/tools/inspector) — 交互式调试工具的使用方法与连接 stdio 服务器的细节
4. [Add Custom Tools to TypeScript MCP Servers — MCPcat 指南](https://mcpcat.io/guides/adding-custom-tools-mcp-server-typescript/) — Zod 校验、错误处理、自定义工具的最佳实践补充
5. [Build an MCP Server with TypeScript: 2026 Tutorial — dev.to](https://dev.to/jangwook_kim_e31e7291ad98/build-an-mcp-server-with-typescript-2026-tutorial-1ipk) — 2026 年社区教程，佐证 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)
