资讯详情

别让大模型拿着“万能钥匙”进生产:Spring AI 2.0.0 GA 搭建安全 MCP Server 全流程(TaoToken 统一 Key 接入篇)

📅 2026/10/10 1:42:19 | 华诺云谱 👁 阅读
别让大模型拿着“万能钥匙”进生产:Spring AI 2.0.0 GA 搭建安全 MCP Server 全流程(TaoToken 统一 Key 接入篇)
1. 从 Demo 到生产MCP Server 为什么不能直接上线很多团队第一次跑通 MCP Server 的场景几乎一模一样给方法加上McpTool本地启动 Spring BootAI 客户端发出tools/list工具被发现了模型自动调用数据库里真的多了一行记录。会议室里一片欢呼然后安全同事开始提问——模型说它只是查询服务端就真的相信它不会写数据吗同一个调用因为网络重试执行两遍会不会创建两张工单普通巡检账号能不能越权查别的租户数据异常堆栈里带着数据库地址和 SQL会不会原样返回给模型这些问题的共同点是工具能发现、能调用但我们没有证据说明它应该被谁调用、最多产生一次什么副作用、事后如何追责。这不是协议有没有接通的问题而是信任边界没有落地。Spring AI 2.0.0 GA 已经正式发布到 Maven Central配合 Spring Boot 4.0.x 可以比较顺畅地搭建 MCP Server。但框架帮你解决的是协议适配和工具注册它不会替你理解租户、不会自动判断操作员权限、也不会替数据库保证幂等。这篇文章要做的就是把一个常见的上线前工程问题放进测试环境完整复现用 Spring AI 2.0.0 GA、Spring Boot 4、PostgreSQL 和 Streamable HTTP 做出两个工具——只读的故障知识查询以及会写库的维修工单创建——然后把参数校验、身份认证、业务权限、幂等、事务、审计、Origin 校验和错误脱敏逐层补齐。最终链路是这样的用户通过 AI Host桌面端/IDE/Agent 平台发起请求MCP Client 负责会话和能力协商通过 Streamable HTTP 传输到服务端。HTTP 安全边界做 Origin JWT Audience 校验MCP Tool 适配层做 Schema 参数校验 错误映射业务 Service 层做租户 权限 事务 幂等最后落到 PostgreSQL 的唯一约束和业务数据同时写审计日志。这里最关键的一点MCP Tool 只是适配层不是业务权限层。就算未来把 MCP 换成 REST、消息队列或者内部任务调度业务 Service 的权限、幂等与审计仍然成立。这样做看起来比在工具方法里写完所有逻辑多了一层但它避免的是同一套业务规则在每个入口重复一次。2. 前置准备TaoToken 统一 Key 与项目环境搭建在动手写代码之前先把调用链路的入口准备好。MCP Server 本身不包含大模型但你需要一个 AI Host 或 Agent 平台来发起工具调用。这里用 TaoToken 作为统一 Key 接入层把模型对话和工具调用链统一管理起来。TaoToken 的定位是统一 API 入口官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 端点是 https://taotoken.net/api 。你需要先在控制台创建一个 API Key然后就可以在 AI Host 里配置模型和 MCP Server 的连接。具体操作路径打开 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 创建 API Key然后在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 管理你的密钥。如果你用的是 Claude Code 或类似的编码 Agent可以参考 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里的接入文档。需要长期跑编码任务或 Agent 工作流的话Coding Plan 页面 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 有更详细的套餐说明。拿到 Key 之后回到 Spring Boot 项目。本文示例采用一套刻意朴素的技术栈JDK 21、Spring Boot 4.0.x、Spring AI 2.0.0 GA、spring-ai-starter-mcp-server-webmvcWebMVC Streamable HTTP、PostgreSQL 16、OAuth2 Resource Server JWT、Spring JDBC、Maven 3.9。pom.xml 的关键部分如下Spring AI 2.0.0 GA 已发布到 Maven Central不需要添加 milestone 或 snapshot 仓库?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version4.0.0/version relativePath/ /parent groupIdcom.example/groupId artifactIdfault-mcp-server/artifactId version0.0.1-SNAPSHOT/version properties java.version21/java.version spring-ai.version2.0.0/spring-ai.version /properties dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version${spring-ai.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-server-webmvc/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-resource-server/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.postgresql/groupId artifactIdpostgresql/artifactId scoperuntime/scope /dependency /dependencies /projectapplication.yml 里把 MCP Server 的关键参数配好注意这里没有硬编码任何密钥全部走环境变量server: address: ${MCP_BIND_ADDRESS:127.0.0.1} port: ${MCP_PORT:8080} error: include-message: never include-stacktrace: never spring: application: name: fault-mcp-server datasource: url: ${DB_URL} username: ${DB_USERNAME} password: ${DB_PASSWORD} ai: mcp: server: name: fault-diagnosis-mcp version: 1.0.0 type: SYNC protocol: STREAMABLE instructions: 查询故障知识创建工单前必须向用户展示参数并获得确认 capabilities: tool: true resource: false prompt: false completion: false annotation-scanner: enabled: true request-timeout: 15s streamable-http: mcp-endpoint: /mcp security: mcp: issuer-uri: ${MCP_ISSUER_URI} audience: ${MCP_AUDIENCE} allowed-origins: ${MCP_ALLOWED_ORIGINS:http://127.0.0.1}这里type: SYNC和protocol: STREAMABLE是两个关键配置。SYNC 表示工具方法返回普通 Java 对象ASYNC 则只注册响应式方法。如果你在 SYNC Server 里写了返回Mono的方法Spring AI 2.0 会直接过滤掉它服务能启动但tools/list里少工具。这个坑后面会专门讲。数据库先建三张表故障知识表、维修工单表、审计日志表。幂等不能只靠 Java 判断必须让数据库唯一约束兜底create table fault_knowledge ( id bigserial primary key, tenant_id varchar(64) not null, fault_code varchar(64) not null, title varchar(200) not null, symptom text not null, solution text not null, updated_at timestamptz not null default now(), unique (tenant_id, fault_code) ); create table maintenance_ticket ( id bigserial primary key, request_id uuid not null unique, tenant_id varchar(64) not null, asset_id varchar(64) not null, severity varchar(16) not null check (severity in (LOW,MEDIUM,HIGH,CRITICAL)), summary varchar(500) not null, payload_hash char(64) not null, status varchar(16) not null default OPEN, created_by varchar(128) not null, created_at timestamptz not null default now() ); create table mcp_audit_log ( id bigserial primary key, request_id varchar(64) not null, tenant_id varchar(64) not null, principal_id varchar(128) not null, tool_name varchar(80) not null, result_code varchar(40) not null, payload_hash char(64) not null, created_at timestamptz not null default now() );审计表不保存原始查询词、工单描述和 Token只保存请求标识、主体、租户、工具、结果码与载荷哈希。哈希不是加密不能从哈希还原原文它的用途是判断两次调用是否针对同一载荷并在事故调查时关联记录。3. 可复制配置MCP 工具白名单与安全边界落地工具白名单的核心思路是Tool 类只负责 MCP 名称、Schema、Hint、边界校验和稳定错误映射Service 类负责权限、租户、事务、幂等和审计Repository 只执行参数化 SQL数据库负责唯一性与约束的最终一致性。先定义输入输出模型用 Bean Validation 做服务端校验package com.example.faultmcp.domain; import jakarta.validation.constraints.*; import java.time.Instant; import java.util.List; public final class ToolModels { private ToolModels() {} public record QueryKnowledgeInput( NotBlank Size(max 100) String keyword, Size(max 64) String faultCode, Min(1) Max(20) int limit) {} public record KnowledgeItem( String faultCode, String title, String symptom, String solution, Instant updatedAt) {} public record QueryKnowledgeResult( boolean ok, String code, String message, ListKnowledgeItem items, String errorId) { public static QueryKnowledgeResult success(ListKnowledgeItem items) { return new QueryKnowledgeResult(true, OK, 查询完成, List.copyOf(items), null); } public static QueryKnowledgeResult failure(String code, String message, String errorId) { return new QueryKnowledgeResult(false, code, message, List.of(), errorId); } } public record CreateTicketInput( NotBlank Pattern(regexp ^[0-9a-fA-F-]{36}$, message requestId 必须是 UUID) String requestId, NotBlank Size(max 64) String assetId, NotBlank Pattern(regexp LOW|MEDIUM|HIGH|CRITICAL) String severity, NotBlank Size(min 5, max 500) String summary) {} public record TicketData( long ticketId, String requestId, String assetId, String severity, String status, Instant createdAt, boolean replayed) {} public record CreateTicketResult( boolean ok, String code, String message, TicketData ticket, String errorId) { public static CreateTicketResult success(TicketData ticket) { String message ticket.replayed() ? 重复请求返回原工单 : 工单创建成功; return new CreateTicketResult(true, OK, message, ticket, null); } public static CreateTicketResult failure(String code, String message, String errorId) { return new CreateTicketResult(false, code, message, null, errorId); } } }MCP 自动生成的 JSON Schema 能帮助模型构造参数但不应成为唯一校验。请求可能由旧客户端、恶意脚本或绕过 Schema 的调用者直接提交所以进入工具方法后仍要执行一次服务端校验package com.example.faultmcp.tool; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validator; import org.springframework.stereotype.Component; import java.util.Comparator; import java.util.Set; import java.util.stream.Collectors; Component public final class ToolInputValidator { private final Validator validator; public ToolInputValidator(Validator validator) { this.validator validator; } public T void requireValid(T input) { SetConstraintViolationT violations validator.validate(input); if (!violations.isEmpty()) { String message violations.stream() .sorted(Comparator.comparing(v - v.getPropertyPath().toString())) .map(v - v.getPropertyPath() : v.getMessage()) .collect(Collectors.joining(; )); throw new InvalidToolArgumentException(message); } } } final class InvalidToolArgumentException extends RuntimeException { InvalidToolArgumentException(String message) { super(message); } }从经过验证的 JWT 获取当前操作人tenant_id 来自 JWT claim 而不是工具参数package com.example.faultmcp.security; import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.stereotype.Component; Component public class CurrentMcpPrincipal { public record McpPrincipal(String subject, String tenantId) {} public McpPrincipal required() { var authentication SecurityContextHolder.getContext().getAuthentication(); if (!(authentication instanceof JwtAuthenticationToken jwt) || !jwt.isAuthenticated()) { throw new AuthenticationCredentialsNotFoundException(MCP authentication required); } String subject jwt.getToken().getSubject(); String tenantId jwt.getToken().getClaimAsString(tenant_id); if (subject null || subject.isBlank() || tenantId null || tenantId.isBlank()) { throw new AuthenticationCredentialsNotFoundException(Required claims are missing); } return new McpPrincipal(subject, tenantId); } }Service 层把权限、事务、幂等和审计全部收进来。查询要求SCOPE_kb.read创建要求SCOPE_ticket.writepackage com.example.faultmcp.service; import com.example.faultmcp.domain.ToolModels.*; import com.example.faultmcp.repository.FaultRepository; import com.example.faultmcp.security.CurrentMcpPrincipal; import com.example.faultmcp.security.CurrentMcpPrincipal.McpPrincipal; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.HexFormat; import java.util.UUID; Service public class FaultToolService { private final FaultRepository repository; private final CurrentMcpPrincipal currentPrincipal; public FaultToolService(FaultRepository repository, CurrentMcpPrincipal currentPrincipal) { this.repository repository; this.currentPrincipal currentPrincipal; } Transactional(readOnly true) PreAuthorize(hasAuthority(SCOPE_kb.read)) public QueryKnowledgeResult queryKnowledge(QueryKnowledgeInput input) { McpPrincipal principal currentPrincipal.required(); String code blankToNull(input.faultCode()); var items repository.searchKnowledge( principal.tenantId(), input.keyword().trim(), code, input.limit()); return QueryKnowledgeResult.success(items); } Transactional PreAuthorize(hasAuthority(SCOPE_ticket.write)) public CreateTicketResult createTicket(CreateTicketInput input) { McpPrincipal principal currentPrincipal.required(); UUID requestId UUID.fromString(input.requestId()); String payloadHash sha256(String.join(\n, input.assetId(), input.severity(), input.summary())); var inserted repository.insertTicket( requestId, principal.tenantId(), input.assetId(), input.severity(), input.summary(), payloadHash, principal.subject()); if (inserted.isPresent()) { repository.writeAudit(input.requestId(), principal.tenantId(), principal.subject(), create_maintenance_ticket, CREATED, payloadHash); return CreateTicketResult.success(inserted.get().toData(false)); } var existing repository.findTicket(requestId); if (!existing.tenantId().equals(principal.tenantId()) || !MessageDigest.isEqual( existing.payloadHash().getBytes(StandardCharsets.US_ASCII), payloadHash.getBytes(StandardCharsets.US_ASCII))) { repository.writeAudit(input.requestId(), principal.tenantId(), principal.subject(), create_maintenance_ticket, IDEMPOTENCY_CONFLICT, payloadHash); throw new IdempotencyConflictException(); } repository.writeAudit(input.requestId(), principal.tenantId(), principal.subject(), create_maintenance_ticket, REPLAYED, payloadHash); return CreateTicketResult.success(existing.toData(true)); } private static String blankToNull(String value) { return value null || value.isBlank() ? null : value.trim(); } private static String sha256(String value) { try { return HexFormat.of().formatHex( MessageDigest.getInstance(SHA-256) .digest(value.getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(SHA-256 unavailable, e); } } public static final class IdempotencyConflictException extends RuntimeException {} }创建逻辑先尝试原子插入而不是先查再插。首次调用得到新工单重复调用读取旧工单。若 requestId 相同但载荷哈希不同服务拒绝复用。审计成功记录与业务写入位于同一事务不会出现工单已创建、成功审计却没落库的半状态。Tool 类声明 Schema 和 Hint映射稳定错误package com.example.faultmcp.tool; import com.example.faultmcp.domain.ToolModels.*; import com.example.faultmcp.service.FaultToolService; import com.example.faultmcp.service.FaultToolService.IdempotencyConflictException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.McpToolParam; import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Component; import java.util.UUID; Component public class FaultMcpTools { private static final Logger log LoggerFactory.getLogger(FaultMcpTools.class); private final FaultToolService service; private final ToolInputValidator validator; public FaultMcpTools(FaultToolService service, ToolInputValidator validator) { this.service service; this.validator validator; } McpTool( name query_fault_knowledge, title 查询故障知识, description 按当前登录租户查询故障知识。只读不查询其他租户。, generateOutputSchema true, annotations McpTool.McpAnnotations( title 查询故障知识, readOnlyHint true, destructiveHint false, idempotentHint true, openWorldHint false)) public QueryKnowledgeResult queryFaultKnowledge( McpToolParam(description 症状或标题关键词最多100字符, required true) String keyword, McpToolParam(description 可选故障编码最多64字符, required false) String faultCode, McpToolParam(description 返回条数1到20, required true) int limit) { try { var input new QueryKnowledgeInput(keyword, faultCode, limit); validator.requireValid(input); return service.queryKnowledge(input); } catch (InvalidToolArgumentException e) { return QueryKnowledgeResult.failure(INVALID_ARGUMENT, e.getMessage(), null); } catch (AccessDeniedException e) { return QueryKnowledgeResult.failure(FORBIDDEN, 当前身份没有知识查询权限, null); } catch (RuntimeException e) { String errorId UUID.randomUUID().toString(); log.error(query_fault_knowledge failed, errorId{}, errorId, e); return QueryKnowledgeResult.failure( INTERNAL_ERROR, 系统暂不可用请使用 errorId 联系管理员, errorId); } } McpTool( name create_maintenance_ticket, title 创建维修工单, description 为当前登录租户创建维修工单。调用前必须向用户展示设备、级别和摘要并确认。, generateOutputSchema true, annotations McpTool.McpAnnotations( title 创建维修工单, readOnlyHint false, destructiveHint false, idempotentHint true, openWorldHint false)) public CreateTicketResult createMaintenanceTicket( McpToolParam(description 调用方生成的UUID幂等键重试必须复用同一值, required true) String requestId, McpToolParam(description 设备编号最多64字符, required true) String assetId, McpToolParam(description LOW、MEDIUM、HIGH或CRITICAL, required true) String severity, McpToolParam(description 工单摘要5到500字符, required true) String summary) { try { var input new CreateTicketInput(requestId, assetId, severity, summary); validator.requireValid(input); return service.createTicket(input); } catch (InvalidToolArgumentException e) { return CreateTicketResult.failure(INVALID_ARGUMENT, e.getMessage(), null); } catch (AccessDeniedException e) { return CreateTicketResult.failure(FORBIDDEN, 当前身份没有创建工单权限, null); } catch (IdempotencyConflictException e) { return CreateTicketResult.failure( IDEMPOTENCY_CONFLICT, requestId 已被不同请求使用, null); } catch (RuntimeException e) { String errorId UUID.randomUUID().toString(); log.error(create_maintenance_ticket failed, errorId{}, errorId, e); return CreateTicketResult.failure( INTERNAL_ERROR, 系统暂不可用请使用 errorId 联系管理员, errorId); } } }这里把创建标成destructiveHintfalse意思是它执行的是新增而非删除或覆盖它仍然是有副作用的所以readOnlyHintfalse。idempotentHinttrue也不是一句乐观描述前面的唯一约束、载荷哈希比较和重复响应已经让它成为可验证的幂等操作。4. 验证请求从 initialize 到 tools/call 的完整链路下面不用大模型直接用 HTTP 验证协议链路。这样能把模型是否选对工具和Server 是否正确执行分开排查。先设置环境变量示例只展示变量名不把真实 Token 写进命令历史export MCP_URLhttp://127.0.0.1:8080/mcp export MCP_ACCESS_TOKEN从身份平台获取、audience 为本 MCP 的短期令牌 export MCP_ORIGINhttp://127.0.0.1第一步 initialize这是 MCP 连接生命周期的起点承担协议版本和能力协商POST /mcp HTTP/1.1 Host: 127.0.0.1:8080 Authorization: Bearer ${MCP_ACCESS_TOKEN} Origin: http://127.0.0.1 Content-Type: application/json Accept: application/json, text/event-stream { jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2025-06-18, capabilities: {}, clientInfo: { name: fault-mcp-smoke-test, version: 1.0.0 } } }一次合理的响应形状如下字段取决于实际协商不要写测试断言强行认定 Server 一定返回示例中的版本{ jsonrpc: 2.0, id: 1, result: { protocolVersion: 2025-06-18, capabilities: { tools: { listChanged: true } }, serverInfo: { name: fault-diagnosis-mcp, version: 1.0.0 } } }记录响应头中的Mcp-Session-Id如果 Server 返回再发送初始化完成通知。后续请求中的MCP-Protocol-Version必须使用协商结果POST /mcp HTTP/1.1 Authorization: Bearer ${MCP_ACCESS_TOKEN} Origin: http://127.0.0.1 Mcp-Session-Id: ${MCP_SESSION_ID} MCP-Protocol-Version: 2025-06-18 Content-Type: application/json Accept: application/json, text/event-stream { jsonrpc: 2.0, method: notifications/initialized }第二步 tools/list返回中应出现两个工具以及由参数生成的 inputSchema{ jsonrpc: 2.0, id: 2, method: tools/list, params: {} }返回的关键结构如下检查的不只是名称还包括 required、类型、描述和 Hint{ jsonrpc: 2.0, id: 2, result: { tools: [ { name: query_fault_knowledge, description: 按当前登录租户查询故障知识。只读不查询其他租户。, inputSchema: { type: object, properties: { keyword: {type: string}, faultCode: {type: string}, limit: {type: integer} }, required: [keyword, limit] }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } }, { name: create_maintenance_ticket, inputSchema: { type: object, properties: { requestId: {type: string}, assetId: {type: string}, severity: {type: string}, summary: {type: string} }, required: [requestId, assetId, severity, summary] } } ] } }能看到工具只证明注册成功。它没有证明当前 Token 拥有调用权限更没有证明数据库幂等正确。第三步调用只读知识查询{ jsonrpc: 2.0, id: 3, method: tools/call, params: { name: query_fault_knowledge, arguments: { keyword: 主轴温度升高, faultCode: , limit: 5 } } }工具结果中的文本包装由 SDK 负责结构化内容应与 Java 返回模型一致{ ok: true, code: OK, message: 查询完成, items: [ { faultCode: SPINDLE-TEMP-01, title: 主轴温升异常, symptom: 连续运行后温度快速上升, solution: 检查润滑、冷却回路和轴承预紧状态, updatedAt: 2026-07-20T08:30:00Z } ], errorId: null }第四步创建并重放维修工单。Host 应先把设备、级别和摘要展示给用户用户确认后才发送{ jsonrpc: 2.0, id: 4, method: tools/call, params: { name: create_maintenance_ticket, arguments: { requestId: b8ad3f3a-3424-4e31-b92e-9980cfb34037, assetId: A-17, severity: HIGH, summary: 主轴连续运行后温升异常请检查冷却回路与润滑状态 } } }首次调用得到{ ok: true, code: OK, message: 工单创建成功, ticket: { ticketId: 101, requestId: b8ad3f3a-3424-4e31-b92e-9980cfb34037, assetId: A-17, severity: HIGH, status: OPEN, createdAt: 2026-07-27T09:00:00Z, replayed: false }, errorId: null }使用完全相同的 requestId 和参数重试应返回同一个 ticketId并且replayedtrue。数据库中仍然只有一张工单。随后保持 requestId 不变、修改摘要再次调用应得到IDEMPOTENCY_CONFLICT而不是悄悄修改旧工单或新建第二张。这三个断言共同证明幂等成立同键同载荷返回同一业务结果同键不同载荷被拒绝数据库唯一约束保证并发条件下也不会双写。如果你用 TaoToken 的模型对话功能来验证工具链可以在 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 里配置 MCP Server 地址然后让模型发起一次查一下主轴温度异常的请求观察它是否正确调用query_fault_knowledge而不是直接编造答案。5. 常见报错排查401、Origin 拒绝、工具缺失与幂等冲突这一节对照真实报错逐项排查。401 Unauthorized最常见的原因是 Token 缺失、过期、签名错误、issuer 不匹配或 audience 不匹配。先检查Authorization头是否携带再确认 JWT 的aud字段是否包含security.mcp.audience配置的值。如果用了 TaoToken 统一 Key 接入确认 Key 没有过期并且请求头格式是Bearer token。注意不要把入站 Token 原样转发给下游 APIMCP 授权规范明确禁止 Token Passthrough。403 ForbiddenOrigin rejectedMcpOriginFilter在认证之前执行如果请求头里的 Origin 不在白名单里直接返回 403。检查security.mcp.allowed-origins配置确保完整匹配 scheme、host、port。不要用endsWith之类的模糊判断evil-example.com也可能通过。很多 CLI Client 不发送 Origin示例允许请求头缺失但缺失绝不代表请求可信它仍需通过 Bearer Token 验证。tools/list 里少工具如果服务能启动但工具没注册先看启动日志里是否有方法因 Server 类型不匹配而被过滤的警告。SYNC Server 只注册返回普通 Java 对象的方法ASYNC Server 只注册响应式方法。如果你在 SYNC 配置下写了返回MonoT的方法它会被静默过滤。检查spring.ai.mcp.server.type和工具方法的返回类型是否一致。local proxy failed / connection refusedStreamable HTTP 模式下确认server.address绑定的是127.0.0.1而不是0.0.0.0端口没有被占用。如果通过网关访问确认网关正确转发了Origin和Authorization头并且应用看到的是客户端真实 Origin 而不是被代理覆盖的内部值。reading choices 报错这通常出现在模型调用工具后解析返回结构时。检查工具返回的 JSON 结构是否与generateOutputSchema生成的 schema 一致。如果返回了null字段而 schema 里标记为 required客户端可能解析失败。确保QueryKnowledgeResult和CreateTicketResult的字段在成功和失败路径下都完整填充。OAuth 相关报错如果issuer-uri配置错误Spring Security 启动时会尝试拉取 OIDC 配置并失败。确认MCP_ISSUER_URI可访问并且返回的jwks_uri正确。如果 audience 校验失败检查JwtDecoder里的audienceValidator是否正确读取了security.mcp.audience。幂等冲突 IDEMPOTENCY_CONFLICT同一个 requestId 被不同载荷复用了。检查调用方是否在重试时重新生成了 requestId或者是否在修改参数后没有更新 requestId。正确做法是重试必须复用同一 requestId 和同一载荷如果要修改内容生成新的 requestId。数据库连接中断返回 INTERNAL_ERROR这是预期行为响应里只包含 errorId不包含 JDBC URL、SQL 和堆栈。用 errorId 去服务端日志里定位详细异常。检查日志里是否意外记录了 Token 或完整参数可以用grep -E Authorization:|Bearer |DB_PASSWORD|jdbc:postgresql app.log扫描。CC Switch / Cline MCP / Codex auth.json 配置如果你用这些工具接入 MCP Server需要写全三件套——Base URL、Key、Model ID。Base URL 填https://taotoken.net/apiKey 填你在控制台创建的 API KeyModel ID 填你实际使用的模型标识。Claude Code 的接入可以参考 https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecodeutm_campaignrewrite 里的说明。6. 把大模型能力收进最小权限沙箱回头看文章开头那个已经能创建工单的 Demo真正让它具备上线基础的不是多写了一个注解而是每个不可信输入都有明确去向网络层检查 Origin认证层验证 Token 受众Service 层检查 Scope 与租户数据库用唯一约束封住并发重试审计留下最小必要证据错误映射阻止内部信息进入模型上下文。MCP 解决的是能力如何被标准化发现与调用不是业务如何自动获得安全性。这反而是一件好事协议做好协议的事Spring Security、事务和数据库继续做它们擅长的事。我们不需要发明一套AI 专属权限系统只需要拒绝因为调用者是大模型就跳过成熟的工程边界。上线前可以逐项检查是否先完成 initialize 并使用协商后的协议版本是否统一使用 Streamable HTTP 术语和单一 MCP 端点Server 是否只开放真正需要的 capabilities本地运行是否默认绑定 127.0.0.1有 Origin 时是否按完整 allowlist 精确校验每次请求是否验证 Token 的签名、有效期、issuer 和 audience是否禁止把入站 Token 原样传给下游tenant_id 与主体是否来自认证上下文而非模型参数查询和创建是否分别要求 kb.read、ticket.write所有参数是否在 Server 端再次校验SQL 是否参数化租户条件是否直接进入查询创建工单是否由数据库唯一约束保证幂等同幂等键不同载荷是否明确拒绝业务写入与成功审计是否处于一致事务是否只向客户端返回稳定错误码和 errorId日志、APM 和代理是否都不记录 Token 与完整敏感参数Host 是否在有副作用工具执行前展示参数并让用户确认是否验证过 401、403、非法参数、重复请求、错误 Origin 和数据库故障SYNC/ASYNC 配置是否与工具返回类型一致。工具可发现是协议成功工具在错误身份、错误参数、重复请求和错误来源下都能安全拒绝才是工程成功。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑