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 → Client | resources | 服务端 | 客户端 | 给模型喂结构化内容 |
| Server → Client | tools | 服务端 | 客户端 | 让模型调用外部动作 |
| Server → Client | prompts | 服务端 | 客户端 | 复用提示模板 |
| Client → Server | roots | 客户端 | 服务端 | 告诉服务端工作区边界 |
| Client → Server | sampling | 客户端 | 服务端 | 让服务端借用 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) | 服务端 | ✅ | ❌ | 隐私红线、离线环境 |
经验法则:
- 默认走 Sampling —— 用户付费、用户选模型、用户能审批,三方都合理
- 涉及隐私红线的内容,绝不用 Sampling —— 它会把数据送上 API
- 纯确定性变换不要引入 LLM —— 正则比 LLM 快 100 倍且结果可复现
- 服务端是产品形态(多租户 SaaS)时,只能自带 Key —— 你不能让用户为每个租户配一次
七、安全注意事项
- Roots 是可信边界不是安全措施 —— Roots 由客户端声明。恶意客户端可以声明
file:///。 它防御的是「误操作」和「prompt 注入诱导越界」,不防御「客户端本身就是恶意的」 - 必须做
realpath二次校验 —— 只看resolve()会被符号链接穿越绕过 ..必须 normalize 后判断 —— 服务端永远不要相信客户端传来的相对路径- Sampling 会出网 —— 服务端不小心把敏感内容塞进 prompt,就等于把它发给了 API。要在注释里写清楚
includeContext: 'allServers'慎用 —— 会把其他 Server 的上下文也拉进来,既是成本黑洞也是泄露面- 给 sampling 加高频限制 —— 恶意 Server 可以疯狂 createMessage 烧用户的钱。客户端侧要限流, 服务端侧则应主动限制每轮工具的 sampling 调用次数(建议 ≤ 3)
// 建议加一道计数
let sampleCount = 0;
function guardSampling() {
if (++sampleCount > 3) {
throw new Error('单次工具调用内的 Sampling 次数超过上限(3),疑似异常,已终止');
}
}
八、故障排查表
| # | 现象 | 根因 | 解法 |
|---|---|---|---|
| 1 | roots/list 报 -32601 Method not found | 客户端不支持 roots | 捕获该 code,降级到 process.cwd() 而不是崩溃 |
| 2 | roots 返回空数组 | 客户端没有打开任何工作区(空窗口) | 提示用户先打开文件夹;或降级到 cwd |
| 3 | 打开新文件夹后 Server 还在搜旧目录 | 没监听 roots/list_changed | 注册 NotificationHandler 并清空缓存 |
| 4 | file:///home/user/project-evil 被判为合法 | 只做了 startsWith 字符串前缀 | 必须 + sep 或判 ===,见 2.3 |
| 5 | 软链能读到工作区外的文件 | 没做 realpath 二次校验 | 加第二道 realpath 校验,见 2.3 |
| 6 | 新建文件时 realpath 抛 ENOENT | 该文件尚不存在 | catch 住 ENOENT,改校验其父目录 |
| 7 | sampling/createMessage 报 -32601 | 客户端不支持 sampling | 双分支返回明确提示,让用户升级/开关 |
| 8 | Sampling 返回 _meta 但 content 为空 | 客户端模型被限流返回了空 | maxTokens 调大;重试一次;给兜底文本 |
| 9 | Sampling 返回包了 ```json 代码块 | 模型不遵守「只输出 JSON」 | 用宽容解析(去代码块 + 正则抽首个 {...}) |
| 10 | Sampling 结果每次都不一样 | temperature 太高 | 结构化任务强制 temperature: 0 |
| 11 | hints 写的模型没生效 | hints 只是建议,客户端有权忽略 | 不要用 hints 表达硬性要求,改用 priority 三元组 |
| 12 | VS Code 里 sampling 报权限错 | 设置里没开 | .vscode/settings.json 加 mcp.sampling.enabled: true |
| 13 | Sampling 花费失控 | Server 循环调用 createMessage | 服务端加次数上限;客户端侧也限流 |
| 14 | Server 声明了 roots/sampling 能力却无效 | 这两个是客户端能力,服务端声明无效 | 从 getClientCapabilities() 读,不要写在服务端 capabilities 里 |
九、小结
| 能力 | 一句话 | 最典型用途 |
|---|---|---|
| Roots | 客户端告诉服务端工作区边界 | 多仓守卫、resources 范围限定、路径穿越防御 |
| Sampling | 服务端反向借用客户端的 LLM | 日志归因、语义抽取、分类打标、智能脱敏 |
写 MCP Server 时的三个必做:
- 启动即探测能力(
getClientCapabilities()),不支持就降级,绝不假设支持 - Roots 校验写一个
assertInsideStrict,带realpath第二道,全项目复用 - Sampling 包一层
sampleJson,带去代码块 + 正则兜底,temperature 给 0
做完这三件事,你的 Server 才算从「带鉴权的 RPC」跨到了「有边界、能思考」的那一侧。
相关阅读
- MCP 无状态化改造 —— 会话管理、水平扩容、断线重连
- Docker MCP —— 容器场景下的 Server 部署
- DeepSeek V4.1 Flash 接入 Cursor 完整指南 —— 在哪配这套 Server