核心摘要
本教程使用 Node.js、TypeScript SDK v2、Zod 和 stdio 构建一个本地只读 MCP Server。Server 只暴露 text.stats,对输入设置上限,并同时返回文本与结构化结果。你将完成类型检查、Inspector 调用和 MCP 2026-07-28 现代消息检查,而不会把本地示例误当作生产服务。
最重要的边界是:SDK 负责 MCP 消息、Transport 与 Schema 转换;业务语义、授权、资源限制、错误处理和副作用仍由你的代码负责。
安装前先固定版本契约
TypeScript SDK v2 与搜索结果中仍很常见的 v1 示例不是同一套 Package 和 API,不能混用导入路径与 Tool 注册方式。
| 项目 | 本教程的可复现配置 | 边界 |
|---|---|---|
| Runtime | Node.js 22 | SDK v2 支持 Node.js 20+;Inspector 2.3.0 要求 22.19+ |
| Server SDK | @modelcontextprotocol/[email protected] |
Server 代码不再使用 v1 聚合包 |
| Schema | 通过 zod/v4 使用 [email protected] |
SDK v2 接受 Standard Schema Library |
| Runner | [email protected] |
本地教程直接运行 TypeScript |
| 类型检查 | [email protected] |
tsc --noEmit 检查代码但不生成构建产物 |
| Protocol | MCP 2026-07-28 |
现代请求是 Self-describing,不使用 initialize |
这些精确版本定义的是经过测试的组合,不是永久版本建议。升级时一次只改一个依赖,阅读 Release Notes,重新生成 Lockfile,并重复本文的验证矩阵。
如果需要先理解代码背后的参与方与协议层,请阅读 MCP 协议解析。MCP Server 术语 则说明 Server 的职责与应用策略的边界。
你将构建什么
项目只提供一个确定性能力:
本地 Host
|
| stdin/stdout 上逐行传输 JSON-RPC
v
MCP Server
|
v
text.stats(text) -> 有界结构化计数
text.stats 不读取文件、不访问网络、不执行命令,也不修改外部状态。这种窄化契约适合在增加业务风险前暴露 Transport、Schema 与结果格式问题。
Tool 返回三项数据:
- UTF-16 Code Unit 数,与 JavaScript
String.length一致; - Unicode Code Point 数,避免把一个代理对算成两个字符;
- 按空白切分的 Word 数,字段名明确限定了算法语义。
它不声称能计算用户感知的 Grapheme Cluster,也不声称实现了语言相关的分词。
1. 创建固定依赖的项目
创建独立目录并安装精确版本:
mkdir mcp-text-stats
cd mcp-text-stats
npm init -y
npm pkg set type=module
npm pkg set scripts.start="tsx src/index.ts"
npm pkg set scripts.typecheck="tsc --noEmit"
npm install --save-exact @modelcontextprotocol/[email protected] [email protected]
npm install --save-dev --save-exact [email protected] [email protected] @types/[email protected]
mkdir src
提交 package-lock.json,并在 CI 中使用 npm ci。只安装不受约束的 latest 无法证明明天解析出的 API 仍与今天的代码匹配。
创建 tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
SDK v2 发布为 ECMAScript Module。"type": "module" 与 NodeNext 让 TypeScript Resolver 和 Node.js 保持一致,避免把 ESM 源码与 CommonJS 输出混在一起。
2. 注册一个有界只读 Tool
创建 src/index.ts:
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
const textStatsSchema = z.object({
text: z.string().max(20_000).describe("Text to measure"),
});
const textStatsResultSchema = z.object({
utf16CodeUnits: z.number().int().nonnegative(),
unicodeCodePoints: z.number().int().nonnegative(),
whitespaceWords: z.number().int().nonnegative(),
});
function createServer(): McpServer {
const server = new McpServer({
name: "mcp-text-stats",
version: "1.0.0",
});
server.registerTool(
"text.stats",
{
title: "Text statistics",
description:
"Return bounded UTF-16, Unicode code-point, and whitespace-word counts.",
inputSchema: textStatsSchema,
outputSchema: textStatsResultSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ text }) => {
const trimmed = text.trim();
const output = {
utf16CodeUnits: text.length,
unicodeCodePoints: [...text].length,
whitespaceWords: trimmed === "" ? 0 : trimmed.split(/\s+/u).length,
};
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
},
);
return server;
}
void serveStdio(createServer);
console.error("mcp-text-stats is ready on stdio");
这段代码使用了四项 SDK v2 契约:
registerTool接收 Tool Name、配置对象和异步 Handler。inputSchema在 Handler 执行前拒绝超长或非字符串输入。outputSchema定义结构化结果契约,structuredContent提供匹配的数据。serveStdio负责 Transport,并创建实际服务该连接的 Server 实例。
文本 Content 适合主要消费展示内容的 Client;structuredContent 让支持它的 Client 不必从自然语言中重新解析数据。同时返回两者是一种兼容方式,不代表可以跳过输出校验。
Tool Annotation 只是提示。可信 Client 可以用它决定 UI 或审批方式,但 readOnlyHint: true 无法阻止错误或恶意 Handler 写入数据。MCP Tool 术语 进一步解释 Schema、授权、副作用与 Tool Poisoning。
3. 执行类型检查并启动
先运行静态检查:
npm run typecheck
命令成功时不产生 TypeScript Diagnostic。它只能证明固定版本的导入、Schema 与 Handler 类型一致,不能证明协议行为正确。
启动进程:
npm start
就绪信息会写到 stderr:
mcp-text-stats is ready on stdio
随后进程保持等待是正常现象。stdio Server 通常由 Host 启动并驱动,不是供人直接输入普通文本的交互式命令。
不要把示例中的 console.error 换成 console.log。2026-07-28 stdio Binding 要求 stdout 每行只能包含一条 JSON-RPC Message;任何普通日志都会破坏协议流。
4. 使用 MCP Inspector 验证
使用本教程测试过的 Inspector 版本:
npx --yes @modelcontextprotocol/[email protected] npx tsx src/index.ts
在 Inspector 中执行:
- 选择 Modern Protocol Era 并连接。
- 确认
server/discover返回2026-07-28。 - 打开 Tools,确认
text.stats同时暴露 Input Schema 与 Output Schema。 - 使用
{"text":"hello MCP world"}调用。 - 确认
structuredContent.whitespaceWords等于3。 - 提交超过 20,000 个 JavaScript String Unit 的输入,确认收到 Tool-level Error。
- 确认拒绝错误输入后 Server 仍可继续服务。
成功调用的最终 Result 包含:
{
"resultType": "complete",
"structuredContent": {
"utf16CodeUnits": 15,
"unicodeCodePoints": 15,
"whitespaceWords": 3
}
}
SDK 还可能返回文本 Content 与 Server Metadata。测试时应断言自己负责的语义字段,不要对可能演进的可选 Metadata 做完整 Snapshot。
5. 理解现代消息契约
MCP 2026-07-28 没有协商握手。每个现代 Request 都通过 _meta 携带 Protocol Revision 与相关 Client Capability。
简化后的 tools/call Request 如下:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {
"name": "example-client",
"version": "1.0.0"
}
},
"name": "text.stats",
"arguments": {
"text": "hello MCP world"
}
}
}
不要在生产进程中手工输入协议消息。这个示例只用于看清三件事:
- Request Identity 是 JSON-RPC
id; - Protocol Version 与 Capability 随 Request 到达;
- Tool Name 和 Arguments 仍是普通
tools/call参数。
serveStdio 默认可以从同一个 Factory 服务较早版本的 Client。Legacy Client 可能仍以 initialize 开始,但兼容行为不会把 Protocol Session 重新带入现代请求。测试和 Telemetry 必须明确区分两个 Era。
6. 区分 Tool Error 与 Protocol Error
Tool 失败和协议失败的含义与恢复方式不同。
| 失败 | 预期格式 | Client 动作 |
|---|---|---|
| 输入违反 Tool Schema | resultType: "complete"、isError: true 的最终 Tool Result |
修正 Arguments,不能当作成功 |
| Tool 遇到预期业务失败 | Handler 返回有界、可向用户展示的 isError: true 内容 |
修改输入或业务状态 |
| Protocol Revision 不受支持 | 包含支持版本的 JSON-RPC -32022 Error |
选择共同版本或停止 |
| JSON-RPC 格式错误 | JSON-RPC Error Response | 修复 Client 实现 |
| 进程退出或 Pipe 断开 | Transport Failure;写操作可能是 Unknown Effect | 查证执行结果后再决定是否重试 |
resultType: "complete" 表示协议 Request 已获得最终 Result,不表示业务操作成功;还必须检查 isError。
示例 Tool 没有副作用,因此相同输入可安全重试。写 Tool 在支持自动重试前,需要 Idempotency Key、Effect Ledger 和明确的 unknown_effect 结果。
7. 谨慎接入真实 Host
许多本地 Host 接受类似下面的配置:
{
"mcpServers": {
"mcp-text-stats": {
"command": "/absolute/path/to/node_modules/.bin/tsx",
"args": ["/absolute/path/to/mcp-text-stats/src/index.ts"]
}
}
}
配置位置与字段名称取决于 Host。请使用对应产品的官方文档、绝对路径,以及 Host 被允许访问的项目目录。连接成功只能证明进程和协议路径正常,不能证明模型一定会正确选择 Tool。
应分别测试三层:
- **Handler:**空字符串、Unicode、空白、最大长度等确定性用例。
- **Protocol:**Discovery、List、有效调用、无效调用、Cancellation 与 Shutdown。
- **Host Workflow:**Tool Selection、审批展示、结果处理和失败恢复。
MCP Client 术语 解释了为什么 Client 是协议组件,而不是用户、模型或安全 Principal。
8. 正确认识 stdio 信任边界
stdio 没有网络监听端口,但并不等于没有安全问题。Host 启动的本地进程会继承一部分 Argument、Environment、Working Directory、Filesystem Permission 与操作系统身份。
包装真实数据前至少应做到:
- 只传递必要环境变量,且不得输出 Secret;
- 使用允许目录而不是接受任意路径;
- 访问前规范化路径并拒绝 Symlink Escape;
- 限制请求、结果、执行时间、并发与子进程;
- 把 Tool Description、Argument、Result 与关联 Resource 都视为不可信数据;
- 鉴权精确对象与动作,不能只信任 Tool Name;
- 长时 Handler 收到
notifications/cancelled后停止工作; - stdin 到达 EOF 后及时退出;
- 只向 stderr 记录有界标识,不记录原始 Secret 或私有 Result。
Schema Validation 只能证明数据形态,不能证明身份、所有权、Purpose、Freshness 或 Permission。
9. 改造成 HTTP 前先停下来
不能把 stdio 替换成 Web Framework 后就声称服务已达到生产要求。远程 MCP Server 改变了信任边界,必须使用当前 Streamable HTTP 实现。
暴露 URL 前需要:
- 校验
Origin、Content-Type、Protocol Header 与 Header/Body 一致性; - 只监听预期 Interface,并正确配置 Proxy Buffering;
- 端点受保护时实现 OAuth Resource Server;
- 校验 Issuer、Audience/Resource、Expiry 与最小 Scope;
- 对每个 Request 授权 Tenant、Tool、对象、Argument 与副作用;
- 限制 Body、Result、Queue Time、Execution Time 与并发 Stream;
- 实现 Cancellation、Idempotency、Effect Status 和可审计的脱敏 Telemetry;
- 将 Legacy Initialize、Session ID、GET/SSE 与 Resume 隔离到明确兼容路径。
完整 Request Admission 与故障模型参见 MCP 生产最佳实践。受保护远程流程参见 企业级 MCP OAuth。
验证矩阵
可复现教程不能只记录“Inspector 已连接”。
| 测试 | 证据 | 能证明什么 |
|---|---|---|
npm ci |
Lockfile 可解析 | 依赖图可复现 |
npm run typecheck |
0 Diagnostic | Import、Schema 与 Handler 类型一致 |
server/discover |
包含 2026-07-28 |
Modern Protocol Support 可见 |
tools/list |
Name 与 Schema 正确 | Tool 注册和发现正常 |
有效 tools/call |
结构化计数符合预期 | Handler 与输出契约正常 |
| 超限调用 | isError: true |
输入边界生效 |
| 新进程首请求使用不支持版本 | JSON-RPC -32022 |
版本不匹配可见 |
| 捕获 stdout | 只有 JSON-RPC 行 | 日志没有破坏 Framing |
| EOF / Cancellation | 工作停止且进程退出 | Lifecycle 有界 |
每次记录结果时,都要保存 Command、Runtime、依赖版本、操作系统和断言。SDK、Schema、Runtime 或 Transport 变化后重新执行整套矩阵。
总结
第一个 MCP Server 的价值不在于 Tool 数量,而在于你能解释并复现它的依赖版本、Schema、Result Semantics、Protocol Message 和失败行为。
先实现一个有界只读 Tool,保持 stdout 纯净,返回类型化数据,分别检查 Tool Error 与 Protocol Error,并测试真实 Host 路径。只有当每个新增信任边界都有明确契约后,再加入 Resource、Prompt、远程 Transport、Credential 与副作用。