实战:Deno MCP Server 项目
从 stdio 与只读 tool 起步,用 Zod 校验参数、fixtures 做协议测试,再扩展到 Streamable HTTP
本教程把 项目蓝图 2 展开为可执行步骤。registerTool、transport 与客户端配置的 API 教学见 用 Deno 构建 MCP Server,本文聚焦项目结构、测试与演进路线。
目标与最终形态
mcp-project/
├── main.ts # 入口:组装 server、连接 transport
├── tools/{search,read}.ts # 只读 tool,各为一个模块
├── schemas.ts # Zod 参数校验,单一事实来源
├── fixtures/ # JSON-RPC 请求/响应样本,协议测试用
└── deno.json
第一阶段:stdio transport + 只读 tool,客户端以最小权限启动。第二阶段:fixtures 驱动的协议测试。之后再评估 Streamable HTTP、认证与写操作——每一步都是独立可验收的切片。
初始化
deno init mcp-project
cd mcp-project
deno add npm:@modelcontextprotocol/sdk npm:zod
里程碑一:stdio + 只读 tool
参数校验集中在 schemas.ts
import { z } from "zod";
export const noteName = z.string().regex(/^[a-z0-9-]+$/)
.describe("Short name of an approved note");
export const searchQuery = z.string().min(1).max(200);
tool 实现为可独立测试的函数
把业务逻辑和协议注册分开:handler 核心是普通 async 函数,registerTool 只做接线。
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { noteName } from "../schemas.ts";
export async function readNote(name: string): Promise<string> {
return await Deno.readTextFile(`./notes/${name}.md`);
}
export function registerReadTool(server: McpServer) {
server.registerTool(
"read_project_note",
{
description: "Read one approved note by its short name",
inputSchema: { name: noteName },
},
async ({ name }) => ({
content: [{ type: "text", text: await readNote(name) }],
}),
);
}
tools/search.ts 结构相同:对允许目录内的文件做受控搜索,结果截断到固定条数。注意路径安全——slug 白名单正则 (^[a-z0-9-]+$) 让 ../ 之类的输入在 Zod 层就被拒绝,不需要再做路径拼接防御。
入口
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerReadTool } from "./tools/read.ts";
import { registerSearchTool } from "./tools/search.ts";
const server = new McpServer({ name: "project-notes", version: "0.1.0" });
registerReadTool(server);
registerSearchTool(server);
await server.connect(new StdioServerTransport());
客户端配置坚持最小权限——只给 notes 目录的读权限:
{
"mcpServers": {
"project-notes": {
"command": "deno",
"args": ["run", "--allow-read=./notes", "main.ts"]
}
}
}
里程碑二:fixtures 协议测试
stdio transport 的消息是换行分隔的 JSON-RPC,因此可以把整段会话存成文本 fixture,用子进程回放:
fixtures/
├── initialize.jsonl # initialize + notifications/initialized
├── tools-list.jsonl
├── tools-call-read.jsonl
└── tools-call-invalid.jsonl # 非法参数,期望 error 响应
测试启动真实 server 进程、写入 fixture、逐行比对响应中的关键字段(result.tools[].name、error.code 等),不要全文字节比对——响应里时间戳类字段会漂移:
import { assertEquals } from "jsr:@std/assert";
async function runFixture(fixture: string): Promise<unknown[]> {
const proc = new Deno.Command("deno", {
args: ["run", "--allow-read=./notes", "main.ts"],
stdin: "piped",
stdout: "piped",
}).spawn();
const writer = proc.stdin.getWriter();
await writer.write(await Deno.readFile(`fixtures/${fixture}`));
await writer.close();
const { stdout } = await proc.output();
return new TextDecoder().decode(stdout)
.trim().split("\n").map((line) => JSON.parse(line));
}
Deno.test("tools/list exposes only read-only tools", async () => {
const responses = await runFixture("tools-list.jsonl");
const result = responses.find((r) => (r as { result?: unknown }).result);
const tools = (result as { result: { tools: { name: string }[] } })
.result.tools.map((t) => t.name);
assertEquals(tools.sort(), ["read_project_note", "search_notes"]);
});
父测试进程与子 server 的权限是两套:父进程要读 fixtures 并启动子进程,子 server 保持最小权限。把协议测试的权限写进 task,不要靠 -A 糊弄:
{
"tasks": {
"test": "deno test --allow-read=fixtures --allow-run=deno",
"test:unit": "deno test --allow-read=./notes tools/ schemas_test.ts"
}
}
工具函数层另配普通单元测试:readNote 对合法名字返回内容、对不存在的名字抛错。Zod schema 测试直接 noteName.safeParse("../etc") 断言失败。
里程碑三(可选):Streamable HTTP、认证、写操作
只有在确实需要远程客户端时才做这一步,并且按顺序加:
- Streamable HTTP transport:SDK 提供
StreamableHTTPServerTransport,旧的 HTTP+SSE 只用于兼容存量客户端。 - 认证与防护:公开部署必须有认证、Origin/DNS rebinding 防护、速率限制和每次调用的授权判断——这些要求的背景见 MCP Server 教程。
- 写操作:写 tool 要有独立的权限边界(单独的
--allow-write目录)、参数白名单和审计日志;先在 fixtures 里回放危险输入(路径穿越、超长参数、并发调用)再开放。
测试策略小结
- 单元测试:tool 核心函数 + Zod schema,不启动 server。
- 协议测试:fixtures 经子进程走真实 stdio,覆盖 initialize、tools/list、tools/call 正常与错误分支。
- 权限测试:故意让 tool 访问
./notes之外的路径,断言 Deno 权限层拒绝(这验证了最小权限配置本身)。
部署
- stdio 分发:用户本地
deno run即可,无服务器;deno.lock提交,README 写明客户端配置片段。 - 远程部署:走 Streamable HTTP 时可部署到 Deno Deploy;环境变量用平台 secret 管理,参考 生产基线 的 checklist。
验收清单
- 客户端配置只含 --allow-read=./notes 等最小权限
- 所有 tool 参数经 Zod 校验,非法输入返回协议 error 而非崩溃
- fixtures 协议测试覆盖 initialize、tools/list、tools/call 的正常与错误分支
- stdout 无任何调试输出,日志全部走 stderr
- tool 核心逻辑不依赖协议层,可脱离 server 单测
- deno fmt --check、deno lint、deno check、deno test 全部通过
官方参考:Deno MCP server example、MCP TypeScript SDK、Model Context Protocol。