# 实战：Deno MCP Server 项目

本教程把 [项目蓝图 2](/docs/projects) 展开为可执行步骤。`registerTool`、transport 与客户端配置的 API 教学见 [用 Deno 构建 MCP Server](/docs/ai/mcp-server)，本文聚焦项目结构、测试与演进路线。

## 目标与最终形态

```text
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、认证与写操作——每一步都是独立可验收的切片。

## 初始化

```bash
deno init mcp-project
cd mcp-project
deno add npm:@modelcontextprotocol/sdk npm:zod
```

## 里程碑一：stdio + 只读 tool

### 参数校验集中在 schemas.ts

```ts title="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` 只做接线。

```ts title="tools/read.ts"
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 层就被拒绝，不需要再做路径拼接防御。

### 入口

```ts title="main.ts"
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 目录的读权限：

```json
{
  "mcpServers": {
    "project-notes": {
      "command": "deno",
      "args": ["run", "--allow-read=./notes", "main.ts"]
    }
  }
}
```

<Callout type="warn" title="stdout 只能走协议">
stdio server 的 stdout 是 JSON-RPC 通道。所有日志写 `console.error`（stderr），且不要回显 tool 参数中可能包含的敏感内容。详见 [MCP Server 教程](/docs/ai/mcp-server)。
</Callout>

## 里程碑二：fixtures 协议测试

stdio transport 的消息是换行分隔的 JSON-RPC，因此可以把整段会话存成文本 fixture，用子进程回放：

```text
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` 等），不要全文字节比对——响应里时间戳类字段会漂移：

```ts title="protocol_test.ts"
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` 糊弄：

```json title="deno.json（节选）"
{
  "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 教程](/docs/ai/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 管理，参考 [生产基线](/docs/deploy/production-baseline) 的 checklist。

## 验收清单

<Checklist id="mcp-project-acceptance" items={[
  "客户端配置只含 --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](https://docs.deno.com/examples/mcp_server/)、[MCP TypeScript SDK](https://ts.sdk.modelcontextprotocol.io/server)、[Model Context Protocol](https://modelcontextprotocol.io/docs/)。
