一、业务背景:Agent 的「工具」不该只属于自家聊天框
企业里做 AI Agent,最容易掉进的坑是:把工具调用能力写死在自家前端聊天面板里。
用户在你们系统里能问「帮我取消订单」,但换个入口就全都用不上了------同事习惯在 Cursor 里写运维脚本、老板用 Claude Desktop 做日常助手、测试同学想用 MCP Inspector 看协议报文。每多一个入口,就要重接一遍 API、重写一遍参数校验、重做一遍鉴权。
MCP(Model Context Protocol)解决的就是这件事:把「工具」从应用内部实现,提升为标准协议。
任何 MCP 客户端连上你的服务端,就能把你的业务能力当工具用,模型自己决定什么时候调、传什么参数。你唯一要做的是让服务端说同一种「话」。
我的订单 Agent 项目 order-agent-assistant 就把 agent 做成了标准 MCP server :POST /mcp,Streamable HTTP transport,已过严格客户端握手。Claude Desktop / Cursor 连上后,「查库存 / 查订单 / 改收货地址 / 取消订单」四个工具会直接出现在客户端的工具列表里。
这里有一个关键区别,也是整个作品集的安全叙事核心:
写操作在 MCP 层同样被权限闸门拦截。
客户端里的模型调到写工具,拿到的是 isError:true 的「需要人工批准」结果,而批准必须发生在浏览器里 (POST /approve)------MCP 客户端绕不过去。
模型只有「提议权」,人保留「决定权」。这句话在面试里比任何架构图都好用。
二、架构与数据流
先看文字版数据流向图:
#mermaid-svg-OZk0uNWWoaohIxcT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OZk0uNWWoaohIxcT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OZk0uNWWoaohIxcT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OZk0uNWWoaohIxcT .error-icon{fill:#552222;}#mermaid-svg-OZk0uNWWoaohIxcT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OZk0uNWWoaohIxcT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OZk0uNWWoaohIxcT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OZk0uNWWoaohIxcT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OZk0uNWWoaohIxcT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OZk0uNWWoaohIxcT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OZk0uNWWoaohIxcT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OZk0uNWWoaohIxcT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OZk0uNWWoaohIxcT .marker.cross{stroke:#333333;}#mermaid-svg-OZk0uNWWoaohIxcT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OZk0uNWWoaohIxcT p{margin:0;}#mermaid-svg-OZk0uNWWoaohIxcT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OZk0uNWWoaohIxcT .cluster-label text{fill:#333;}#mermaid-svg-OZk0uNWWoaohIxcT .cluster-label span{color:#333;}#mermaid-svg-OZk0uNWWoaohIxcT .cluster-label span p{background-color:transparent;}#mermaid-svg-OZk0uNWWoaohIxcT .label text,#mermaid-svg-OZk0uNWWoaohIxcT span{fill:#333;color:#333;}#mermaid-svg-OZk0uNWWoaohIxcT .node rect,#mermaid-svg-OZk0uNWWoaohIxcT .node circle,#mermaid-svg-OZk0uNWWoaohIxcT .node ellipse,#mermaid-svg-OZk0uNWWoaohIxcT .node polygon,#mermaid-svg-OZk0uNWWoaohIxcT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OZk0uNWWoaohIxcT .rough-node .label text,#mermaid-svg-OZk0uNWWoaohIxcT .node .label text,#mermaid-svg-OZk0uNWWoaohIxcT .image-shape .label,#mermaid-svg-OZk0uNWWoaohIxcT .icon-shape .label{text-anchor:middle;}#mermaid-svg-OZk0uNWWoaohIxcT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OZk0uNWWoaohIxcT .rough-node .label,#mermaid-svg-OZk0uNWWoaohIxcT .node .label,#mermaid-svg-OZk0uNWWoaohIxcT .image-shape .label,#mermaid-svg-OZk0uNWWoaohIxcT .icon-shape .label{text-align:center;}#mermaid-svg-OZk0uNWWoaohIxcT .node.clickable{cursor:pointer;}#mermaid-svg-OZk0uNWWoaohIxcT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OZk0uNWWoaohIxcT .arrowheadPath{fill:#333333;}#mermaid-svg-OZk0uNWWoaohIxcT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OZk0uNWWoaohIxcT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OZk0uNWWoaohIxcT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OZk0uNWWoaohIxcT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OZk0uNWWoaohIxcT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OZk0uNWWoaohIxcT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OZk0uNWWoaohIxcT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OZk0uNWWoaohIxcT .cluster text{fill:#333;}#mermaid-svg-OZk0uNWWoaohIxcT .cluster span{color:#333;}#mermaid-svg-OZk0uNWWoaohIxcT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OZk0uNWWoaohIxcT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OZk0uNWWoaohIxcT rect.text{fill:none;stroke-width:0;}#mermaid-svg-OZk0uNWWoaohIxcT .icon-shape,#mermaid-svg-OZk0uNWWoaohIxcT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OZk0uNWWoaohIxcT .icon-shape p,#mermaid-svg-OZk0uNWWoaohIxcT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OZk0uNWWoaohIxcT .icon-shape .label rect,#mermaid-svg-OZk0uNWWoaohIxcT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OZk0uNWWoaohIxcT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OZk0uNWWoaohIxcT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OZk0uNWWoaohIxcT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /mcp JSON-RPC 2.0
Authorization: Bearer JWT
只读
写操作
否
是
Claude Desktop / Cursor / MCP Inspector
McpController :8081
initialize
签发 Mcp-Session-Id
绑定 userId, Redis TTL 30min
tools/list
暴露 Tool 接口为 MCP 工具
tools/call
工具类型
直接执行
WritePermissionGate
已批准?
isError:true + 批准入口
执行 + 消费一次性批准
order-system :8080
订单状态机 / 库存 / Outbox
三个要点:
/mcp和/query一样强制登录。 读订单数据绝不能匿名,所以整个端点都要Authorization: Bearer <order-system JWT>,拦截器把 userId 放进AgentUserContext,调用链据此带X-User-Id去业务层。- 会话是每客户端独立的。 每个 MCP 客户端
initialize时服务器签发一个Mcp-Session-Id并通过响应头带回,后续请求必须随头带回。这样同一用户的两个客户端(两台设备 / 两个应用)各有一个 pending 槽,互不干扰。 - 业务规则只留一份。 MCP 层不做任何业务判断,只做「协议翻译 + 权限闸门」,真正的状态机、库存恢复、退款事件全在 order-system。
三、代码实现:手写 JSON-RPC 的 MCP 服务端
MCP 工具服务的核心就三个方法,和项目里的 Tool 接口一一对应:
initialize------ 握手,告诉客户端「我是谁、支持什么能力」tools/list------ 返回 name / description / inputSchema(模型靠它决定用不用、怎么用)tools/call------ 执行工具,返回{content:[{type:"text",text:...}]}
为什么手写而不是引 MCP SDK:协议核心就一个 POST 端点,自己实现看得见本质、零依赖;将来要接官方 SDK 的 transport,这层业务代码不动。
java
/**
* MCP(Model Context Protocol)兼容层。
* 走 Streamable HTTP transport:单 POST /mcp 端点,通知回 202、
* initialize 做协议版本协商。
*/
@RestController
public class McpController {
/** 支持的 MCP 协议版本:客户端请求的版本受支持就原样回声,否则回最新版。 */
private static final List<String> SUPPORTED_PROTOCOL_VERSIONS =
List.of("2025-06-18", "2025-03-26");
private final List<Tool> tools;
private final WritePermissionGate gate;
private final McpSessionRegistry sessionRegistry;
public McpController(List<Tool> tools, WritePermissionGate gate,
McpSessionRegistry sessionRegistry) {
this.tools = tools;
this.gate = gate;
this.sessionRegistry = sessionRegistry;
}
@PostMapping("/mcp")
public ResponseEntity<Map<String, Object>> mcp(
@RequestBody Map<String, Object> body,
@RequestHeader(value = "Mcp-Session-Id", required = false) String sessionId,
HttpServletResponse response) {
String method = body.get("method") instanceof String ? (String) body.get("method") : null;
Object id = body.get("id");
// 通知类消息没有 id、不需要响应:规范要求回 202 Accepted 空 body
// (如客户端握手完发的 notifications/initialized)
if (method != null && method.startsWith("notifications/")) {
return ResponseEntity.status(HttpStatus.ACCEPTED).build();
}
if (method == null) {
return ResponseEntity.ok(rpcError(id, -32600, "Invalid Request: missing method"));
}
return switch (method) {
case "initialize" -> handleInitialize(id, params(body), response);
case "ping" -> ResponseEntity.ok(rpc(id, Map.of()));
case "tools/list" -> handleToolsList(id, sessionId);
case "tools/call" -> callTool(id, params(body), sessionId);
default -> ResponseEntity.ok(rpcError(id, -32601, "Method not found: " + method));
};
}
注意两个容易被忽略的细节:
① 通知必须回 202 空 body。 客户端握手完会发一条 notifications/initialized,它没有 id、也不期待 result。如果你按普通请求回 200 + result,严格客户端会认为协议违规。这是很多人自研 MCP server 时第一次握手失败的原因。
② 未知方法回 -32601,不抛异常。 协议层用 JSON-RPC 错误码是「正常响应」,HTTP 状态码才决定传输层成败。
接着是握手与协议版本协商:
java
/**
* initialize:签发独立 MCP 会话并通过 Mcp-Session-Id 响应头交给客户端。
* 客户端必须随后续请求带回该头(规范约定),服务器据此区分"是哪个客户端"。
*/
private ResponseEntity<Map<String, Object>> handleInitialize(
Object id, Map<String, Object> params, HttpServletResponse response) {
Long userId = AgentUserContext.get();
if (userId == null) {
return ResponseEntity.ok(rpcError(id, -32001,
"未登录:/mcp 需要 Authorization: Bearer <JWT>"));
}
String sid = sessionRegistry.create(userId);
response.setHeader("Mcp-Session-Id", sid);
return ResponseEntity.ok(rpc(id, initializeResult(params)));
}
/** initialize 结果:协议版本协商 + 能力声明 + 服务信息 + 使用说明。 */
private Map<String, Object> initializeResult(Map<String, Object> params) {
String requested = params.get("protocolVersion") instanceof String
? (String) params.get("protocolVersion") : "";
// 客户端版本受支持就原样回声,否则回我们支持的最新版(客户端自己决定能否降级)
String version = SUPPORTED_PROTOCOL_VERSIONS.contains(requested)
? requested
: SUPPORTED_PROTOCOL_VERSIONS.get(0);
return Map.of(
"protocolVersion", version,
"capabilities", Map.of("tools", Map.of("listChanged", false)),
"serverInfo", Map.of("name", "order-agent", "version", "0.0.1"),
// 规范允许的 instructions:Claude Desktop 会展示给用户,
// 先把「鉴权 + 写要批准」说清楚
"instructions", "连接本 MCP 服务需要登录:客户端配置里带 Authorization: Bearer "
+ "<order-system JWT>。该 agent 的写操作需要人工批准......");
}
版本协商的写法值得单独说。 规范里服务器不能拒绝客户端的版本,只能「回声你支持的版本」或「回你自己的版本」,由客户端决定能不能降级。所以我这里是在白名单里查一次,命中就回声,否则回最新版------而不是抛错。
instructions 字段也是规范允许的,Claude Desktop 会把它展示给用户,正好用来前置说明「需要登录 + 写操作要批准」。
接下来是 tools/list,它把项目里 Tool 接口的描述原样暴露出去:
java
/** tools/list:把 Tool 接口的 name/description/inputSchema 原样暴露成 MCP 工具。 */
private List<Map<String, Object>> listTools() {
return tools.stream().map(t -> {
Map<String, Object> spec = new LinkedHashMap<>();
spec.put("name", t.name());
spec.put("description", t.description());
spec.put("inputSchema", t.inputSchema());
return spec;
}).toList();
}
这是整个设计里最省事的一点 :业务侧定义工具时本来就写好了 name / description / JSON Schema 参数结构(因为 DeepSeek 的 Function Calling 也要用),MCP 层只是换个字段名转发。同一份工具定义,既喂给自家 AgentLoop,也喂给 Claude Desktop,不存在两处维护。
最后是 tools/call,安全边界全在这里:
java
/** tools/call:只读工具直接执行;写工具走闸门(未批准拦下 / 已批准执行)。 */
private ResponseEntity<Map<String, Object>> callTool(
Object id, Map<String, Object> params, String sessionId) {
// 会话校验:必须带 initialize 签发的 Mcp-Session-Id 且绑定当前登录用户。
// 顺带续期;失败返回 JSON-RPC 错误,不执行任何工具(含只读------查订单也不能匿名)。
ResponseEntity<Map<String, Object>> sessionErr = requireSession(id, sessionId);
if (sessionErr != null) {
return sessionErr;
}
Long userId = AgentUserContext.get();
String name = params.get("name") == null ? "" : String.valueOf(params.get("name"));
@SuppressWarnings("unchecked")
Map<String, Object> args = params.get("arguments") instanceof Map<?, ?>
? (Map<String, Object>) params.get("arguments") : Map.of();
Tool tool = tools.stream().filter(t -> t.name().equals(name)).findFirst().orElse(null);
if (tool == null) {
return ResponseEntity.ok(rpcError(id, -32602, "Unknown tool: " + name));
}
ToolCall call = new ToolCall(String.valueOf(id), name, args);
if (!tool.readOnly()) {
// 写操作:MCP 层也走闸门,防止绕过人工批准直接改数据
if (gate.blocks(call, sessionId, userId)) {
String text = gate.reason(call)
+ " 批准入口:POST /approve(Authorization: Bearer <token>),"
+ "body={\"sessionId\":\"" + sessionId + "\"},然后让模型重试同一操作。";
return ResponseEntity.ok(rpc(id, Map.of(
"content", List.of(Map.of("type", "text", "text", text)),
"isError", true)));
}
String result = tool.run(args);
gate.afterToolExecuted(call, sessionId, userId, result);
return ResponseEntity.ok(rpc(id, Map.of(
"content", List.of(Map.of("type", "text", "text", result)))));
}
String result = tool.run(args);
return ResponseEntity.ok(rpc(id, Map.of(
"content", List.of(Map.of("type", "text", "text", result)))));
}
会话注册表本身很简单,但设计意图很明确:
java
/**
* MCP 客户端会话注册表:每个 MCP 客户端一次连接 = 一个独立 session
* (Mcp-Session-Id),绑定到登录用户(userId),存 Redis 带 TTL,每次请求续期。
*
* 为什么从固定 "mcp-<userId>" 改成真正的会话:
* · 固定命名空间下,同一用户的两个 MCP 客户端共享一个 pending/批准槽:
* 一个客户端拦下的写操作,另一个客户端也能批准或把它挤掉------会话之间没有隔离。
* · 真正的 Mcp-Session-Id:客户端 initialize 时服务器签发独立 id 并通过响应头带回。
* 每个客户端一个会话、一个 pending 槽、一套批准,互不干扰;同时又绑定 userId,
* /approve 校验归属,别人拿你的会话 id 批不了。
*
* Redis key:agent:mcp:{sessionId} → userId。30 分钟不活跃自动过期。
*/
@Component
public class McpSessionRegistry {
private static final String PREFIX = "agent:mcp:";
private static final Duration TTL = Duration.ofMinutes(30);
private final StringRedisTemplate redis;
public McpSessionRegistry(StringRedisTemplate redis) {
this.redis = redis;
}
/** 签发一个绑定到 userId 的新 MCP 会话(initialize 时调用),返回会话 id。 */
public String create(Long userId) {
String sid = "mcp-" + UUID.randomUUID().toString().replace("-", "");
redis.opsForValue().set(PREFIX + sid, String.valueOf(userId), TTL);
return sid;
}
/** 续期并返回绑定用户;会话不存在/已过期返回 null。 */
public Long touch(String sessionId) {
String key = PREFIX + sessionId;
String raw = redis.opsForValue().get(key);
if (raw == null) {
return null;
}
redis.expire(key, TTL);
return Long.valueOf(raw);
}
}
这里为什么要用 Redis 而不是本地 Map? 因为 agent 服务将来可能多实例,会话必须跨实例可见;而且 touch 里「读 + 续期」是一个天然要落 Redis 的操作。本地 Map 在单机 demo 里能跑,但一旦横向扩容就变成「同一个客户端每次请求可能落到不同实例、会话时有时无」的随机故障------这种 bug 在面试里讲出来,比背 Redis 的八股有用得多。
下面是写操作从「被拦」到「批准执行」的完整时序:
浏览器 /approve WritePermissionGate McpController MCP 客户端 浏览器 /approve WritePermissionGate McpController MCP 客户端 #mermaid-svg-vnxESTtPTbEih6Cu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vnxESTtPTbEih6Cu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vnxESTtPTbEih6Cu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vnxESTtPTbEih6Cu .error-icon{fill:#552222;}#mermaid-svg-vnxESTtPTbEih6Cu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vnxESTtPTbEih6Cu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vnxESTtPTbEih6Cu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vnxESTtPTbEih6Cu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vnxESTtPTbEih6Cu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vnxESTtPTbEih6Cu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vnxESTtPTbEih6Cu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vnxESTtPTbEih6Cu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vnxESTtPTbEih6Cu .marker.cross{stroke:#333333;}#mermaid-svg-vnxESTtPTbEih6Cu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vnxESTtPTbEih6Cu p{margin:0;}#mermaid-svg-vnxESTtPTbEih6Cu .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-vnxESTtPTbEih6Cu text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-vnxESTtPTbEih6Cu .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-vnxESTtPTbEih6Cu .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-vnxESTtPTbEih6Cu .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-vnxESTtPTbEih6Cu .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-vnxESTtPTbEih6Cu #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-vnxESTtPTbEih6Cu .sequenceNumber{fill:white;}#mermaid-svg-vnxESTtPTbEih6Cu #sequencenumber{fill:#333;}#mermaid-svg-vnxESTtPTbEih6Cu #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-vnxESTtPTbEih6Cu .messageText{fill:#333;stroke:none;}#mermaid-svg-vnxESTtPTbEih6Cu .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-vnxESTtPTbEih6Cu .labelText,#mermaid-svg-vnxESTtPTbEih6Cu .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-vnxESTtPTbEih6Cu .loopText,#mermaid-svg-vnxESTtPTbEih6Cu .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-vnxESTtPTbEih6Cu .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-vnxESTtPTbEih6Cu .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-vnxESTtPTbEih6Cu .noteText,#mermaid-svg-vnxESTtPTbEih6Cu .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-vnxESTtPTbEih6Cu .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-vnxESTtPTbEih6Cu .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-vnxESTtPTbEih6Cu .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-vnxESTtPTbEih6Cu .actorPopupMenu{position:absolute;}#mermaid-svg-vnxESTtPTbEih6Cu .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-vnxESTtPTbEih6Cu .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-vnxESTtPTbEih6Cu .actor-man circle,#mermaid-svg-vnxESTtPTbEih6Cu line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-vnxESTtPTbEih6Cu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} tools/call (写操作, sessionId) blocks(call, sessionId, userId) 未批准 isError:true + 批准入口 + sessionId 提示用户去浏览器批准 用户点击「批准执行」 POST /approve (sessionId, tool, args) 记录一次性批准 tools/call (同一操作, 重试) blocks(call, sessionId, userId) 已批准 tool.run(args) 执行结果
四、客户端接入:三行配置就能连上
前置 :order-system(:8080)+ agent(:8081)都起着,Redis 在,DEEPSEEK_API_KEY 配到 .env。
先拿登录 token ------这是很多人卡住的第一步,/mcp 强制登录:
bash
curl -X POST localhost:8080/api/user/login -H "Content-Type: application/json" \
-d '{"username":"demo","password":"123456"}' # 返回里的 token 存成 $TOKEN
方式一:Claude Desktop 。Windows 配置文件在 %APPDATA%\Claude\claude_desktop_config.json:
json
{
"mcpServers": {
"order-agent": {
"type": "http",
"url": "http://localhost:8081/mcp",
"headers": {
"Authorization": "Bearer 你的token"
}
}
}
}
存盘后重启 Claude Desktop,设置里能看到 4 个工具。
方式二:Cursor 。Settings → MCP Servers → + Add:Type 选 http,URL 填 http://localhost:8081/mcp,Headers 填 {"Authorization": "Bearer 你的token"}。加完在 MCP 面板能看到 4 个工具及参数 schema。
方式三:MCP Inspector(官方调试工具,能看每个请求的 JSON-RPC 报文):
bash
npx @modelcontextprotocol/inspector
URL 填 http://localhost:8081/mcp,Headers 填 Authorization 再连接。要浏览器应用,所以 WebConfig 给 /mcp 开了 CORS------这个细节面试官一问「MCP 服务要不要配跨域」就能接上:Claude Desktop 是本地进程不带 Origin,Inspector 是浏览器应用要跨域,两个都得支持。
连上之后试什么?
- 只读(直接能过):「查一下 iPhone 还有货吗」「订单 2026... 的收货地址是什么」
- 写操作(演示批准闭环) :
- 让模型「把订单 2026... 的地址改成 上海市浦东新区」
- 模型调
update_order_address→ 未批准 → 返回isError:true,正文带「需要人工确认 + 批准入口 + 本次会话 sessionId」 - 去浏览器点「批准执行」按钮(或
POST /approve) - 再让模型重试同一操作 → 这次真正执行
第 4 步必须由人重新发起,客户端不会自动重试------这个设计是刻意的,见下一节。
五、异常与并发边界
这部分才是「能跑」和「能上线」的分水岭。
① 握手 401 / 会话 401,错误码分开。 未登录、会话缺失、会话过期、会话与当前登录人不符,四种情况我都回 -32001 但 message 各不相同:
java
private ResponseEntity<Map<String, Object>> requireSession(Object id, String sessionId) {
Long userId = AgentUserContext.get();
if (userId == null) {
return ResponseEntity.ok(rpcError(id, -32001,
"未登录:/mcp 需要 Authorization: Bearer <JWT>"));
}
if (sessionId == null || sessionId.isBlank()) {
return ResponseEntity.ok(rpcError(id, -32001,
"会话缺失:请先调用 initialize 获取 Mcp-Session-Id 并随后续请求携带"));
}
Long owner = sessionRegistry.touch(sessionId);
if (owner == null) {
return ResponseEntity.ok(rpcError(id, -32001,
"会话不存在或已过期:请重新 initialize"));
}
if (!owner.equals(userId)) {
return ResponseEntity.ok(rpcError(id, -32001,
"会话与当前登录用户不符:请使用该会话绑定的账号重新登录"));
}
return null;
}
「会话与登录用户不符」这一条是关键防线:会话 id 泄漏了也没用,攻击者还得有对应用户的 JWT 才能用这个会话干活。
② 批准是一次性的,且 per-session。 批准后改参数 / 换工具 / 换用户 / 过期都要重新批准 。为什么?因为「批准」批准的是一个具体的 (session, tool, args) 组合。如果批准了「把订单 A 地址改成上海」之后,模型可以拿这个批准去执行「把订单 B 地址改成北京」,那人工批准就是摆设。闸门消费批准时把这一整组当作凭据,用完即焚。
③ 同一会话连着拦下两个不同写操作,批准的是哪一个? 首拦优先------先被拦下的那个优先被放行,后来的不会顶掉前面的,必须等第一个批准或作废。所以人批准的一定是他第一次看到的那次操作,不会出现「批准了 A 实际放行 B」。
④ 协议层不崩传输层。 未知工具回 -32602、未知方法回 -32601、请求缺 method 回 -32600,全部是 200 OK + JSON-RPC error。JSON-RPC 错误不是 HTTP 错误,混用会让严格客户端直接判定连接不可用。
⑤ 会话 30 分钟 TTL 与续期。 客户端可能长时间闲置,所以每次请求都 touch 续期;真过期了就要求重新 initialize------而不是默默给个新会话(那样会让客户端以为还是原来那个,批准槽就对不上了)。
六、面试 / 简历亮点
这段代码能提炼出的简历条目(仓库:github.com/maixiaowen111/order-agent-assistant):
实现 MCP(Model Context Protocol)兼容层,使项目 Agent 以标准 MCP Server 形态对外提供 4 个订单工具。 采用 Streamable HTTP transport,手写 JSON-RPC 2.0(零依赖),支持协议版本协商、
notifications/*202 语义、Mcp-Session-Id每客户端会话隔离(Redis 存储 + 30min TTL 续期);写操作在 MCP 层复用同一权限闸门,未人工批准返回isError,杜绝客户端绕过审批直改数据 。Claude Desktop / Cursor 可直接接入。累计 146 个 agent 单测 ,其中含McpHandshakeTest完整握手序列用例。
可量化的点:4 个工具 、2 个 MCP 客户端已实测接入 、146 个用例 、0 个额外依赖(手写协议)。
再加一句「踩坑的深度」:github 上的 PITFALLS.md 记了 10 个真实踩过的坑(现象 → 根因 → 修复 → 面试怎么讲),其中的「批准第二个写操作,模型却去取消订单」(批准链路把「取消订单」写死在文案里,通用机制不该点名具体工具)就是这段 MCP 代码同一血脉的 bug。能把踩坑写成文档,比能写代码更稀缺。
七、场景题暴击
Q1:MCP 服务器为什么要自己实现会话管理(Mcp-Session-Id),直接用无状态 + JWT 不行吗?
先答「无状态为什么不够」:这个系统里批准是有状态的。人工在浏览器里批准的是「某个客户端在某次对话里提议的某个写操作」,这份 pending 必须有个归属。如果完全无状态,就只能把 pending 塞进 token 或让客户端自己带着------前者 token 会膨胀且无法撤销,后者等于把「我批准过」的判断权交给客户端,安全模型直接崩塌。
再答「会话绑定了什么」:会话 id → userId 的映射放在 Redis(agent:mcp:{sessionId}),解决两个问题 :一是同一用户多客户端时 pending 槽互相隔离,二是会话 id 泄漏也无用------requireSession 会校验会话归属用户 == 当前 JWT 用户。
追问「Redis 挂了怎么办」:会话查不到 → 返回「会话不存在或已过期,请重新 initialize」,客户端重新握手即可恢复;降级方向是「要求重新握手」,不是「放行」。这点在安全相关的降级里必须这样选。
Q2:你手写了 MCP 协议,如果官方 SDK 升级规范(比如新增了 SSE 流式响应、resources 能力),你的兼容成本是多少?
诚实回答比硬吹好。我刻意没做 的是规范里对服务器的可选能力 :SSE 流式响应、resources / prompts 能力------规范允许服务器一律回 JSON,客户端照常工作。
如果将来要补:McpController.mcp() 里就是一个 switch (method),加 case 即可;流式响应需要把返回值换成 SseEmitter 并把 tools/call 的执行改成异步回推。因为协议层和业务层是分开的 (McpController 只依赖 Tool 接口 + 闸门,不依赖任何订单逻辑),这层改动不会波及业务代码。
这也正好回答了「为什么不用官方 SDK」:手写的成本是 200 行,收益是协议报文完全可见、可断点、可调试 ------面试官问「MCP 握手具体发了什么」时,我能直接打开 McpHandshakeTest 给他看完整序列:initialize → notifications/initialized(202) → tools/list → tools/call 只读 → tools/call 写未批准被拦 → /approve → tools/call 写已批准真正执行。
仓库 :https://github.com/maixiaowen111/order-agent-assistant
说明:文中「支付/退款」为状态模拟 + 事件通知,不接真实支付通道;MCP 层的能力边界(不做 SSE / resources)已在文中明确标注为「刻意不做」,非已实现能力。
推荐标签:MCP、Claude Desktop、Spring Boot、AI Agent、JSON-RPC
MCP 握手与调用完整序列:
Redis 会话注册表 McpController MCP 客户端 Redis 会话注册表 McpController MCP 客户端 #mermaid-svg-oMjkG5IeEKB0PJCn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oMjkG5IeEKB0PJCn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oMjkG5IeEKB0PJCn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oMjkG5IeEKB0PJCn .error-icon{fill:#552222;}#mermaid-svg-oMjkG5IeEKB0PJCn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oMjkG5IeEKB0PJCn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oMjkG5IeEKB0PJCn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oMjkG5IeEKB0PJCn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oMjkG5IeEKB0PJCn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oMjkG5IeEKB0PJCn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oMjkG5IeEKB0PJCn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oMjkG5IeEKB0PJCn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oMjkG5IeEKB0PJCn .marker.cross{stroke:#333333;}#mermaid-svg-oMjkG5IeEKB0PJCn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oMjkG5IeEKB0PJCn p{margin:0;}#mermaid-svg-oMjkG5IeEKB0PJCn .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oMjkG5IeEKB0PJCn text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-oMjkG5IeEKB0PJCn .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-oMjkG5IeEKB0PJCn .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-oMjkG5IeEKB0PJCn .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-oMjkG5IeEKB0PJCn .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-oMjkG5IeEKB0PJCn #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-oMjkG5IeEKB0PJCn .sequenceNumber{fill:white;}#mermaid-svg-oMjkG5IeEKB0PJCn #sequencenumber{fill:#333;}#mermaid-svg-oMjkG5IeEKB0PJCn #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-oMjkG5IeEKB0PJCn .messageText{fill:#333;stroke:none;}#mermaid-svg-oMjkG5IeEKB0PJCn .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oMjkG5IeEKB0PJCn .labelText,#mermaid-svg-oMjkG5IeEKB0PJCn .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-oMjkG5IeEKB0PJCn .loopText,#mermaid-svg-oMjkG5IeEKB0PJCn .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-oMjkG5IeEKB0PJCn .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-oMjkG5IeEKB0PJCn .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-oMjkG5IeEKB0PJCn .noteText,#mermaid-svg-oMjkG5IeEKB0PJCn .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-oMjkG5IeEKB0PJCn .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oMjkG5IeEKB0PJCn .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oMjkG5IeEKB0PJCn .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oMjkG5IeEKB0PJCn .actorPopupMenu{position:absolute;}#mermaid-svg-oMjkG5IeEKB0PJCn .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-oMjkG5IeEKB0PJCn .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oMjkG5IeEKB0PJCn .actor-man circle,#mermaid-svg-oMjkG5IeEKB0PJCn line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-oMjkG5IeEKB0PJCn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} initialize (protocolVersion) create(userId) → Mcp-Session-Id 200 + protocolVersion + Mcp-Session-Id 头 notifications/initialized 202 Accepted (空 body) tools/list (带 Mcp-Session-Id) 200 + 4 个工具描述 tools/call (只读, 带 Mcp-Session-Id) touch(sessionId) 续期 200 + 执行结果 tools/call (写, 未批准) 200 + isError:true + 批准入口 tools/call (写, 已批准) touch(sessionId) 续期 200 + 真正执行结果