工具调用、MCP 与权限边界工具调用、MCP 与权限边界
从工具契约与 MCP 协议出发,建立能力发现、业务授权、审批、幂等和审计的完整执行边界。从工具契约与 MCP 协议出发,建立能力发现、业务授权、审批、幂等和审计的完整执行边界。
专题导读
工具调用把模型输出接入数据库、订单、工单、消息和部署系统,风险由“回答不准确”升级为“真实副作用错误”。Java 后端工程师不能把工具调用理解为模型直接执行函数:模型最多提出调用意图,MCP 客户端或应用编排层负责协议交互,MCP 服务器负责暴露能力,最终仍由领域服务做身份校验、资源授权、事务、幂等、限流和审计。
MCP(Model Context Protocol)解决的是上下文和能力的标准化互操作。它定义客户端与服务器如何协商能力、发现并调用 tools、读取 resources、获取 prompts,以及消息如何由 transport 承载。MCP 不等于业务授权系统:即使某个正式规范版本定义了 HTTP 授权流程,也不能替代“当前主体是否可取消这张订单”这样的租户、资源和业务状态判断。
MCP 演进较快。本文用正式规范中的稳定概念讲解,并在涉及生命周期和 transport 的具体例子中明确以 2025-06-18 规范修订版为参照。生产实现必须固定协议修订版与 SDK 版本;方法名、transport 细节、授权要求和兼容策略都应以所选正式规范为准,而不是凭博客或本文记忆实现。
知识地图
用户请求 → Host/Agent 选择能力 → MCP Client 发现 Server 能力 → 生成结构化 tool call → Schema/语义校验 → 可信身份注入 → 资源级授权 → 风险策略与审批 → 幂等领域执行 → 输出校验/脱敏 → observation → 审计
需要区分四个边界:
- 协议边界:JSON-RPC 消息、生命周期、能力协商、工具发现和 transport。
- 信任边界:Host、MCP Client、MCP Server、领域 API、第三方数据源分别能看到什么、能做什么。
- 授权边界:认证确认“是谁”,scope 限制粗粒度能力,领域授权判断“能否对这个资源执行这个动作”。
- 副作用边界:审批、参数绑定、幂等、结果未知、补偿和审计。
编号面试题
Q1:普通模型 Function Calling 与 MCP 是什么关系?
核心回答
Function Calling 通常指某个模型 API 让模型按 schema 产生工具名和参数;MCP 是 Host/Client 与 Server 之间交换上下文和能力的协议。二者可以组合:模型产生调用意图,Host 通过 MCP Client 调用 MCP Server;但它们不是同一层,也都不自动执行领域授权。
深入解释
模型厂商的 tool/function calling 解决“怎样让模型输出结构化调用”;MCP 解决“应用怎样以相对统一的方式发现和连接外部能力”。真正执行仍可能经过 API Gateway、Java 领域服务和数据库。把三层分开可以替换模型或 MCP SDK,而不改变订单取消规则。
无论调用意图来自模型、规则还是人工按钮,服务端都必须把它当作不可信输入。schema 通过只说明结构合格,不说明订单属于当前租户、金额合法或审批仍有效。
面试追问:不用 MCP 能做工具调用吗?
可以,直接使用内部 REST/gRPC 或进程内接口也能做;MCP 的价值是标准化互操作,不是所有系统的强制依赖。
常见错误回答:“MCP 是模型调用函数的 SDK。”它是协议;具体 SDK 只是协议实现。
Q2:MCP 的 Host、Client、Server 分别承担什么职责?
核心回答
Host 是承载 AI 应用并统筹用户交互、安全策略和上下文的进程;它创建 MCP Client。Client 与特定 MCP Server 建立协议关系并路由消息;Server 暴露 tools、resources、prompts 等能力。领域系统通常位于 Server 之后,不应因接入 MCP 绕过原有控制。
深入解释
在 2025-06-18 架构中,一个 Host 可管理多个 Client,每个 Client 与一个 Server 对应。Host 决定向哪个 Server 暴露多少上下文,并隔离不同 Server;Server 不应默认获得完整会话或其他 Server 的数据。远程 Server 和本地子进程的信任等级也不同:本地不代表天然可信,远程更要处理认证、网络与供应链风险。
Java 项目可采用三层:McpTransportAdapter → ToolApplicationService → DomainService。适配层只做协议映射,应用层做工具策略和用例编排,领域层守住租户、资源、事务与不变量。
面试追问:授权应放 Client 还是 Server?
Client 可做用户同意和预检查;Server/领域服务必须独立强制授权,不能信任 Client 已经检查过。
常见错误回答:“MCP Server 就是大模型服务器。”MCP Server 是提供上下文或能力的协议端点,不等同于模型推理服务。
Q3:能力协商与工具发现是怎样工作的?
核心回答
能力协商先确认双方支持哪些协议功能,工具发现再列出当前 Server 暴露的工具。以 2025-06-18 修订版为例,连接先进行 initialize/initialized 生命周期协商,Server 声明 tools 等 capability;支持后 Client 才调用 tools/list,工具列表可分页,声明 listChanged 时还可通知变化。
深入解释
能力声明不是权限授予,也不是工具结果可信证明。它只表示协议功能可用。Client 只能使用协商成功的 capability;工具列表变化后应重新发现并按版本更新本地缓存。工具的 name、描述、inputSchema、可选 outputSchema 和 annotations 帮助理解接口,但 Server 自报的描述或只读标记不能替代 Host 风险策略。
2025-06-18 生命周期包含 initialization、operation 与 shutdown。初始化完成前双方只能进行规范允许的有限交互;operation 阶段必须遵守已协商版本和 capability。该版本没有通用的 MCP shutdown JSON-RPC 方法:stdio 由 Client 关闭输入流并按进程生命周期终止,HTTP 则通过 transport 连接/会话语义结束。
协议修订版可能改变生命周期和发现机制,因此生产系统要把 protocolVersion、Server 身份、工具定义摘要和缓存版本一起记录。不要将一次发现结果永久缓存,也不要在未重新授权时自动开放新增危险工具。
面试追问:发现工具后模型就能自动调用吗?
协议允许列出和调用,不代表产品必须自动执行;Host 可要求确认、隐藏部分工具或完全禁用模型控制。
常见错误回答:“capability negotiation 就是 OAuth scope negotiation。”前者是协议功能协商,后者是访问授权概念。
Q4:MCP 为什么同时提 JSON-RPC 和 transport?两者如何区分?
核心回答
JSON-RPC 描述请求、响应、通知、id 和错误等消息语义;transport 负责消息如何分帧、传输、连接和终止。MCP 使用 JSON-RPC 编码消息,但 stdio、Streamable HTTP 等 transport 的具体规则必须以所选正式规范为准。
深入解释
以 2025-06-18 版为例,标准 transport 包括 stdio 与 Streamable HTTP。stdio 通常由 Client 启动子进程,通过标准输入输出传输消息,并要求 stdout 不混入普通日志;Streamable HTTP 使用规定的 HTTP 端点和媒体类型,可结合 SSE。JSON-RPC 层的成功不等于工具业务成功:协议错误与工具执行错误需要分开建模。
在该修订版中,初始化后的 HTTP 请求携带 MCP-Protocol-Version;若 Server 在初始化时分配 Mcp-Session-Id,Client 后续请求也要携带它。Client 可按规范发送 HTTP DELETE 请求终止会话,Server 也可能不支持并返回相应状态。stdio 则通过关闭流和进程生命周期结束,不存在可跨 transport 套用的统一关闭消息。
超时、取消、会话标识、Origin 校验、断线恢复等细节与 transport 和规范版本有关。尤其不能把 HTTP 断开自动解释成副作用已取消:请求可能已在 Server 或下游提交,恢复时仍要按 operation id 查询。
面试追问:可以自己用 WebSocket 传 MCP 吗?
只有所选规范允许自定义 transport 且双方明确实现相同分帧、生命周期和安全语义时才可互操作;不能把任意 JSON-RPC over WebSocket 自动称为标准 MCP transport。
常见错误回答:“MCP 就是 JSON-RPC,所以能发 JSON-RPC 就兼容。”MCP 还有生命周期、能力和各功能的规范语义。
Q5:tools、resources、prompts 有什么区别?
核心回答
Tools 表达可调用操作,可能产生副作用;Resources 表达可读取或订阅的上下文数据;Prompts 表达可获取的提示模板。三者的交互模型、风险和用户控制方式不同,不能统一当成“函数”。
深入解释
工具输入必须校验,危险调用通常要确认;资源读取要做 URI、租户、权限、大小和内容类型限制;Prompt 模板是 Server 提供的内容,同样不应获得高于 Host 系统策略的指令优先级。2025-06-18 工具规范将 tools 描述为 model-controlled,但协议不强制具体 UI,Host 仍可决定是否自动调用。
来自 Server 的 tool annotation、资源内容和 prompt 文本都是跨信任边界数据。Client 应最小化传给模型的内容,避免 Server 借返回文本诱导调用其他工具或外泄数据。
面试追问:只读 resource 就一定安全吗?
不一定;读取可能泄露 PII/密钥,也可能形成 SSRF、路径穿越或间接提示注入。
常见错误回答:“Prompt 是文本,所以无需权限。”模板本身可能是租户资产,也可能影响后续高权限行为。
Q6:MCP 支持授权,为什么仍然说 MCP 不等于授权?
核心回答
正式规范可定义 transport 层授权机制,例如 HTTP 场景下的令牌获取、受众约束和 scope;这些机制解决 Client 如何获得并携带访问凭证。它们不能替代业务层 ABAC/RBAC、资源归属、租户隔离、订单状态和金额上限判断。
深入解释
认证回答“调用者是谁”,粗粒度 scope 可能回答“是否有 orders.cancel 能力”,领域授权还要回答“该订单是否属于此租户、调用者是否管理该业务线、订单是否仍可取消、金额是否需要二级审批”。这些条件经常依赖实时数据库状态,不可能仅由 token 静态声明覆盖。
Server 必须验证令牌受众,避免 token passthrough 和 confused deputy;禁止把 Client 给的第三方 token 原样转发到任意下游。stdio 本地部署与 HTTP 远程部署的凭证方式不同,必须遵循对应规范。身份、租户和权限范围来自可信认证上下文,不能从模型参数获取。
面试追问:拿到管理员 token 后是否可省略资源级校验?
不可以。高权限令牌扩大爆炸半径,应进一步收敛到工具、动作、租户和资源。
常见错误回答:“通过 OAuth 就已经授权完成。”OAuth 令牌不是业务不变量验证器。
Q7:一个生产工具契约至少要定义哪些内容?
核心回答
至少定义稳定名称和版本、准确描述、输入/输出 schema、必填与长度/枚举约束、副作用等级、超时、幂等语义、错误分类、分页/大小上限以及敏感字段处理。schema 负责结构,领域代码负责语义与授权。
深入解释
优先暴露窄工具,例如分开 orders.get 与 orders.cancel,而不是一个可执行任意 HTTP/SQL 的通用工具。危险值应使用服务端可验证的业务 ID 和枚举;不要让模型提交 tenantId、数据库表名、任意 URL、SQL 或 shell。输出 schema 有助于 Client 验证结构,但无法证明数据真实或安全。
工具描述需要写清前置条件和后果,却不能作为控制。参数要规范化后再做授权、审批摘要和幂等比较,防止等价输入在不同表示下绕过检查。错误应区分协议错误、参数错误、权限拒绝、业务冲突、瞬时失败和结果未知。
面试追问:
additionalProperties: false能解决什么?若具体 schema 实现支持,它可拒绝未声明字段、减少参数走私;仍不能替代跨字段和资源授权校验。
常见错误回答:“JSON Schema 校验通过就可以执行。”语法正确不等于有权执行或符合业务状态。
Q8:身份、租户和权限上下文应该怎样传递?
核心回答
从已验证的连接、令牌或服务端会话构造不可伪造的 PrincipalContext,再由 Server 注入领域调用。模型参数只能提供业务意图,不能声明 actorId、tenantId、角色或 scope。
深入解释
授权检查要落到资源:先在租户范围内加载订单,再判断 actor、动作、资源属性和业务状态。查询本身也要带租户条件,不能先按全局 ID 查出对象后再过滤,否则可能产生存在性泄露或遗漏检查。
缓存键、幂等表、审计和指标同样要包含租户维度。后台服务代表用户调用时要区分用户身份与服务身份,记录 delegation chain,防止高权限服务成为 confused deputy。
面试追问:工具参数中保留 tenantId 再与 token 比较可以吗?
通常没有必要,最好完全由服务端注入;若协议业务确需携带,也只能作为待校验声明,不能作为事实。
常见错误回答:“system prompt 已告诉模型不要修改 tenantId,所以安全。”提示不是访问控制。
Q9:为什么高风险动作要采用 plan/preview + approval?怎样避免 TOCTOU?
核心回答
先生成规范化变更计划,让用户看清对象、动作和影响,再把审批绑定到参数摘要、资源版本、actor、run 和过期时间。执行前重新加载资源并验证摘要与版本,避免审批后参数或状态变化。
深入解释
dryRun=true 只是一种产品能力,不是安全证明。若 preview 展示“取消订单 A”,执行时却允许模型改成订单 B,就是批准对象错位。审批 token 应由可信服务签发,短期有效、一次性或受限次数,并记录审批人。资源版本变化时必须重新预览或重新审批。
高风险动作还应限制影响规模,例如单笔退款上限、批量数量上限、工作时间窗和双人审批。审批不能把原本无权的动作变成有权:先授权,再审批,执行前两者都重查。
面试追问:审批后执行失败可以直接重试吗?
只有审批仍有效、参数和资源版本满足绑定条件,且能用同一幂等键确认不会重复时才可以。
常见错误回答:“有用户点确认就可以执行任意参数。”确认必须是知情、具体且与最终动作绑定的。
Q10:工具超时、取消和结果未知时如何恢复?
核心回答
只读、确定未执行的瞬时错误可按退避策略重试;参数、权限和业务冲突通常不可重试;副作用超时进入 UNKNOWN 时,先按幂等键查询执行状态,不能盲重试。取消是请求意图,不证明远端副作用已撤销。
深入解释
幂等键必须由最终执行服务持久化,并把键与规范化参数摘要绑定。同一个键收到不同参数应拒绝。下游若不能查询操作状态,高风险动作就应暂停人工对账。补偿是新的领域操作,例如“退款冲正”,需要独立授权、审批和幂等。
MCP 工具规范还区分 JSON-RPC/协议层错误与工具执行错误。观测系统应分别统计,否则“unknown tool”和“库存不足”会混成同类。超时预算需要覆盖 Client、Server 和下游,且要保留一个绝对截止时间。
面试追问:HTTP 500 都应该重试吗?
不应该。500 可能发生在提交之后;先依据工具语义、幂等和状态查询判断,而不是只看状态码。
常见错误回答:“重试三次总能提高成功率。”无条件重试会放大故障并重复副作用。
Q11:如何用 Java 21 实现一个安全的 MCP 工具适配边界?
核心回答
不要手写一个“看起来像 MCP”的 JSON-RPC 方言。协议层使用与目标正式规范匹配的 SDK/实现,映射到一个独立的 Java 应用服务。下面示例聚焦规范之后最容易遗漏的边界:可信身份注入、精确参数、资源级授权、审批绑定和原子幂等。
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.function.Supplier;
public final class SecureToolBoundary {
record Principal(String tenantId, String actorId, Set<String> scopes) {
Principal {
Objects.requireNonNull(tenantId);
Objects.requireNonNull(actorId);
scopes = Set.copyOf(scopes);
}
}
// runId 与 Principal 都必须由可信 Host/transport 上下文提供,不能来自模型参数。
record ExecutionContext(String runId, Principal principal) {
ExecutionContext {
Objects.requireNonNull(runId);
Objects.requireNonNull(principal);
}
}
record ToolCall(
String name,
Map<String, String> arguments,
String idempotencyKey,
Approval approval) {
ToolCall {
Objects.requireNonNull(name);
arguments = Map.copyOf(arguments);
Objects.requireNonNull(idempotencyKey);
}
}
record Approval(String approvalId, String actionDigest, Instant expiresAt, String approverId) {}
record Order(String tenantId, String orderId, long version, String status) {}
record ToolResult(boolean success, String code, Map<String, String> data) {
ToolResult {
data = Map.copyOf(data);
}
}
interface OrderService {
Optional<Order> find(String tenantId, String orderId);
ToolResult cancel(String tenantId, String orderId, long expectedVersion, String reason);
}
interface Authorizer {
void requireCancel(Principal principal, Order order);
}
interface ApprovalVerifier {
// 真实实现应原子校验并消费一次性 approvalId。
boolean verifyAndConsume(Approval approval, String expectedDigest, Instant now);
}
interface IdempotencyStore {
ToolResult executeOnce(String key, String requestDigest, Supplier<ToolResult> action);
}
static final class InMemoryIdempotencyStore implements IdempotencyStore {
private record Entry(String digest, ToolResult result) {}
private final Map<String, Entry> entries = new HashMap<>();
@Override
public synchronized ToolResult executeOnce(
String key, String requestDigest, Supplier<ToolResult> action) {
Entry existing = entries.get(key);
if (existing != null) {
if (!existing.digest().equals(requestDigest)) {
throw new IllegalArgumentException("idempotency key reused with different arguments");
}
return existing.result();
}
ToolResult result = action.get();
// APPROVAL_REQUIRED 等前置失败不占用幂等键,批准后仍可用同一 key 执行。
if (result.success()) {
entries.put(key, new Entry(requestDigest, result));
}
return result;
}
}
private final OrderService orders;
private final Authorizer authorizer;
private final ApprovalVerifier approvals;
private final IdempotencyStore idempotency;
private final Clock clock;
public SecureToolBoundary(
OrderService orders,
Authorizer authorizer,
ApprovalVerifier approvals,
IdempotencyStore idempotency,
Clock clock) {
this.orders = Objects.requireNonNull(orders);
this.authorizer = Objects.requireNonNull(authorizer);
this.approvals = Objects.requireNonNull(approvals);
this.idempotency = Objects.requireNonNull(idempotency);
this.clock = Objects.requireNonNull(clock);
}
public ToolResult dispatch(ExecutionContext context, ToolCall call) {
return switch (call.name()) {
case "orders.cancel.v1" -> cancelOrder(context, call);
default -> new ToolResult(false, "UNKNOWN_TOOL", Map.of());
};
}
private ToolResult cancelOrder(ExecutionContext context, ToolCall call) {
Principal principal = context.principal();
requireExactKeys(call.arguments(), Set.of("orderId", "expectedVersion", "reason"));
requireScope(principal, "orders.cancel");
String orderId = requireIdentifier(call.arguments().get("orderId"));
long expectedVersion = parsePositiveLong(call.arguments().get("expectedVersion"));
String reason = requireText(call.arguments().get("reason"), 1, 200);
Order order = orders.find(principal.tenantId(), orderId)
.orElseThrow(() -> new IllegalArgumentException("order not found"));
authorizer.requireCancel(principal, order);
if (order.version() != expectedVersion) {
return new ToolResult(false, "STALE_RESOURCE", Map.of());
}
String digest = digest(context.runId(), principal.tenantId(), principal.actorId(),
call.name(), canonicalArguments(call.arguments()));
String scopedKey = principal.tenantId() + ":" + call.name() + ":" + call.idempotencyKey();
return idempotency.executeOnce(scopedKey, digest, () -> {
if (!approvals.verifyAndConsume(call.approval(), digest, clock.instant())) {
return new ToolResult(false, "APPROVAL_REQUIRED", Map.of("actionDigest", digest));
}
return orders.cancel(principal.tenantId(), orderId, expectedVersion, reason);
});
}
private static void requireScope(Principal principal, String scope) {
if (!principal.scopes().contains(scope)) {
throw new SecurityException("insufficient scope");
}
}
private static void requireExactKeys(Map<String, String> arguments, Set<String> expected) {
if (!arguments.keySet().equals(expected)) {
throw new IllegalArgumentException("arguments must be exactly " + expected);
}
}
private static String requireIdentifier(String value) {
if (value == null || !value.matches("[A-Za-z0-9_-]{1,64}")) {
throw new IllegalArgumentException("invalid identifier");
}
return value;
}
private static String requireText(String value, int min, int max) {
if (value == null || value.length() < min || value.length() > max) {
throw new IllegalArgumentException("invalid text length");
}
return value;
}
private static long parsePositiveLong(String value) {
try {
long parsed = Long.parseLong(value);
if (parsed <= 0) throw new IllegalArgumentException("must be positive");
return parsed;
} catch (NumberFormatException e) {
throw new IllegalArgumentException("invalid positive long", e);
}
}
private static String canonicalArguments(Map<String, String> arguments) {
StringBuilder canonical = new StringBuilder();
new TreeMap<>(arguments).forEach((key, value) -> canonical
.append(key.length()).append(':').append(key)
.append(value.length()).append(':').append(value));
return canonical.toString();
}
private static String digest(String... parts) {
try {
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
for (String part : parts) {
byte[] bytes = part.getBytes(StandardCharsets.UTF_8);
sha256.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
sha256.update(bytes);
}
return Base64.getUrlEncoder().withoutPadding().encodeToString(sha256.digest());
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is required by the Java platform", e);
}
}
}参数规范化与摘要计算相对参数总长度 n 为 O(n);长度前缀编码避免简单字符串拼接产生歧义。示例内存幂等表查询平均为 O(1),空间为 O(k),k 是保留的成功操作数。审批摘要绑定可信 runId,verifyAndConsume 负责原子消费一次性 approvalId;已成功的幂等重放直接返回首次结果,不重复消费审批。生产幂等表需要数据库唯一约束、结果状态、参数摘要和保留期;若领域事务在另一个服务,最终去重必须在那个服务实现。
面试追问:为什么示例不直接实现 JSON-RPC 和
tools/call?协议细节会随修订版变化,应由匹配正式规范的适配层/SDK承担;示例专注稳定的业务安全边界。
常见错误回答:“在 Controller 校验一次 scope 就够了。”领域服务仍需资源和状态校验,最终副作用服务还要幂等。
Q12:MCP 工具系统如何做版本、审计和可观测性?
核心回答
固定协议修订版、SDK 和工具契约版本;审计每次发现、授权、审批和执行;按 Host/Client/Server/领域服务分层建立 trace。日志记录必要元数据和摘要,不记录访问令牌、密钥、完整 PII 或无界工具结果。
深入解释
工具 schema 变更要兼容旧 Client:优先新增可选字段,破坏性变更使用新工具名/版本并设置迁移期。一次 run 应记录 Server 身份、协议版本、工具定义摘要、调用参数摘要、principal、授权决策、approval id、idempotency key、结果状态和 trace id。
关键指标包括发现失败率、版本协商失败率、schema 拒绝率、授权拒绝率、审批率、工具 P50/P95、超时、结果未知、幂等命中、输出校验失败和单租户调用量。对 Server 工具列表变化、权限扩大和异常数据外传设置告警。
面试追问:是否应该记录完整 tool arguments 方便复盘?
只在合法且必要时记录;默认做字段级脱敏、摘要或引用受控审计存储,并设置访问控制与保留期。
常见错误回答:“协议统一后所有 Server 都能用相同超时和权限。”工具成本、风险和数据敏感度不同,策略必须按能力配置。
场景设计题:用 MCP 接入订单查询与取消能力
题目:一个内部 AI 助手需要连接订单系统。员工可查询自己业务线订单,只有主管可取消,金额超过阈值需二级审批。请设计 MCP 接入。
设计要点
- 部署边界:Host 管理用户会话和多个 MCP Client;订单 MCP Server 只接收必要上下文,后面调用现有 Java 订单应用服务。
- 能力拆分:
orders.get.v1与orders.cancel.v1分离;不暴露任意 SQL、HTTP 和批量取消通用工具。 - 发现与版本:固定协议修订版,协商 tools capability,缓存
tools/list时绑定 Server 身份和定义摘要;新增工具默认不自动授权。 - 身份:HTTP token 或受控本地凭证建立 Principal;tenant、actor、业务线从可信上下文注入。
- 授权:查询在租户和业务线条件内执行;取消检查主管角色、资源归属、订单状态和金额阈值。
- 审批:preview 返回订单、金额、影响和版本;审批绑定参数摘要、版本、run、审批人和过期时间,高金额需要第二审批人。
- 幂等:取消服务以
tenant + operationId唯一约束并保存首次结果;相同键不同参数拒绝。 - 结果处理:输出 schema、字段白名单、PII 脱敏和大小上限;返回文本仍按不可信 observation 处理。
- 恢复:断线或超时先查取消操作;状态未知进入人工对账,不自动生成新键重试。
- 审计:串联 Host、MCP 调用和订单事务 trace,记录授权与审批证据,但不落 token 和完整隐私字段。
故障恢复与排障清单
- 初始化或能力协商失败:核对双方协议修订版、SDK 兼容矩阵和必需 capability,不要猜测降级语义。
- stdio 解析异常:检查 Server 是否把普通日志写入 stdout;日志应走 stderr,分帧规则遵循所选规范。
- HTTP 偶发断连:不能把连接关闭当作取消成功;检查 transport 恢复规则,并查询副作用状态。
- 新增工具突然可用:检查发现缓存和 Host allowlist;工具列表变化不应自动扩大用户权限。
- 跨租户数据泄露:检查资源查询是否在 SQL/领域入口带租户条件,缓存键和幂等键是否包含租户。
- 审批了 A 却执行 B:检查 approval 是否绑定规范化参数摘要与资源版本,执行前是否重验。
- 403 被无限重试:授权拒绝应是不可重试;step-up 也要受正式规范、最小权限和次数限制。
- 重复取消:确认最终订单服务持久化幂等,而非只在 MCP Server 内存去重。
- 模型受工具结果诱导:缩小输出、标明来源、隔离数据与指令;真正防线仍是工具 allowlist、授权和审批。
- 日志出现 token/PII:停止采集并轮换泄露凭证,按字段分类整改日志、trace 和错误响应。
速记总结
- Function Calling 产生调用意图,MCP 标准化 Client/Server 交互,领域服务执行真实业务。
- Host 管理上下文和策略;Client 连接 Server;Server 暴露 tools/resources/prompts。
- capability 表示协议功能,不是用户权限;
tools/list发现也不是授权。 - JSON-RPC 是消息语义,transport 是承载与分帧;具体细节以固定的正式规范版本为准。
- MCP 的协议级授权不能替代租户、资源、动作和业务状态授权。
- 身份和租户从可信上下文注入,不能信任模型参数。
- schema 只验证结构;执行前还要语义校验、授权、审批和版本检查。
- approval 必须绑定最终参数和资源版本,防止 TOCTOU。
- 副作用超时先查状态;幂等由最终执行服务保证。
- 工具结果、resources 和 prompts 都是跨信任边界数据。
- 协议适配层与 Java 领域层分离,才能在升级 MCP 时不绕过核心控制。