如何让 Claude Desktop 与 Cursor 调用订单工具?MCP Streamable HTTP 接入思路

一、业务背景: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

三个要点:

  1. /mcp 和 /query 一样强制登录。 读订单数据绝不能匿名,所以整个端点都要 Authorization: Bearer <order-system JWT>,拦截器把 userId 放进 AgentUserContext,调用链据此带 X-User-Id 去业务层。
  2. 会话是每客户端独立的。 每个 MCP 客户端 initialize 时服务器签发一个 Mcp-Session-Id 并通过响应头带回,后续请求必须随头带回。这样同一用户的两个客户端(两台设备 / 两个应用)各有一个 pending 槽,互不干扰。
  3. 业务规则只留一份。 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... 的收货地址是什么」
  • 写操作(演示批准闭环) :
    1. 让模型「把订单 2026... 的地址改成 上海市浦东新区」
    2. 模型调 update_order_address → 未批准 → 返回 isError:true,正文带「需要人工确认 + 批准入口 + 本次会话 sessionId」
    3. 去浏览器点「批准执行」按钮(或 POST /approve)
    4. 再让模型重试同一操作 → 这次真正执行

第 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 + 真正执行结果

相关推荐
zxcvb1531 小时前
轻量网站内容监测系统实践:从标题监控到异常告警
网络
北城笑笑2 小时前
Server 18 ,Nginx 双服务器 HTTP转HTTPS 代理实战( HTTP 地址换 HTTPS 域名 )
运维·服务器·前端·后端·nginx·http·https
十二同学啊2 小时前
Docker 容器化技术详解:镜像、容器、网络与应用部署设计
网络·docker·eureka
luiyarch2 小时前
TBOX信息安全系列7设计篇-双向认证车云通信安全方案
网络·安全·车载系统·汽车
打工仔折腾 AI2 小时前
Qt网络编程实战:UDP、TCP到HTTP的落地经验与选型
网络·人工智能·后端·python·qt·udp·ai agent 实战
硅基造物主2 小时前
18.从 IP 到 MQTT:STM32 用 AT 指令驱动 ESP-12F 上云的完整链路(含全套实战代码 + 踩坑清单)
网络·stm32·单片机·嵌入式硬件·网络协议·tcp/ip
重生之高冷汉堡包2 小时前
JavaScript 网络爬虫教程(2026)
大数据·网络·爬虫·安全
Raas1002 小时前
AI网关怎么选?MAI Gateway(魔芋企业级AI网关)入选2026年企业级AI网关推荐清单
大数据·网络·人工智能·gateway·mai gateway
白帽攻防录2 小时前
SRC 挖洞:MSMQ 端口上的 UAF 炸弹,9.8 分网络 RCE 怎么打到 SYSTEM
网络·网络安全·src漏洞挖掘