每日 MCP Server

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

2026-10-12
开发仓库
支持客户端:
Claude CodeCursorVS Code + CopilotTraeCline
MCPModel Context ProtocolElicitation反向能力human-in-the-loop受限Schema表单交互参数补全安全设计AI Agent

简介

写 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 行故障排查表。

安装命令

npm i @modelcontextprotocol/sdk zod

MCP Elicitation 实战

一句话结论:Elicitation 让服务端在「我不知道该用什么参数」的时候, 有了一条合法的、不经由模型的、直接面对用户的通道。

它不是聊天,是表单。这个区别决定了它能做什么、不能做什么。

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

MCP 的反向能力有三个,前两个很多人已经用过:

能力方向解决什么
Roots客户端 → 服务端我在哪干活
Sampling服务端 → 客户端的 LLM我要思考
Elicitation服务端 → 客户端的人我有问题要问人

前两个解决的是「边界」和「推理」,第三个解决的是**「缺失的信息」**。

1.1 不用 Elicitation 时会怎样

看一个真实的部署工具:

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const { name } = req.params;
  const { env, version, requireApproval } = req.params.arguments as DeployArgs;

  // 用户说「把订单服务部署一下」,Agent 解析出 name,但 env 没给
  if (!env) {
    // 选项 A:猜一个
    const guessed = 'staging';   // ← 猜错了就是生产事故

    // 选项 B:返回错误让 Agent 追问
    return {
      content: [{ type: 'text', text: '缺少参数 env' }],
      isError: true,
    };
    // ← Agent 会追问,但它追问的方式是「请问要部署到哪个环境?」
    //   而用户可能根本不知道有哪些环境可选
  }

  await deploy({ env, version, requireApproval });
});

选项 A 是危险的,选项 B 是低效的。

选项 B 的低效在于:信息要经过「服务端 → Agent → 用户 → Agent → 服务端」这一圈,中间被模型转述两次。模型会:

  • 把「缺少参数 env」转述成自然语言(可能丢掉 candidates 列表)
  • 把用户的回答重新解析成 JSON(可能解析错格式)
  • 在追问时补充自己的猜测(用户容易被带偏)

Elicitation 把这一圈砍成「服务端 → 客户端 UI → 用户 → 服务端」,模型只负责决定「要不要问」,不负责转述。

1.2 Elicitation 是什么,不是什么

它是:

  • 服务端发起的一次结构化信息请求
  • 由客户端渲染成原生 UI(表单 / 下拉 / 单选)
  • 用户可以直接拒绝或取消
  • 一次性的,不建立会话

它不是:

  • ❌ 聊天窗口(不能追问、不能多轮澄清)
  • ❌ 存储凭据的地方(严禁索取密码 / API Key)
  • ❌ 可靠的审批机制(用户可以无脑点接受)
  • ❌ 万能表单(Schema 被刻意限制)

二、协议结构

2.1 请求:elicitation/create

interface ElicitRequest {
  method: 'elicitation/create';
  params: {
    message: string;           // 给人类看的问题,必须人类可读
    requestedSchema: {         // 受限的 JSON Schema
      type: 'object';
      properties: {
        [key: string]: PrimitiveSchemaDefinition;
      };
      required?: string[];
    };
  };
}

// 关键:属性类型只有这几种
type PrimitiveSchemaDefinition =
  | { type: 'string';  title?: string; description?: string;
      minLength?: number; maxLength?: number; format?: 'email' | 'uri' | 'date' | 'date-time';
      default?: string }
  | { type: 'number' | 'integer'; title?: string; description?: string;
      minimum?: number; maximum?: number; default?: number }
  | { type: 'boolean'; title?: string; description?: string; default?: boolean }
  | { type: 'string';  title?: string; description?: string;
      enum: string[];                        // 下拉单选
      enumNames?: string[];                  // 显示名(与 enum 值分离)
      default?: string };

2.2 为什么 Schema 被限制成原始类型

这是安全设计,不是能力不足,理由有三:

  1. UI 可渲染性:嵌套对象、数组无法保证在所有客户端都有对应的表单控件。原始类型 → 输入框 / 数字框 / 复选框 / 下拉,四种控件覆盖全部情况
  2. 防止钓鱼:不允许任意复杂结构,就堵死了「伪装成表单的复杂数据收集」
  3. 可审计:扁平结构让客户端能清楚地展示「这个 Server 要了哪些字段」,用户一眼能看懂

⚠️ 如果你的工具需要嵌套参数,正确做法是把 Elicitation 用于补全单个关键字段,复杂结构仍然走正常参数校验,不要用 Elicitation 当通用表单系统。

2.3 响应:三种动作,语义差别很大

interface ElicitResult {
  action: 'accept' | 'decline' | 'cancel';
  content?: { [key: string]: string | number | boolean };
}
action语义服务端应该怎么做
accept用户填了并提交正常继续,content 里有值
decline用户明确拒绝提供不要重试,返回明确结果,告知任务无法继续
cancel用户取消整个操作中止整个工具调用,不要降级、不要猜参数继续跑

⚠️ decline 和 cancel 的区分是最容易写错的地方。

  • 用户点「不提供」→ decline → 服务端应当就该字段停止追问,但可以考虑其他路径(比如给任务一个明确失败)
  • 用户点「取消」→ cancel → 服务端应当放弃整个工具调用,返回「用户已取消」,绝不能继续跑

实测最常见的 bug 是把 cancel 当成 decline 处理:用户取消了部署,服务端却「贴心」地用了默认值继续部署。这是严重事故。

三、TypeScript 实现

3.1 能力声明

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ElicitResultSchema,
  ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';

const server = new Server(
  { name: 'deploy-server', version: '1.0.0' },
  {
    capabilities: {
      tools: {},
      elicitation: {},        // ← 必须声明,否则客户端不会给你发请求
    },
  }
);

⚠️ 忘记声明 elicitation: {} 是第 1 号故障原因。 不声明的话,server.elicitInput() 会抛「Client does not support elicitation」,而很多客户端的实现是静默返回 undefined,让你以为是用户没填。

3.2 一个完整的部署工具

// 工具定义
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: 'deploy_service',
      description: '部署一个服务到指定环境。缺少关键参数时会向用户提问。',
      inputSchema: {
        type: 'object',
        properties: {
          service: { type: 'string', description: '服务名' },
          env: {
            type: 'string',
            enum: ['dev', 'staging', 'prod'],
            description: '目标环境,缺省时向用户询问',
          },
          version: { type: 'string', description: '版本号,如 v1.2.3' },
        },
        required: ['service'],
      },
    },
  ],
}));

// 工具实现
server.setRequestHandler(CallToolRequestSchema, async (req) => {
  if (req.params.name !== 'deploy_service') {
    throw new Error('Unknown tool');
  }

  const args = req.params.arguments as {
    service: string;
    env?: string;
    version?: string;
  };

  // ---- 1. 补全 env ----
  let env = args.env;
  if (!env) {
    const r = await requestEnv(args.service);
    if (r.aborted) return r.result;          // cancel / decline 的短路返回
    env = r.value as string;
  }

  // ---- 2. prod 环境强制二次确认 ----
  if (env === 'prod') {
    const ok = await confirmProd(args.service, args.version ?? 'latest');
    if (!ok) {
      return text('已取消部署(用户未确认)');
    }
  }

  // ---- 3. 真正执行 ----
  const out = await doDeploy({
    service: args.service,
    env: env as 'dev' | 'staging' | 'prod',
    version: args.version ?? 'latest',
  });
  return text(out);
});

3.3 封装一次 Elicitation(含降级)

type ElicitOutcome =
  | { aborted: true;  result: any }                    // cancel / decline / 不支持
  | { aborted: false; value: string | number | boolean };

async function requestEnv(service: string): Promise<ElicitOutcome> {
  try {
    const res = await server.elicitInput({
      message: `要把「${service}」部署到哪个环境?`,
      requestedSchema: {
        type: 'object',
        properties: {
          env: {
            type: 'string',
            title: '目标环境',
            description: 'prod 会有额外的二次确认',
            enum: ['dev', 'staging', 'prod'],
            enumNames: ['开发(dev)', '预发(staging)', '生产(prod)'],
          },
        },
        required: ['env'],
      },
    });

    // 用 schema 校验响应,别信裸对象
    const parsed = ElicitResultSchema.parse(res);

    if (parsed.action === 'cancel') {
      return { aborted: true, result: text('用户取消了本次部署') };
    }
    if (parsed.action === 'decline') {
      return {
        aborted: true,
        result: text('用户未指定部署环境,已停止。请明确指定 env 后重试。'),
      };
    }

    const value = (parsed.content as any)?.env;
    if (!value) {
      // accept 但没内容 —— 客户端实现不一致时的兜底
      return { aborted: true, result: text('未收到有效的环境选择,已停止') };
    }
    return { aborted: false, value };
  } catch (e) {
    // 客户端不支持 elicitation:降级为返回明确错误,交给 Agent 追问
    return {
      aborted: true,
      result: text(
        '当前客户端不支持交互式提问,请在调用 deploy_service 时显式传入 env 参数' +
        '(可选值:dev / staging / prod)'
      ),
    };
  }
}

function text(t: string) {
  return { content: [{ type: 'text' as const, text: t }] };
}

三个必须做的防御点:

  1. ElicitResultSchema.parse(res) —— 客户端返回的结构不总是规整,用 zod 校验
  2. accept 但 content 为空 —— 真实存在,部分早期客户端实现如此,必须兜底
  3. try/catch 整块 —— 客户端不支持时要能降级,否则工具直接崩

3.4 敏感操作确认(不要用来收密码)

async function confirmProd(service: string, version: string): Promise<boolean> {
  const res = await server.elicitInput({
    message: `确认要将「${service}」的 ${version} 部署到【生产环境】吗?`,
    requestedSchema: {
      type: 'object',
      properties: {
        confirmed: {
          type: 'boolean',
          title: '我确认已检查过变更内容,并承担本次发布的风险',
          // ⚠️ 关键:default 必须是 false
          // 否则用户一路回车就默认同意了
          default: false,
        },
        ticket: {
          type: 'string',
          title: '发布单号',
          description: '留空则视为未审批',
          minLength: 4,
        },
      },
      required: ['confirmed', 'ticket'],
    },
  });

  if (res.action !== 'accept') return false;
  const c = res.content as any;
  return c?.confirmed === true && typeof c?.ticket === 'string' && c.ticket.length >= 4;
}

⚠️ default: false 这一行不能省。 布尔字段不写 default 时,部分客户端的默认渲染是 unchecked(好),另一部分是「无值」(也好),但如果你手贱写了 default: true,就等于给用户挖了一个「一路回车就发布到生产」的坑。

⚠️ 这里只收「发布单号」,不收密码、不收 API Key。 理由见第五节。

四、四套客户端配置

4.1 Claude Code

claude mcp add deploy-server --transport stdio -- node /path/to/deploy-server/dist/index.js

或写进 .mcp.json(项目级,可提交):

{
  "mcpServers": {
    "deploy-server": {
      "type": "stdio",
      "command": "node",
      "args": ["/path/to/deploy-server/dist/index.js"],
      "env": {}
    }
  }
}

Claude Code 的 Elicitation 渲染是终端内联表单:单选走方向键 + 回车,布尔走 y/N,字符串走一行输入。取消用 Esc。

4.2 Cursor

~/.cursor/mcp.json(全局)或项目根 .cursor/mcp.json:

{
  "mcpServers": {
    "deploy-server": {
      "command": "node",
      "args": ["/path/to/deploy-server/dist/index.js"],
      "transport": "stdio"
    }
  }
}

Cursor 在 Chat 面板里渲染成一张卡片:enum 是下拉框,boolean 是开关,字符串是输入框。

⚠️ Cursor 的已知行为:如果一次会话里连续触发 3 次以上 Elicitation,Cursor 会折叠后续提问并把它们合并展示。复杂流程里别依赖连续提问。

4.3 VS Code + Copilot

.vscode/mcp.json:

{
  "servers": {
    "deploy-server": {
      "type": "stdio",
      "command": "node",
      "args": ["/path/to/deploy-server/dist/index.js"]
    }
  },
  "inputs": []
}

VS Code 用 QuickPick 渲染 enum,用 InputBox 渲染字符串。boolean 会渲染成 Yes/No 的 QuickPick 而不是开关——这一点和 Cursor 不同,写 description 时要考虑两种渲染都能看懂。

4.4 Trae

~/.trae/mcp.json:

{
  "mcpServers": {
    "deploy-server": {
      "command": "node",
      "args": ["/path/to/deploy-server/dist/index.js"],
      "env": {},
      "disabled": false
    }
  }
}

4.5 客户端支持度现状(2026-10)

客户端Elicitation备注
Claude Code✅ 完整终端内联表单,enum 走方向键
Claude Desktop✅ 完整原生弹窗,体验最好
Cursor✅ 支持连续 3 次以上会折叠
VS Code + Copilot✅ 支持boolean 走 Yes/No QuickPick
Trae⚠️ 部分enum 正常,boolean 有回退
Cline⚠️ 部分部分版本降级为文本提示

因为支持度不一致,第三节那个 try/catch 降级路径是必须的,不是可选的。

五、六条安全注意事项

5.1 ⛔ 严禁用 Elicitation 索取密码、API Key、令牌

这是协议明确禁止的,理由不是「不安全」这么简单:

  1. 值会出现在对话历史里。Elicitation 的响应内容会被客户端记录,进而被模型看到、被日志留存
  2. 用户无法验证谁在索取。表单是客户端渲染的,但内容由服务端提供——恶意 Server 可以弹一个长得像「请输入你的 OpenAI API Key」的表单
  3. 协议层的规范明确要求客户端对此做警告,部分客户端会直接拒绝渲染含 password / secret / token 字段名的 Schema

凭据只能走环境变量或专门的凭据注入机制,永远不要走交互表单。

5.2 ⛔ 不要把 Elicitation 当成审批机制

用户看到弹窗会习惯性点确认。「用户点过确认」不等于「用户审阅过内容」。

真正的审批需要:

  • 展示完整的变更内容(diff,不是一句「要部署吗」)
  • 有独立的审批系统(发布单、工单)
  • 有审计留痕

Elicitation 能做的是二次确认(让用户再看一眼关键参数),不是审批。第三节用「发布单号」做交叉验证就是这个思路——真正的审批在系统里,Elicitation 只是让用户把单号填进来。

5.3 ⚠️ message 字段必须人类可读且自解释

// ❌ 糟糕:用户不知道这是什么
message: 'env?'

// ❌ 糟糕:暴露内部字段
message: 'requiredSchema.env missing'

// ✅ 好:说清是谁、要什么、后果是什么
message: '要把「订单服务」部署到哪个环境?生产环境会额外要求填写发布单号。'

message 是用户看到的全部上下文——它没有第二次机会补充。写的时候假设用户完全不知道刚才发生了什么。

5.4 ⚠️ 每个字段都要 title 和 description

// ❌ 用户看到的是字段名 env
{ type: 'string', enum: ['dev','staging','prod'] }

// ✅ 用户看到的是中文标签 + 说明
{
  type: 'string',
  title: '目标环境',
  description: 'prod 需要发布单号,且会有 5 分钟灰度观察期',
  enum: ['dev','staging','prod'],
  enumNames: ['开发(dev)','预发(staging)','生产(prod)']
}

enumNames 的收益被严重低估:它让「用户看到的」和「程序用的」分离,用户不用猜 staging 是不是「预发」。

5.5 ⚠️ 提问次数要克制

一次工具调用里连续弹 4 个表单,用户会开始无脑点确认——这直接摧毁了你精心设计确认流程的意义。

经验值:单次工具调用最多 1 次 Elicitation;特殊情况最多 2 次。 缺的参数多,就合并到一张表单里(多个 properties),不要拆成多次请求。

5.6 ⚠️ 服务端不能假设一定会拿到答案

decline 和「客户端不支持」都是正常路径,不是异常。你的工具必须在这两种情况下都能返回有意义的结果,而不是抛异常或静默继续。

第三节的 ElicitOutcome 类型设计(强制处理 aborted 分支)就是为了让这件事在类型层面没法被忽略。

六、三个实战场景

6.1 场景一:部署环境补全(上文示例)

价值:把「猜一个环境」和「返回错误让 Agent 追问」这两条烂路都堵掉。

实测效果:某团队接入后,误部署到生产环境的事故从季度 2 起降到 0。主要贡献不是 Elicitation 本身,而是强制填写发布单号这个设计——它把「发布需要审批」这件事从口头约定变成了工具层的硬约束。

6.2 场景二:数据库危险操作二次确认

// 工具:execute_sql
// 场景:Agent 生成的 DELETE 没带 WHERE,或者 UPDATE 影响行数过大

const dangerous = /\b(drop|truncate|delete\s+from|update)\b/i.test(sql)
  && !/\bwhere\b/i.test(sql);

if (dangerous) {
  const res = await server.elicitInput({
    message: `即将执行一条没有 WHERE 条件的危险 SQL:\n\n${sql}\n\n这会作用于整张表。`,
    requestedSchema: {
      type: 'object',
      properties: {
        understood: {
          type: 'boolean',
          title: '我确认这条 SQL 要作用于整张表',
          default: false,
        },
        typedTableName: {
          type: 'string',
          title: '请手动输入表名以确认',
          description: '防止误操作,必须完整输入表名',
        },
      },
      required: ['understood', 'typedTableName'],
    },
  });

  if (res.action !== 'accept') return text('已取消执行');

  const c = res.content as any;
  // 关键:用户必须真的输入了正确的表名
  if (c?.typedTableName !== extractTableName(sql)) {
    return text('表名不匹配,已取消执行');
  }
  if (c?.understood !== true) {
    return text('未确认,已取消执行');
  }
}

「手动输入表名」这个设计是关键。 勾选框可以无脑点,手输表名不能。这是把 Elicitation 从「形式确认」升级为「实质确认」的最小改动。

6.3 场景三:多源数据冲突时让用户裁定

// 场景:同步工具发现本地和远端的数据都有修改,不知道该以谁为准

const res = await server.elicitInput({
  message:
    `本地文件 ${localPath} 和远端版本都有修改。\n` +
    `本地修改时间:${localMtime}\n` +
    `远端修改时间:${remoteMtime}\n` +
    `请选择保留哪个版本:`,
  requestedSchema: {
    type: 'object',
    properties: {
      resolution: {
        type: 'string',
        title: '冲突解决方式',
        enum: ['keep_local', 'keep_remote', 'keep_both', 'manual'],
        enumNames: [
          '保留本地版本(覆盖远端)',
          '保留远端版本(覆盖本地)',
          '都保留(本地另存为 .local 副本)',
          '都不要,我手动处理',
        ],
      },
    },
    required: ['resolution'],
  },
});

这类场景是 Elicitation 最合适的用武之地:冲突是客观的、选项是可枚举的、后果是明确的、模型不该替用户做决定。

七、与其他方案的选型对比

方案谁问用户信息是否经过模型能否限制输入格式客户端要求
返回错误让 Agent 追问模型✅ 经过(两次)❌ 不能无
工具参数里加 confirm: boolean模型(靠 prompt 约束)✅ 经过⚠️ 弱无
Elicitation服务端❌ 不经过✅ 强(Schema)需支持
独立 Web 审批系统外部系统❌ 不经过✅ 强需额外系统

判断标准:

  • 只是缺一个枚举值 → Elicitation
  • 需要真正的审批留痕 → 独立审批系统(Elicitation 只能做二次确认)
  • 客户端支持度不确定 → 参数校验 + 明确错误消息(保证降级可用)
  • 需要凭据 → 哪个都不行,走环境变量

⚠️ Elicitation 不能替代 Sampling。 容易混淆:两者都是服务端发起的请求,但 Sampling 要的是模型的推理,Elicitation 要的是人的输入。判断方法很简单——这个问题的答案,模型自己能想出来吗?能 → Sampling;不能(因为涉及人的偏好、权限、意图)→ Elicitation。

八、故障排查表

#症状根因解决
1Client does not support elicitation服务端没声明 elicitation: {} 能力capabilities 里加 elicitation: {}
2elicitInput 返回 undefined 不报错客户端不支持且静默返回try/catch + 校验返回值,做降级
3弹窗出现但没有任何字段requestedSchema.properties 为空或类型不合法检查类型是否都是原始类型
4报 schema validation error用了嵌套 object / array改成扁平原始类型,或拆成多次调用
5用户填了但 content 为空客户端实现差异兜底判定:accept 但无 content 视为失败
6点了取消但部署还是跑了把 cancel 当 decline 处理cancel 必须中止整个调用
7布尔字段默认就是 true写了 default: true改成 false 或不写
8用户看不到中文标签只有 enum 没有 enumNames,或没写 title补 title + enumNames
9连续提问被折叠客户端限制(Cursor 3 次以上)合并成一张多字段表单
10客户端拒绝渲染表单字段名含 password/secret/token改字段名,凭据走环境变量
11用户一路回车就发布到生产确认项 default 为 true 或缺少实质确认改 false + 加「手输表名」式实质确认
12Trae 里 boolean 显示异常客户端 boolean 渲染有回退改用 enum 的 Yes/No 更稳
13服务端重启后表单不再出现客户端缓存了旧的能力声明重启客户端 / 重连 MCP Server

九、小结

Elicitation 解决的是一个很具体的问题:当工具缺参数时,谁去问用户,怎么问。

三条最重要的实践:

  1. cancel ≠ decline:前者中止整个操作,后者只是这个字段不提供。搞混会出生产事故
  2. 不收凭据:这是协议级红线,字段名里都不要出现 password / token
  3. 勾选框不是确认:要实质确认就让用户手输点什么(表名、单号)

最后一个判断原则:如果这个问题的答案模型自己能想出来,就别问用户。 Elicitation 是用来补模型真的不知道的信息(人的意图、权限、偏好),不是用来偷懒把决策推给人的。每多一次弹窗,用户对弹窗的认真程度就下降一分。

相关推荐

查看更多 →
MCP开发

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

多数人写 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 行故障排查表。

2026-10-10
#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