本教程带你用 Model Context Protocol(MCP) 的 TypeScript SDK,从零构建一个「图表 MCP 服务器」:Claude Code 只要把一串数据交给它,它就能吐出一张可交互的 ECharts 图表。学完你将掌握 MCP 工具(Tool)的完整开发闭环——项目骨架、Zod 输入校验、stdio 传输、错误处理、Inspector 调试、以及接入 Claude Code 实际调用。适合已会基本 TypeScript、想把 Claude Code 能力从「能聊天」升级到「能干活」的开发者。
很多新手会问:我直接让 Claude 写一段 ECharts 代码不就行了,为什么要折腾一个 MCP 服务器?
区别在于可靠性边界。让模型每次现场生成 HTML,会面临三个不稳定因素:ECharts 的 option 字段名偶尔写错、CDN 地址版本飘移、数据序列化时引号转义出错。而把「生成图表」封装成一个确定性工具——输入固定、输出固定、逻辑用代码写死——模型只负责决定「调用哪个工具、传什么参数」,脏活累活由代码兜底。这正是 MCP 的设计哲学:把「提供上下文/执行动作」这件事,从 LLM 的概率世界里剥离出来,交给确定性的服务端。
MCP 在 2026 年已经从「Anthropic 的实验」长成了开放标准,TypeScript SDK 每月下载量过千万,公共服务器过万。本文使用的官方 SDK 信息如下(截至 2026 年 7 月):
@modelcontextprotocol/server 与 @modelcontextprotocol/client,实现 2026-07-28 规范候选版,预计 7 月 28 日转正。@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。
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。工具是 MCP 最核心的原语。一个工具 = 一个名字 + 一份入参 schema + 一个异步处理函数。创建 src/index.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在浏览器打开即可查看交互式图表。`,
}],
};
}
);
这段代码的关键认知点:
inputSchema 是你唯一要写的 schema。 它是 Zod 对象。SDK 会自动把它转成模型能看懂的 JSON Schema,并在调用你的 handler 之前完成参数校验。也就是说,模型如果把 type 传成了 "scatter",SDK 会直接挡掉,你的 handler 根本不会执行。content,一个类型化块数组。 这里用 text 块;如果你返回的是图片,可以用 { type:'image', data: base64, mimeType:'image/png' }。isError: true 不是抛异常,而是「告诉模型这次失败了」。 模型能读到失败原因并决定是否重试。这比抛 throw new Error() 优雅得多——抛异常会让连接进入异常状态。buildOption 与 renderHtml 两个纯函数放到下一步实现。先在文件末尾接上启动逻辑:
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:
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 浏览器,超出本文范围)。
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。在你把服务器接进 Claude Code 之前,先用官方调试器单独验证工具逻辑。Inspector 是个本地 Web 应用,能直接调用任何 stdio 服务器:
npx @modelcontextprotocol/inspector npx tsx src/index.ts
操作步骤:
http://localhost:6274)。render_chart。{
"title": "Q1 各产品线营收(万元)",
"type": "bar",
"labels": ["网优", "核心网", "传输", "数据通信"],
"values": [320, 580, 210, 460]
}
预期结果: 返回一段文本,类似:
✅ 图表已生成:/tmp/chart-a3f1b2c9.html
类型:bar|数据点:4
把这个路径在浏览器打开,就能看到一张带 tooltip 的柱状图。
调试技巧:
index.ts,重启 Inspector 即可看到新 schema。"type": "scatter"),你能看到 SDK 在 handler 之前就拦截并返回标准校验错误,这正是你想要的「确定性边界」。服务器调通了,接下来让它真正被 Claude Code 调用。两种方式任选其一。
方式 A:命令行注册(推荐,最快)
claude mcp add chart-mcp -- npx tsx /绝对路径/chart-mcp/src/index.ts
注意 -- 后面是启动命令本身。用绝对路径,避免 Claude Code 的工作目录不在项目里时找不到文件。
方式 B:项目级 .mcp.json(适合团队共享)
在项目根目录创建 .mcp.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 完整版:
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');
一键验证流程:
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
工具从「能跑」到「能上线」,差在三件事:
isError:true + 文本说明)→ 系统错(throw,仅用于真正不可恢复的故障,如磁盘满)。三者不要混用。text 外,image 块可直接把 PNG 喂给模型看(需配合 headless 渲染);resource 块可让模型按 URI 取更大数据。本例用 text 回传路径,是权衡「产物可复用」与「协议轻量」后的选择。outputPath 直接来自模型输入——务必警惕路径穿越(如 ../../../etc/xxx)。生产化时应对路径做 path.resolve + 前缀白名单校验,限制只能写到指定目录。临时文件名用 randomBytes 而非时间戳,避免并发碰撞与可猜测。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[用户: 浏览器打开图表]
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.x(@modelcontextprotocol/sdk),核心差异是包名与注册 API:
// 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、做完入门工具后,用它建立「从工具到协议」的完整心智模型。
https://ts.sdk.modelcontextprotocol.io/v2/docs/get-started/first-server.md)https://modelcontextprotocol.io/docs@modelcontextprotocol/server、registerTool API、Zod v4 schema 与 serveStdio,以及 2026-07-28 规范时间线npm init 到工具调用的完整流程、stdio「stdout 即协议」警告、Inspector 调用方式与错误返回约定[!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 等场景全覆盖。
