文档实战项目

实战: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[].nameerror.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、认证、写操作

只有在确实需要远程客户端时才做这一步,并且按顺序加:

  1. Streamable HTTP transport:SDK 提供 StreamableHTTPServerTransport,旧的 HTTP+SSE 只用于兼容存量客户端。
  2. 认证与防护:公开部署必须有认证、Origin/DNS rebinding 防护、速率限制和每次调用的授权判断——这些要求的背景见 MCP Server 教程
  3. 写操作:写 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 exampleMCP TypeScript SDKModel Context Protocol

输入关键词搜索全部文档。