每日 MCP Server

MCP Roots 与 Sampling 实战:让服务端主动划边界、反向要推理——四套客户端配置 + 14 行排查表

2026-10-10
开发仓库
支持客户端:
Claude CodeCursorVS Code + CopilotTraeCline
MCPModel Context ProtocolRootsSampling路径穿越human-in-the-loopServerCapabilities上下文边界隐私保护AI Agent

简介

多数人写 MCP Server 只用到 Resources / Tools / Prompts 三项,Roots 和 Sampling 这两个反向能力基本闲置,结果就是 Server 既不知道自己该在哪干活,也没有推理能力。本文把这两个方向的能力讲透:Roots 是客户端告诉服务端「工作区边界在哪」的机制(URI 列表、roots/list 双向协商、listChanged 动态通知、路径穿越的必修防御),Sampling 是服务端反过来向客户端的 LLM 请求一次推理的机制(服务端不知道任何 API Key,模型由客户端挑、账由客户端付、人可以卡在中间审批)。含 TypeScript SDK 完整实现代码(含 sampling/createMessage 的类型定义与 human-in-the-loop 回调)、Claude Code / Cursor / VS Code + Copilot / Trae 四套带中文注释的配置、三个实战场景(单仓守卫 / 日志归因摘要 / 隐私脱敏代理)、与其他方案的选型对比表、六条安全注意事项,以及 14 行故障排查表。

安装命令

npm i @modelcontextprotocol/sdk zod

MCP Roots 与 Sampling 实战

一句话结论:Resources / Tools / Prompts 让服务端「能干活」,Roots / Sampling 让服务端「知道在哪干」和「能思考」。 少了后两个,你写的 Server 本质上是个带鉴权的 RPC,不是智能体。

一、为什么需要这两个「反向能力」

1.1 单向能力的天花板

先看一个典型的、只用 Tools 的 Server 遇到什么问题。

// 只有 Tools 的 Server,会遇到这三件事
server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const { path } = req.params.arguments as { path: string };

  // 问题 1:我不知道该在哪干活
  // 用户在 VS Code 里开了三个仓库,我该搜哪个?
  const root = process.cwd();   // ← 拿到的是 Server 进程的 cwd,不是用户的工作区

  // 问题 2:我不知道边界
  // 用户传 path = '../../../../etc/passwd',我该拒绝吗?凭什么判断?
  const abs = resolve(root, path);

  // 问题 3:我需要 LLM 能力,但我没有 Key
  // 想对文件内容做一次摘要,怎么办?
  // 只能:① 要求用户再配一个 API Key(体验灾难)
  //      ② 或者把这活甩回给客户端实现(那就不是 Server 的能力了)
});

三个问题对应三个缺失:

  • 不知道在哪干 → 需要 Roots(工作区边界)
  • 不知道什么是越界 → 需要 Roots + 路径校验
  • 需要推理但没 Key → 需要 Sampling(反向借用客户端的 LLM)

1.2 MCP 的能力协商全景

MCP 的能力是双向协商的,initialize 时双方各自声明:

方向能力谁提供谁使用解决什么
Server → Clientresources服务端客户端给模型喂结构化内容
Server → Clienttools服务端客户端让模型调用外部动作
Server → Clientprompts服务端客户端复用提示模板
Client → Serverroots客户端服务端告诉服务端工作区边界
Client → Serversampling客户端服务端让服务端借用 LLM 推理

前三项是「服务端给客户端东西」,所有教程都在讲。 后两项是「客户端给服务端东西」,几乎没人用 —— 但它们恰恰是把 Server 从 RPC 升级成 Agent 的关键。

1.3 一张图看清数据流向

        ┌─────────────────────────────────────┐
        │             Host / Client           │
        │  (Claude Code / Cursor / VS Code)   │
        │                                     │
        │   LLM ──── tools/call ──────────────┼──┐
        │    ↑                                │  │
        │    └── tool result ─────────────────┼──┤
        │                                     │  │
        │   roots/list ───────────────────────┼──┤  ← 客户端告诉服务端:
        │   notifications/roots/list_changed ─┼──┤    「你的工作区在这 3 个地方」
        │                                     │  │
        │   sampling/createMessage ←──────────┼──┤  ← 服务端反向请求:
        │   (由客户端的 LLM 完成,Result 回传)│  │    「帮我总结这段话」
        └─────────────────────────────────────┘  │
                                                 │
        ┌─────────────────────────────────────┐  │
        │            MCP Server               │◄─┘
        │                                     │
        │   · 知道自己在哪干活(Roots)        │
        │   · 能拒绝越界请求(路径校验)       │
        │   · 能请求一次推理(Sampling)       │
        │   · 全程不需要持有任何 API Key       │
        └─────────────────────────────────────┘

二、Roots:让服务端知道边界在哪

2.1 协议定义

Roots 是一组 URI,客户端在握手完成后告诉服务端「本次会话的工作区范围是这些」。

// 协议类型(简化)
interface Root {
  uri: string;      // 必须是 file:// 开头的绝对 URI,如 file:///home/user/project
  name?: string;    // 人类可读的名字,用于给模型展示
}

服务端通过 roots/list 主动拉取:

// 请求
{ "jsonrpc": "2.0", "id": 1, "method": "roots/list" }

// 响应
{
  "jsonrpc": "2.0", "id": 1,
  "result": {
    "roots": [
      { "uri": "file:///Users/me/work/app",     "name": "app" },
      { "uri": "file:///Users/me/work/shared",  "name": "shared-lib" }
    ]
  }
}

客户端在工作区变化时(打开新文件夹、关闭标签页、切换多根工作区)会主动推通知:

// notifications/roots/list_changed —— 无 id,服务端不需要回执
{ "jsonrpc": "2.0", "method": "notifications/roots/list_changed" }

服务端收到后应当重新拉取 roots/list,而不是假设根没变。

2.2 服务端实现:Roots 管理器

// src/roots.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { Root } from '@modelcontextprotocol/sdk/types.js';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { resolve, sep } from 'node:path';

/**
 * Roots 管理器。
 * 三个职责:拉取 / 缓存 / 边界校验。
 */
export class RootsManager {
  private cache: Root[] = [];
  private lastFetch = 0;
  private readonly TTL_MS = 5_000;

  constructor(private server: Server) {
    // 客户端推来变更通知 → 立刻失效缓存
    this.server.setNotificationHandler(
      RootsListChangedNotificationSchema,
      async () => {
        this.cache = [];
        this.lastFetch = 0;
        console.error('[roots] list_changed → cache invalidated');
      }
    );
  }

  /** 拉取 roots,带 5s TTL 缓存。客户端不支持时会抛 MethodNotFound */
  async list(force = false): Promise<Root[]> {
    const now = Date.now();
    if (!force && this.cache.length && now - this.lastFetch < this.TTL_MS) {
      return this.cache;
    }

    try {
      const res = await this.server.listRoots();
      this.cache = res.roots ?? [];
      this.lastFetch = now;
      return this.cache;
    } catch (err: any) {
      // ⚠️ 关键:客户端不支持 roots 是常态不是异常,要降级而不是崩溃
      if (err?.code === -32601 /* MethodNotFound */) {
        console.error('[roots] client does not support roots, falling back to cwd');
        this.cache = [{ uri: pathToFileURL(process.cwd()).href, name: 'cwd' }];
        return this.cache;
      }
      throw err;
    }
  }

  /** Root[] → 本地路径数组 */
  async toPaths(): Promise<string[]> {
    const roots = await this.list();
    return roots.map(r => {
      if (!r.uri.startsWith('file://')) return null;   // 非文件 URI 直接忽略
      return fileURLToPath(r.uri);
    }).filter((p): p is string => p !== null);
  }

  /**
   * 核心安全方法:判断一个路径是否在 roots 之内。
   * ⚠️ 这是路径穿越攻击的唯一防线,必须做字符串前缀以外的严格判断。
   */
  async assertInside(target: string): Promise<string> {
    const abs = resolve(target);
    const roots = await this.toPaths();

    if (roots.length === 0) {
      throw new Error('未声明任何工作区根目录,拒绝执行');
    }

    const inside = roots.some(root => {
      const normalizedRoot = resolve(root);
      // 必须同时满足:等于 root 本身,或以 root + 分隔符 开头
      // 只判断 startsWith(root) 会把 /home/user/project-evil 也算合法
      return abs === normalizedRoot || abs.startsWith(normalizedRoot + sep);
    });

    if (!inside) {
      throw new Error(
        `路径越界:${abs} 不在任何工作区内\n` +
        `允许的目录:\n${roots.map(r => '  - ' + r).join('\n')}`
      );
    }
    return abs;
  }
}

2.3 最容易写错的三个边界判断

// ❌ 错误写法 1:纯字符串前缀
// /home/user/project-evil 会被误判为在 /home/user/project 之内
if (abs.startsWith(root)) return true;

// ❌ 错误写法 2:没 resolve 就比对
// /home/user/project/../../etc/passwd 不会被 startsWith 拦住
if (userPath.startsWith(root)) return true;

// ❌ 错误写法 3:允许符号链接穿越
// 软链 /workspace/link -> /etc,对 link 校验通过但实际落到了 /etc

正确的判据是三条同时成立:resolve() 归一化 + sep 边界 + realpath() 解析软链后二次校验。

import { realpath } from 'node:fs/promises';

export async function assertInsideStrict(target: string, roots: string[]): Promise<string> {
  const abs = resolve(target);

  // 第一道:归一化后的字符串比对
  const normalizedRoots = roots.map(r => resolve(r));
  if (!normalizedRoots.some(r => abs === r || abs.startsWith(r + sep))) {
    throw new Error(`路径越界:${abs}`);
  }

  // 第二道:解析软链后再比一次
  // 用 try/catch:realpath 对不存在的文件会抛 ENOENT(新建文件的正常场景)
  let real: string;
  try {
    real = await realpath(abs);
  } catch {
    // 文件不存在 → 校验其父目录
    real = await realpath(resolve(abs, '..')) + sep + abs.split(sep).pop();
  }

  if (!normalizedRoots.some(r => real === r || real.startsWith(r + sep))) {
    throw new Error(`路径穿越:${abs} → ${real} 逃逸出了工作区`);
  }

  return real;
}

2.4 把 Roots 用在 Resource 列表里

Roots 最常见的用途是让 resources/list 只返回工作区内的东西,而不是无脑返回全部:

server.setRequestHandler(ListResourcesRequestSchema, async () => {
  const paths = await rootsMgr.toPaths();

  const entries = [];
  for (const root of paths) {
    for (const rel of await walkSourceFiles(root)) {
      entries.push({
        uri: pathToFileURL(resolve(root, rel)).href,
        name: rel,
        mimeType: guessMime(rel),
        description: `${root.split(sep).pop()} / ${rel}`,
      });
      if (entries.length >= 500) break;   // 硬上限,防止大仓撑爆上下文
    }
  }
  return { resources: entries };
});

三、Sampling:让服务端反向借用 LLM

3.1 它解决的根本问题

Server 需要推理能力,但不应该持有任何 API Key。

不用 Sampling 时,你要么在 Server 里塞一个 OpenAI/DeepSeek Key(用户得多配一次、多付一份钱、 还要担心 Key 明文落在某个 npm 包里),要么干脆不放推理能力。

Sampling 把这个责任反转:

维度传统做法(Server 自带 Key)Sampling
Key 归属服务端客户端(用户自己的)
模型选择服务端写死客户端决定(用户可用自己最强的模型)
账单服务端承担用户承担(合理,谁受益谁付费)
人工审批无可以插(human-in-the-loop)
上下文Server 要自己拼可以拿到客户端的对话上下文

本质上 Sampling 是一条「Server 请求 Agent 帮忙想一下」的反向通道, 而且这个 Agent 是用户已经在用的那个,模型、配额、审批全部复用现成的。

3.2 协议定义

// 服务端 → 客户端
{
  "jsonrpc": "2.0", "id": 7,
  "method": "sampling/createMessage",
  "params": {
    "messages": [
      { "role": "user", "content": { "type": "text", "text": "总结这段日志的关键错误" } }
    ],
    "systemPrompt": "你是日志分析助手,只输出 JSON",
    "includeContext": "thisServer",     // none | thisServer | allServers
    "maxTokens": 800,
    "temperature": 0.2,
    "stopSequences": ["\n\n"],
    "modelPreferences": {
      "hints": [{ "name": "claude-opus" }],   // 只是建议,客户端可忽略
      "costPriority": 0.3,      // 0-1,省钱偏好
      "speedPriority": 0.6,     // 0-1,速度偏好
      "intelligencePriority": 0.9  // 0-1,智能优先
    }
  }
}

// 客户端 → 服务端
{
  "jsonrpc": "2.0", "id": 7,
  "result": {
    "role": "assistant",
    "content": { "type": "text", "text": "{ \"errors\": [...] }" },
    "model": "claude-opus-5-5",
    "stopReason": "endTurn"
  }
}

⚠️ modelPreferences 是偏好不是命令。客户端完全有权忽略 hints 打给它自己的默认模型。 写 Server 时不要假设拿到的一定是 hints 里那个模型。

3.3 服务端实现:带重试与结构化输出

// src/sampling.ts
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';

export interface SamplingOptions {
  systemPrompt?: string;
  maxTokens?: number;
  temperature?: number;
  retries?: number;
}

/**
 * 封装一次 sampling 请求。
 * 注意:sampling 可能失败(客户端不支持 / 用户拒绝 / 客户端模型超限),
 * 必须当成「能力降级」处理,不能当成「必然成功」。
 */
export async function sampleText(
  server: Server,
  prompt: string,
  opts: SamplingOptions = {}
): Promise<string> {
  const {
    systemPrompt = '你是一个精确的文本处理助手,只输出要求的内容,不要寒暄。',
    maxTokens = 1024,
    temperature = 0.2,
    retries = 1,
  } = opts;

  let lastErr: unknown;
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await server.createMessage({
        messages: [{
          role: 'user',
          content: { type: 'text', text: prompt },
        }],
        systemPrompt,
        maxTokens,
        temperature,
        // 让客户端把「本 Server 相关的上下文」一起带上,能显著提升质量
        includeContext: 'thisServer',
        modelPreferences: {
          // ⚠️ 永远不要用 hints 表达「必须要最强的模型」,
          // 用 priority 三元组表达,客户端才好权衡
          costPriority: 0.4,
          speedPriority: 0.4,
          intelligencePriority: 0.8,
        },
      });

      if (res.content.type !== 'text') {
        throw new Error(`返回了非文本内容:${res.content.type}`);
      }
      return res.content.text;

    } catch (err: any) {
      lastErr = err;
      // -32601 MethodNotFound:客户端不支持 sampling,重试没意义
      if (err?.code === -32601) break;
      console.error(`[sampling] attempt ${attempt} failed:`, err?.message ?? err);
    }
  }

  throw new Error(
    `Sampling 不可用(客户端可能不支持或未开启):${(lastErr as Error)?.message ?? lastErr}`
  );
}

/** 需要结构化输出时用:强约束 + JSON 抽取 + 容错 */
export async function sampleJson<T>(
  server: Server,
  prompt: string,
  opts: SamplingOptions = {}
): Promise<T> {
  const text = await sampleText(server, prompt, {
    ...opts,
    systemPrompt:
      (opts.systemPrompt ?? '') +
      '\n必须输出且仅输出一个合法 JSON 对象,不要包 ``` 代码块,不要加任何解释。',
    temperature: 0,
  });

  // 模型经常包代码块,做一层宽容处理
  const cleaned = text
    .replace(/^```(?:json)?\s*/i, '')
    .replace(/\s*```$/i, '')
    .trim();

  try {
    return JSON.parse(cleaned) as T;
  } catch {
    // 二次容错:抓第一个 {...} 块
    const m = cleaned.match(/\{[\s\S]*\}/);
    if (!m) throw new Error(`Sampling 返回的不是 JSON:${text.slice(0, 200)}`);
    return JSON.parse(m[0]) as T;
  }
}

3.4 human-in-the-loop:让人卡在中间

Sampling 最有价值的设计是允许人类介入。客户端收到 createMessage 时可以弹窗给用户看。 服务端这边要做的,是让预览信息足够可读:

// 不要这样:用户看到一个无意义的 prompt
await server.createMessage({
  messages: [{ role: 'user', content: { type: 'text', text: hugePrompt } }],
});

// 要这样:把「为什么要这么做」说清楚写在 systemPrompt 里,
// 因为审批弹窗通常会展示 systemPrompt 的第一段
await server.createMessage({
  messages: [{ role: 'user', content: { type: 'text', text: content } }],
  systemPrompt: [
    '本工具正在请求一次文本归类推理,用于:给本次抓取的 37 条日志打标签。',
    '不会发生任何写操作、不会联网、不会影响你的磁盘。',
    '输出格式:JSON 数组,每项含 { level, category, one_line }。',
  ].join('\n'),
  maxTokens: 1500,
  temperature: 0,
});

四、四套客户端配置

4.1 Claude Code

claude mcp add roots-sampler -- npx -y tsx /abs/path/to/src/index.ts

配置文件(等价于上面这条命令):

// ~/.claude.json
{
  "mcpServers": {
    "roots-sampler": {
      "command": "npx",
      "args": ["-y", "tsx", "/Users/me/mcp/roots-sampler/src/index.ts"],
      "env": { "TZ": "Asia/Shanghai" }
    }
  }
}

Claude Code 对 Roots 的支持情况:支持,会把当前工作目录作为 root 声明。 Sampling:支持,且会在终端明确提示「Server 请求一次推理」,用户可拒绝。

4.2 Cursor

// ~/.cursor/mcp.json(全局)或 项目根 .cursor/mcp.json(项目级)
{
  "mcpServers": {
    "roots-sampler": {
      "command": "npx",
      "args": ["-y", "tsx", "${workspaceFolder}/mcp/roots-sampler/src/index.ts"],
      "env": {
        "TZ": "Asia/Shanghai"
      }
    }
  }
}

Cursor 的 Roots:多根工作区会声明多个 root;单项目时只有一个。 Sampling:较新版本支持。若报 Method not found,升级 Cursor 到 0.48+。

4.3 VS Code + Copilot

// .vscode/mcp.json
{
  "servers": {
    "roots-sampler": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "tsx", "${workspaceFolder}/mcp/roots-sampler/src/index.ts"],
      "env": { "TZ": "Asia/Shanghai" }
    }
  }
}

VS Code 的 Roots 是天然多根的 —— 它就是为 multi-root workspace 设计的。 Sampling 需要在设置里显式打开:

// .vscode/settings.json
{
  "mcp.sampling.enabled": true,
  "mcp.sampling.requireApproval": true   // 强烈建议开启:每次 sampling 都弹窗
}

4.4 Trae

// Trae 的 MCP 配置文件,通过 UI:设置 → MCP → 添加 → 手动配置
{
  "mcpServers": {
    "roots-sampler": {
      "command": "npx",
      "args": ["-y", "tsx", "/Users/me/mcp/roots-sampler/src/index.ts"]
    }
  }
}

Trae 对 Roots 的支持较完整;Sampling 支持程度随版本变化, 用 -32601 降级处理就不会炸。

4.5 能力检测:先问再用

不要假设客户端支持。 在 Server 启动时做一次能力探测:

import { Server } from '@modelcontextprotocol/sdk/server/index.js';

const server = new Server(
  { name: 'roots-sampler', version: '1.0.0' },
  {
    capabilities: {
      tools: {},
      resources: {},
      // ⚠️ 不要在这里声明 roots / sampling!
      // 这两个是「客户端」的能力,服务端声明无效
    },
  }
);

// 握手完成后,从 Server 侧读客户端声明的能力
server.oninitialized = async () => {
  const caps = server.getClientCapabilities();

  const hasRoots    = !!caps?.roots;     // 客户端是否支持 roots
  const hasSampling = !!caps?.sampling;  // 客户端是否支持 sampling

  console.error(`[caps] roots=${hasRoots} sampling=${hasSampling}`);

  if (!hasRoots) {
    console.error('[caps] 客户端不支持 roots,退化为 process.cwd() 单根模式');
  }
  if (!hasSampling) {
    console.error('[caps] 客户端不支持 sampling,相关工具将返回明确错误而非静默失败');
  }
};

对应的工具实现要做双分支:

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  if (req.params.name === 'summarize_file') {
    const canSample = !!server.getClientCapabilities()?.sampling;
    if (!canSample) {
      return {
        content: [{
          type: 'text',
          text: '当前 MCP 客户端未启用 Sampling 能力,无法执行摘要。' +
                '请升级客户端,或在设置中打开 mcp.sampling.enabled。',
        }],
        isError: true,
      };
    }
    // ...正常走 sampling
  }
});

五、三个实战场景

场景一:单仓守卫(Roots 的典型用途)

团队有大仓 app、共享库 shared-lib、以及一个不该被 AI 碰的配置仓 infra-secrets。 目标是:Server 物理上无法读第三个。

server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
  const target = fileURLToPath(req.params.uri);

  // 一道防线,走不通就整个拒绝
  const safe = await assertInsideStrict(target, await rootsMgr.toPaths());

  // 二道防线:显式黑名单(纵深防御,不依赖单一手段)
  const DENY = ['infra-secrets', '.env', 'id_rsa', '.aws/credentials'];
  if (DENY.some(d => safe.includes(d))) {
    throw new Error('该路径属于敏感目录,Server 层面拒绝读取');
  }

  return { contents: [{ uri: req.params.uri, text: await readFile(safe, 'utf8') }] };
});

关键点:Roots 是第一道也是最重要的一道。 客户端只声明了 app 和 shared-lib 两个 root,infra-secrets 在 Server 眼里根本不存在, 连 prompt 注入诱导越权的机会都没有。

场景二:日志归因摘要(Sampling 的典型用途)

Server 提供一个 analyze_logs 工具:读日志 → 用客户端的 LLM 归因 → 返回结构化结论。

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  if (req.params.name !== 'analyze_logs') return;
  const { path, topN = 10 } = req.params.arguments as any;

  const safe = await rootsMgr.assertInside(path);
  const raw = await readFile(safe, 'utf8');
  const lines = raw.split('\n').filter(l => /ERROR|FATAL/.test(l)).slice(-200);

  // 反向借用客户端 LLM —— 服务端没有任何 Key
  const verdict = await sampleJson<{
    clusters: { category: string; count: number; root_cause: string; evidence: string }[];
  }>(server, [
    '以下是服务端日志中的错误行(按时间顺序,最多 200 行)。',
    '请做归因聚类,相同根因的合并为一类,按出现次数降序,最多输出 ' + topN + ' 类。',
    'evidence 必须是原文里真实存在的一行,不要改写。',
    '',
    '日志原文:',
    '```',
    lines.join('\n'),
    '```',
  ].join('\n'), {
    systemPrompt: '你是 SRE 日志归因专家。输出 JSON:{ clusters: [{category, count, root_cause, evidence}] }',
    maxTokens: 2000,
  });

  return {
    content: [{
      type: 'text',
      text: [
        `共 ${lines.length} 条错误行,归因为 ${verdict.clusters.length} 类:`,
        '',
        ...verdict.clusters.map((c, i) =>
          `${i + 1}. [${c.category}] ×${c.count}\n   根因判断:${c.root_cause}\n   证据:${c.evidence}`
        ),
      ].join('\n'),
    }],
  };
});

这个场景的好处非常直白:用户在 Claude Code 里配的是 Opus 5.5,Server 就用 Opus 5.5 来归因。 换个人用 Cursor + Flash,同一个 Server 就用 Flash。Server 代码一行不用改。

场景三:隐私脱敏代理(Roots + Sampling 组合)

Server 读文件后要在返回给 LLM 之前脱敏,用自身的正则 Cover 不够(身份证、手机号、内网 IP 好办), 但「人名」「项目代号」这类需要语义判断。Sampling 正好补这一环。

async function redactSmart(text: string): Promise<string> {
  // 第一层:确定性规则,快且零成本
  let out = text
    .replace(/\b1[3-9]\d{9}\b/g, '<PHONE>')
    .replace(/\b\d{17}[\dXx]\b/g, '<ID_CARD>')
    .replace(/\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, '<INTERNAL_IP>');

  // 第二层:语义脱敏,交给客户端 LLM(注意:这一步本身会把文本送给 LLM,
  // 所以只适用于「允许喂给 API」的内容;真正敏感的应当走纯本地规则)
  const found = await sampleJson<{ items: { type: string; value: string }[] }>(
    server,
    `找出下面文本里属于「人名」「公司名」「内部项目代号」的实体。输出 JSON:{items:[{type,value}]}\n\n${out.slice(0, 8000)}`,
    { maxTokens: 800, systemPrompt: '你是隐私合规助手。只输出 JSON。' }
  );

  for (const it of found.items) {
    out = out.split(it.value).join(`<${it.type.toUpperCase()}>`);
  }
  return out;
}

⚠️ 这个场景有一条重要警告:Sampling 会把内容送到客户端的 LLM API。 如果内容真的敏感,不要用 Sampling 脱敏 —— 用本地确定性规则 + 不出网。 上面的写法只适用于「内容可以喂给 API,但不想在对话历史里明文留存」的场景。


六、选型对比:什么时候该用哪个

方案Key 归属离线可用人工审批适用
Server 自带 Key服务端❌❌服务端形态的产品、客服机器人
Sampling客户端❌✅ 可MCP Server 的首选
纯规则(无 LLM)—✅—脱敏、格式化、确定性抽取
本地小模型(Ollama)服务端✅❌隐私红线、离线环境

经验法则:

  1. 默认走 Sampling —— 用户付费、用户选模型、用户能审批,三方都合理
  2. 涉及隐私红线的内容,绝不用 Sampling —— 它会把数据送上 API
  3. 纯确定性变换不要引入 LLM —— 正则比 LLM 快 100 倍且结果可复现
  4. 服务端是产品形态(多租户 SaaS)时,只能自带 Key —— 你不能让用户为每个租户配一次

七、安全注意事项

  1. Roots 是可信边界不是安全措施 —— Roots 由客户端声明。恶意客户端可以声明 file:///。 它防御的是「误操作」和「prompt 注入诱导越界」,不防御「客户端本身就是恶意的」
  2. 必须做 realpath 二次校验 —— 只看 resolve() 会被符号链接穿越绕过
  3. .. 必须 normalize 后判断 —— 服务端永远不要相信客户端传来的相对路径
  4. Sampling 会出网 —— 服务端不小心把敏感内容塞进 prompt,就等于把它发给了 API。要在注释里写清楚
  5. includeContext: 'allServers' 慎用 —— 会把其他 Server 的上下文也拉进来,既是成本黑洞也是泄露面
  6. 给 sampling 加高频限制 —— 恶意 Server 可以疯狂 createMessage 烧用户的钱。客户端侧要限流, 服务端侧则应主动限制每轮工具的 sampling 调用次数(建议 ≤ 3)
// 建议加一道计数
let sampleCount = 0;
function guardSampling() {
  if (++sampleCount > 3) {
    throw new Error('单次工具调用内的 Sampling 次数超过上限(3),疑似异常,已终止');
  }
}

八、故障排查表

#现象根因解法
1roots/list 报 -32601 Method not found客户端不支持 roots捕获该 code,降级到 process.cwd() 而不是崩溃
2roots 返回空数组客户端没有打开任何工作区(空窗口)提示用户先打开文件夹;或降级到 cwd
3打开新文件夹后 Server 还在搜旧目录没监听 roots/list_changed注册 NotificationHandler 并清空缓存
4file:///home/user/project-evil 被判为合法只做了 startsWith 字符串前缀必须 + sep 或判 ===,见 2.3
5软链能读到工作区外的文件没做 realpath 二次校验加第二道 realpath 校验,见 2.3
6新建文件时 realpath 抛 ENOENT该文件尚不存在catch 住 ENOENT,改校验其父目录
7sampling/createMessage 报 -32601客户端不支持 sampling双分支返回明确提示,让用户升级/开关
8Sampling 返回 _meta 但 content 为空客户端模型被限流返回了空maxTokens 调大;重试一次;给兜底文本
9Sampling 返回包了 ```json 代码块模型不遵守「只输出 JSON」用宽容解析(去代码块 + 正则抽首个 {...})
10Sampling 结果每次都不一样temperature 太高结构化任务强制 temperature: 0
11hints 写的模型没生效hints 只是建议,客户端有权忽略不要用 hints 表达硬性要求,改用 priority 三元组
12VS Code 里 sampling 报权限错设置里没开.vscode/settings.json 加 mcp.sampling.enabled: true
13Sampling 花费失控Server 循环调用 createMessage服务端加次数上限;客户端侧也限流
14Server 声明了 roots/sampling 能力却无效这两个是客户端能力,服务端声明无效从 getClientCapabilities() 读,不要写在服务端 capabilities 里

九、小结

能力一句话最典型用途
Roots客户端告诉服务端工作区边界多仓守卫、resources 范围限定、路径穿越防御
Sampling服务端反向借用客户端的 LLM日志归因、语义抽取、分类打标、智能脱敏

写 MCP Server 时的三个必做:

  1. 启动即探测能力(getClientCapabilities()),不支持就降级,绝不假设支持
  2. Roots 校验写一个 assertInsideStrict,带 realpath 第二道,全项目复用
  3. Sampling 包一层 sampleJson,带去代码块 + 正则兜底,temperature 给 0

做完这三件事,你的 Server 才算从「带鉴权的 RPC」跨到了「有边界、能思考」的那一侧。


相关阅读

相关推荐

查看更多 →
MCP开发

MCP Elicitation 实战:让服务端在缺参数时反向向用户提问——受限 Schema 设计 + 四套客户端配置 + 13 行排查表

写 MCP Server 的人迟早会遇到一个尴尬场景:工具必填参数用户没给,服务端要么猜一个(猜错就出事),要么返回一个干巴巴的「缺少参数 xx」让 Agent 自己去追问(Agent 经常追问得驴唇不对马嘴)。MCP 协议里的 Elicitation 就是为这个场景设计的第三个「反向能力」——服务端可以在执行过程中主动向客户端发起一次提问,由客户端把问题渲染成表单弹给用户,用户填完再回传,全程服务端不需要自己实现任何 UI、不需要存任何凭据。本文讲透它的机制与边界:elicitation/create 请求的完整结构、为什么 requestedSchema 被刻意限制成只支持原始类型(这是安全设计不是能力不足)、用户的三种响应(accept / decline / cancel)各自的语义差异、以及最关键的——为什么不能在 Elicitation 里索取密码和 API Key。含 TypeScript SDK 完整实现(含类型定义与降级路径)、Claude Code / Cursor / VS Code + Copilot / Trae 四套带中文注释的配置、三个实战场景(部署确认 / 缺失参数补全 / 敏感操作二次确认)、与 Sampling / 传统参数校验的选型对比、六条安全注意事项,以及 13 行故障排查表。

2026-10-12
#MCP#Model Context Protocol
MCP开发

MCP 无状态化改造:协议层不再存状态之后——并发、水平扩容、断线重连三大硬伤的根治方案与四套客户端配置

2026 年 MCP 协议经历了一次方向性的改动:核心交互不再依赖服务端保存的会话状态,状态不再存储在协议层,而是被甩给应用层处理。对写过有状态 MCP Server 的人来说,这次改动的直接感受是「少了一大堆坑」——会话粘连导致的多实例不可用、水平扩容一扩就断连、断线重连要么报错要么整段重放。本文讲清楚三件事:为什么协议层存状态在生产环境必然出问题(并发、扩容、重连三个硬伤,各配一组实测对照数据);无状态化之后你手里的工具清单发生了什么变化(transport 类、协议方法、Serverless 适配);以及状态外置的三种标准实现(外部存储、客户端持有、资源句柄),每种都给出可直接跑的 TypeScript SDK 代码。含 Claude Code / Cursor / VS Code / Trae 四套带中文注释的完整配置、三个实战场景、选型对比表、安全注意事项(含 DNS 重绑定的必修项),以及 14 行故障排查表。

2026-10-08
#MCP#Model Context Protocol
MCP编程开发

MCP Resources 实战:让服务端把 8 万行设计文档喂给 Agent,附四套客户端配置与13 行排查表

Tools 是「Agent 问你答」,Resources 是「你问Agent 答」——同一个 MCP Server 里,方向相反的两种能力,而且很多人在写 Server 时只实现了 Tools,漏掉了 Resources。结果是文档、规范、schema、日志这类只读数据只能靠 Agent 一个个调 Tool 去捞,每次捞都要过一次模型判断,成本高还不稳定。本文讲透MCP 四大原语里的第二个:Resources。涵盖 resources/list、resources/read、resources/templates/list 三个方法与 URI 模板机制(RFC 6570 展开规则),以及 Resource 与 Tool 的选型对照(把同一份数据做成 Tool 还是 Resource 的实测差异)。给出一个可运行的「设计文档中心」Server 完整实现(含 40+ 行 TypeScript,含分页、版本快照、访问审计),以及 Claude Code / Cursor / VS Code + Copilot / Trae 四套带中文注释的配置 JSON。三个实战场景:Agent 读设计文档回答「这个接口为什么这么设计」、按版本读取历史 schema 做迁移校验、把资源目录挂进 IDE 侧边栏。另含资源数量膨胀到 500+ 时的分页与 URI 命名规范、五条安全红线(为什么资源 URI 绝不能暴露绝对路径和凭据),以及 13 行故障排查表。

2026-10-13
#MCP#Model Context Protocol
MCP开发

Playwright MCP:让 AI 真正会操作浏览器——快照模式、隔离配置与安全红线

2026 年 9 月 26 日 OpenAI 发布 GPT-6 Astra,主打自主操作计算机;一天后 OpenAI 与 Anthropic 披露正在调查数万起 AI 智能体自主入侵网站、使用失窃凭证的事件。这两件事合在一起说明:2026 年 Agent 的能力边界,取决于它能操作什么系统,而不只是模型有多大。Playwright MCP 是这条路上最实用的一块拼图——它把微软 Playwright 的浏览器自动化能力通过 MCP 协议暴露给 AI,让模型能真正打开页面、点击、填表、抓取,而不是靠你复制粘贴。与「截图 + 模型猜坐标」的计算机操作路线不同,Playwright MCP 走的是**可访问性树快照**路线:把页面结构转成带引用的文本,模型按引用操作元素,token 消耗更低、定位更准、不依赖视觉。本文覆盖:两种工作模式(Snapshot / Vision)的取舍与实测对比、完整工具清单、三种运行方式(本地 stdio / 远程 Docker / 托管云)与四套客户端配置、企业级隔离配置(独立 profile、网络白名单、凭证隔离)、三个真实场景(UI 回归自愈、无 API 后台取数、竞品监控)、成本优化(快照裁剪 -60% token)、CI 集成,以及 18 条故障排查表。**安全部分占全文四分之一,因为浏览器自动化是提示注入的重灾区。**

2026-09-28
#MCP#Playwright