Java MCP服务鉴权实战:从JWT到Spring Security的完整实现
1. 项目概述:为什么我们需要关注Java MCP鉴权?
最近在几个Java后端项目的技术评审会上,一个词被反复提及:MCP。尤其是在讨论微服务间调用、内部工具链集成,甚至是最近在尝试将一些AI辅助开发工具(比如一些基于大模型的代码生成助手)接入现有系统时,MCP协议及其鉴权设计成了绕不开的话题。我手头就有一个刚处理完的线上问题,一个内部数据查询服务因为MCP接口鉴权缺失,差点导致非授权数据被拉取。这让我觉得,是时候把这块的经验系统地梳理一下了。
简单来说,MCP(Model Context Protocol)最初是为AI模型与工具之间安全、结构化通信而设计的协议。但它的思想——定义清晰的请求/响应格式、支持工具发现与调用、强调安全性——正在被越来越多的内部系统、平台工具所借鉴和采用。当你用Java构建一个服务,这个服务需要对外提供一组标准化的、可被其他程序(可能是另一个微服务、一个运维脚本,或者一个AI助手)调用的“工具”时,你就可能正在设计一个“MCP Server”。而 鉴权 ,就是确保只有被允许的“客户端”才能调用这些工具的第一道,也是最重要的一道防线。
所以,这篇指南不是空谈理论,而是基于我最近在金融和电商领域项目中实际落地MCP风格接口的踩坑经验。我会带你从零开始,理解在Java生态中为MCP服务设计鉴权需要考虑什么,如何选择方案,以及如何用代码实现一个既安全又实用的鉴权层。无论你是在构建内部开发者工具平台,还是在集成智能编码助手,希望这些实打实的经验能帮你避开我走过的弯路。
2. MCP鉴权核心概念与设计考量
在动手写代码之前,我们必须把几个关键概念和设计时的权衡点想清楚。鉴权不是简单加个API Key,尤其是在MCP这种可能面临复杂调用场景的协议下。
2.1 MCP通信模型与安全边界
首先,我们要明确MCP通信的典型模型。通常,会有一个 MCP Server (我们的Java服务)对外暴露一组定义好的工具(Tools),比如“查询用户订单”、“执行数据统计”、“生成系统报告”等。 MCP Client (调用方)则通过标准的MCP协议(通常是基于JSON-RPC over STDIO/SSE/WebSocket)来发现和调用这些工具。
这里的安全边界非常清晰:
- 客户端身份(Who) :谁在调用?是一个内部运维系统,一个CI/CD流水线,还是一个经过审核的第三方集成?
- 操作权限(What) :这个身份允许调用哪些工具?对于“删除数据库”这种工具,显然不能对所有客户端开放。
- 请求完整性(How) :请求在传输过程中有没有被篡改?
MCP鉴权的目标,就是在Server端对每一个传入的请求,都能可靠地回答这三个问题。
2.2 常见鉴权模式选型
Java生态中可用于MCP鉴权的技术很多,选型取决于你的部署环境和安全要求。
1. 传输层安全(TLS/SSL) 这是基础中的基础。无论后续采用何种鉴权方式, 必须启用HTTPS(对于HTTP传输)或WSS(WebSocket) 。这确保了通信的加密和防止窃听。在内部网络,你可能会使用私有CA签发的证书。这是成本最低、收益最高的安全措施,没有之一。
2. 静态令牌(Static Token) 最简单直接的方式,如API Key、Bearer Token。客户端在请求头(如 Authorization: Bearer <token> )中携带一个预先共享的密钥。
- 优点 :实现简单,易于理解和调试。
- 缺点 :令牌泄露即意味着完全权限泄露;缺乏细粒度权限控制;轮换令牌需要协调所有客户端。
- 适用场景 :服务器对服务器的内部调用,且网络环境相对可信(如同一VPC),需要快速原型验证时。
3. 动态令牌(JWT) JSON Web Token是一种自包含的令牌,将身份信息和声明(Claims)编码在令牌本身。Server通过验证签名来确认令牌的有效性和内容。
- 优点 :无状态,Server不需要存储会话;令牌本身可携带丰富的用户身份和权限信息(如角色、可访问的工具列表);适合分布式系统。
- 缺点 :令牌一旦签发,在有效期内无法主动废止(除非使用黑名单,但这又引入了状态);需要妥善管理签名密钥。
- 适用场景 :客户端身份多样,需要携带上下文的场景,是当前MCP鉴权的主流选择之一。
4. OAuth 2.0 / OpenID Connect 这是行业标准,特别适合第三方应用集成或需要用户授权的场景。客户端先向授权服务器获取Access Token,再用这个Token来访问MCP Server。
- 优点 :权限粒度可精确到具体资源和操作(通过Scope);支持令牌刷新和撤销;生态成熟。
- 缺点 :架构复杂,需要引入授权服务器;对于纯服务器间通信略显繁重。
- 适用场景 :你的MCP Server需要被不同的、不受你完全控制的第三方应用调用,或者工具调用需要模拟特定用户权限时。
5. 双向TLS认证(mTLS) 不仅Server提供证书,Client也必须提供由可信CA签发的证书。Server通过验证客户端证书来确认其身份。
- 优点 :非常高的安全性,证书既用于加密也用于身份认证;难以伪造。
- 缺点 :证书管理复杂度高(签发、部署、轮换);不太适合动态变化的客户端(如临时脚本)。
- 适用场景 :对安全性要求极高的内部服务间通信,特别是在零信任网络架构中。
实操心得:如何选择? 我的经验是分层组合使用。 对于内部MCP Server :
TLS + JWT是一个甜点组合。TLS保证通道安全,JWT用于传递身份和权限。你可以用一个轻量的认证服务来签发JWT。 对于需要对接AI助手(如Claude Code)的MCP Server :这些客户端通常只支持简单的Bearer Token或API Key。这时,你可以设计一个“网关”或“适配层”,它持有高权限的Token,负责对AI助手的请求进行二次鉴权和参数过滤,再将安全的请求转发给真正的MCP Server。永远不要将核心MCP Server直接暴露给不受控的客户端。
2.3 权限模型设计
确定了“你是谁”,接下来要决定“你能干什么”。MCP的“工具”本身就是天然的权限单元。
- RBAC(基于角色的访问控制) :为用户或客户端分配角色(如
VIEWER,OPERATOR,ADMIN),角色关联到可用的工具列表。这是最常用、最易管理的模型。 - ABAC(基于属性的访问控制) :更细粒度,根据客户端属性(如部门、项目)、资源属性(如工具所属模块)、环境属性(如时间)来动态决定权限。功能强大但实现复杂。
- 工具级白名单 :最简单粗暴,为每个客户端配置一个它允许调用的工具ID列表。适合客户端数量少、工具不多的场景。
在设计时,我建议从RBAC开始。为每个MCP工具定义一个唯一的标识符(如 tool:order:query ),然后在JWT的 scope 声明或权限表中,记录 clientA: [tool:order:query, tool:report:generate] 。
3. 基于Spring Boot实现JWT鉴权MCP Server
理论说完了,我们进入实战。我将以一个基于Spring Boot的MCP Server为例,演示如何集成JWT鉴权。假设我们使用STDIO作为传输层(这是很多AI工具集成MCP的方式),但鉴权逻辑对于SSE或WebSocket是通用的。
3.1 项目初始化与依赖
首先,创建一个标准的Spring Boot项目。核心依赖如下(以Maven为例):
<dependencies>
<!-- Spring Boot Web (用于HTTP/SSE, 这里我们先以HTTP为例说明鉴权) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 如果考虑WebSocket,还需加入 spring-boot-starter-websocket -->
<!-- JWT 支持 (我们选择成熟的 jjwt) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- 配置处理 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
注意 :关于MCP Server的协议实现,你可以选择自己处理JSON-RPC over STDIO,或者使用社区的一些起步库。本篇重点在鉴权,因此我们用一个简单的HTTP端点来模拟MCP工具调用,鉴权逻辑是完全相通的。
3.2 JWT工具类与配置
创建一个JWT工具类,负责令牌的生成和解析。我们将密钥和过期时间放在配置文件中。
1. 应用配置 application.yml :
app:
jwt:
secret-key: your-256-bit-secret-your-256-bit-secret-your-256-bit-secret # 生产环境务必使用强密钥,并从安全仓库获取
expiration-ms: 86400000 # 24小时
issuer: my-mcp-server
2. JWT配置属性类 JwtProperties.java :
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Data
@Component
@ConfigurationProperties(prefix = "app.jwt")
public class JwtProperties {
private String secretKey;
private Duration expiration = Duration.ofDays(1); // 默认1天
private String issuer;
}
3. JWT服务类 JwtService.java :
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class JwtService {
private final JwtProperties jwtProperties;
private SecretKey getSigningKey() {
// 将配置的字符串密钥转换为HMAC-SHA算法所需的SecretKey
return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8));
}
/**
* 为指定客户端生成JWT令牌
* @param clientId 客户端唯一标识
* @param roles 客户端角色列表
* @param allowedTools 允许访问的MCP工具列表
* @return JWT令牌字符串
*/
public String generateToken(String clientId, List<String> roles, List<String> allowedTools) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", roles);
claims.put("tools", allowedTools); // 将权限信息放入claims
return Jwts.builder()
.setClaims(claims)
.setSubject(clientId) // 主题设为客户端ID
.setIssuer(jwtProperties.getIssuer())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration().toMillis()))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
/**
* 解析并验证JWT令牌
* @param token JWT令牌
* @return 包含声明信息的Claims对象
*/
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 验证令牌是否有效(未过期且签发者正确)
* @param token JWT令牌
* @return 是否有效
*/
public boolean validateToken(String token) {
try {
Claims claims = parseToken(token);
// 检查签发者
return jwtProperties.getIssuer().equals(claims.getIssuer());
} catch (Exception e) {
log.warn("Invalid JWT token: {}", e.getMessage());
return false;
}
}
/**
* 从令牌中提取客户端ID
*/
public String extractClientId(String token) {
return parseToken(token).getSubject();
}
/**
* 从令牌中提取允许的工具列表
*/
@SuppressWarnings("unchecked")
public List<String> extractAllowedTools(String token) {
Claims claims = parseToken(token);
return (List<String>) claims.get("tools", List.class);
}
}
这个 JwtService 是我们的核心。它不仅能生成和验证令牌,还将客户端的权限( allowedTools )直接编码进了JWT的载荷(Claims)里。这样在验证令牌有效性的同时,我们也拿到了权限列表,无需再次查询数据库,符合JWT无状态的设计初衷。
3.3 实现Spring Security鉴权过滤器
接下来,我们需要一个过滤器来拦截HTTP请求,提取并验证JWT,然后将身份和权限信息设置到安全上下文中。
1. 创建JWT认证过滤器 JwtAuthenticationFilter.java :
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private static final String AUTH_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader(AUTH_HEADER);
if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
// 没有Token,交给后续过滤器(如匿名访问处理)
filterChain.doFilter(request, response);
return;
}
String jwtToken = authHeader.substring(BEARER_PREFIX.length());
log.debug("Attempting authentication with token for request: {}", request.getRequestURI());
if (jwtService.validateToken(jwtToken)) {
try {
String clientId = jwtService.extractClientId(jwtToken);
List<String> allowedTools = jwtService.extractAllowedTools(jwtToken);
// 将工具列表转换为Spring Security的权限标识
// 格式为 `TOOL_<tool_name>`,例如 `TOOL_order:query`
List<SimpleGrantedAuthority> authorities = allowedTools.stream()
.map(tool -> new SimpleGrantedAuthority("TOOL_" + tool))
.collect(Collectors.toList());
// 创建认证对象。密码为null,因为我们使用Token认证
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(clientId, null, authorities);
// 将认证信息设置到安全上下文
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("Authenticated client: {} for tools: {}", clientId, allowedTools);
} catch (Exception e) {
log.error("Failed to process JWT token", e);
// 不清除上下文,让后续的授权失败处理
}
} else {
log.warn("Invalid JWT token provided for request: {}", request.getRequestURI());
}
filterChain.doFilter(request, response);
}
}
2. 配置Spring Security SecurityConfig.java :
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 启用方法级安全注解,如 @PreAuthorize
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF,因为MCP API通常是无状态的,使用Token认证
.csrf(csrf -> csrf.disable())
// 配置CORS(根据你的客户端来源调整)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 设置会话管理为无状态
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 配置请求授权规则
.authorizeHttpRequests(auth -> auth
// 公开端点:健康检查、工具发现(/mcp/tools)可能需要公开或另行保护
.requestMatchers("/actuator/health", "/public/**").permitAll()
// 所有其他请求都需要认证
.anyRequest().authenticated()
)
// 在UsernamePasswordAuthenticationFilter之前添加我们的JWT过滤器
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("https://trusted-client.example.com")); // 生产环境严格指定
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
configuration.setAllowCredentials(true); // 如果前端需要传递Cookie或Authorization头,则设为true
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
这个配置做了几件关键事:
- 禁用了CSRF(适用于API)。
- 配置了CORS,允许指定来源的客户端访问。
- 设置了无状态会话管理,契合JWT。
- 定义了URL级别的权限规则(这里简单要求所有API需认证)。
- 将我们的
JwtAuthenticationFilter插入到了安全过滤链中。
3.4 定义MCP工具与权限控制
现在,我们来定义几个模拟的MCP工具,并使用Spring Security的注解进行方法级权限控制。
1. 工具定义与响应DTO:
// Tool.java - MCP工具描述
@Data
@AllArgsConstructor
public class Tool {
private String name; // 如 "query_user_orders"
private String description;
private Object inputSchema; // 输入参数JSON Schema,简化用Map表示
}
// ToolCallRequest.java - 工具调用请求
@Data
public class ToolCallRequest {
private String toolName;
private Map<String, Object> arguments;
}
// ToolCallResponse.java - 工具调用响应
@Data
@AllArgsConstructor
public class ToolCallResponse {
private boolean success;
private Object content;
private String errorMessage;
}
2. MCP工具服务 McpToolService.java :
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
public class McpToolService {
/**
* 模拟工具:查询用户订单
* 要求调用者拥有 TOOL_order:query 权限
*/
@PreAuthorize("hasAuthority('TOOL_order:query')")
public ToolCallResponse queryUserOrders(Map<String, Object> arguments) {
String userId = (String) arguments.get("userId");
log.info("Client is querying orders for user: {}", userId);
// 模拟业务逻辑
List<Map<String, Object>> orders = List.of(
Map.of("orderId", "1001", "amount", 299.99),
Map.of("orderId", "1002", "amount", 159.50)
);
return new ToolCallResponse(true, orders, null);
}
/**
* 模拟工具:生成系统报告
* 要求调用者拥有 TOOL_report:generate 权限
*/
@PreAuthorize("hasAuthority('TOOL_report:generate')")
public ToolCallResponse generateSystemReport(Map<String, Object> arguments) {
String reportType = (String) arguments.get("type");
log.info("Client is generating report of type: {}", reportType);
// 模拟业务逻辑
String reportContent = String.format("System report for %s: All systems operational.", reportType);
return new ToolCallResponse(true, Map.of("report", reportContent), null);
}
/**
* 模拟高危工具:清理测试数据
* 要求调用者同时拥有 TOOL_admin:data_cleanup 权限和 ADMIN 角色
* 演示更复杂的权限控制
*/
@PreAuthorize("hasAuthority('TOOL_admin:data_cleanup') and hasRole('ADMIN')")
public ToolCallResponse cleanupTestData(Map<String, Object> arguments) {
log.warn("Performing critical cleanup operation.");
// 模拟业务逻辑
return new ToolCallResponse(true, Map.of("cleaned", true, "rowsAffected", 1500), null);
}
}
注意 @PreAuthorize 注解的使用。它直接在方法上声明了访问所需的条件。 hasAuthority('TOOL_order:query') 正是与我们JWT过滤器里设置的权限标识 TOOL_order:query 相匹配。Spring Security会在方法调用前进行校验,如果当前安全上下文中的认证信息不包含所需权限,将直接抛出 AccessDeniedException 。
3. MCP协议控制器 McpServerController.java : 这个控制器模拟了MCP Server的两个核心端点: tools (工具发现)和 toolCall (工具调用)。在真实MCP over HTTP/SSE实现中,这些端点可能对应不同的JSON-RPC方法。
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/mcp")
@RequiredArgsConstructor
public class McpServerController {
private final McpToolService toolService;
/**
* 模拟 MCP 的 `tools/list` 端点,返回可用工具列表。
* 这里可以根据客户端权限过滤返回的工具列表,实现动态发现。
*/
@GetMapping("/tools")
public ResponseEntity<List<Tool>> listTools() {
// 在实际项目中,这里应该从安全上下文中获取当前客户端权限,过滤后返回
// 此处简化,返回所有工具描述
List<Tool> tools = List.of(
new Tool("query_user_orders", "Query orders for a given user",
Map.of("userId", Map.of("type", "string"))),
new Tool("generate_system_report", "Generate a system report",
Map.of("type", Map.of("type", "string", "enum", List.of("daily", "weekly")))),
new Tool("cleanup_test_data", "[ADMIN ONLY] Clean up all test data", Map.of())
);
return ResponseEntity.ok(tools);
}
/**
* 模拟 MCP 的 `tools/call` 端点,调用具体工具。
*/
@PostMapping("/tool-call")
public ResponseEntity<ToolCallResponse> callTool(@RequestBody ToolCallRequest request) {
log.info("Received tool call request: {}", request.getToolName());
try {
ToolCallResponse response = switch (request.getToolName()) {
case "query_user_orders" -> toolService.queryUserOrders(request.getArguments());
case "generate_system_report" -> toolService.generateSystemReport(request.getArguments());
case "cleanup_test_data" -> toolService.cleanupTestData(request.getArguments());
default -> new ToolCallResponse(false, null, "Tool not found: " + request.getToolName());
};
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Error calling tool: {}", request.getToolName(), e);
// Spring Security的AccessDeniedException会被全局异常处理器捕获,返回403
// 其他业务异常返回500
return ResponseEntity.internalServerError()
.body(new ToolCallResponse(false, null, "Internal server error: " + e.getMessage()));
}
}
}
3.5 生成测试令牌与验证
最后,我们创建一个管理端点( 仅用于测试,生产环境必须移除或严格保护 )来生成令牌,方便我们测试。
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
import java.util.List;
@RestController
@RequestMapping("/test")
@RequiredArgsConstructor
public class TestTokenController {
private final JwtService jwtService;
@PostMapping("/token")
public String generateTestToken(
@RequestParam String clientId,
@RequestParam(defaultValue = "USER") List<String> roles,
@RequestParam List<String> tools) {
// 警告:此端点仅用于演示和测试!
return jwtService.generateToken(clientId, roles, tools);
}
}
现在,我们可以启动应用,进行测试:
- 调用
POST /test/token?clientId=internal-service&tools=order:query,report:generate获取一个令牌。 - 使用这个令牌,调用
GET /mcp/tools(需要认证,所以Header里要带Authorization: Bearer <token>)。 - 调用
POST /mcp/tool-call尝试执行query_user_orders,应该成功。 - 尝试调用
cleanup_test_data,由于令牌里没有admin:data_cleanup权限,也没有ADMIN角色,将会收到 403 Forbidden 错误。
4. 高级话题:STDIO传输下的鉴权与安全加固
上面的例子基于HTTP,逻辑清晰。但很多MCP实现(尤其是与本地AI工具集成时)使用 STDIO(标准输入输出) 作为传输层。这时,鉴权如何做?
4.1 STDIO模式的鉴权挑战与方案
在STDIO模式下,Client和Server是父子进程关系,通过管道通信。没有HTTP头,传统的Bearer Token无处安放。常见的解决方案有:
方案一:初始化握手(Initial Handshake) 在Server启动并建立STDIO连接后,Client发送的第一条或前几条消息是认证消息,包含身份凭证(如API Key)。Server验证通过后,才允许后续的工具发现和调用请求。这是最符合MCP无状态特性的方式。
- 实现 :在Server端,读取第一条JSON-RPC请求,判断其
method是否为auth/initialize或类似,验证其params中的凭证。验证通过后,在内存或上下文中标记该连接已认证,并回复成功。后续所有请求都需检查这个状态。
方案二:连接时参数传递 在启动MCP Server进程时,通过环境变量或命令行参数传递一个密钥。Server进程自身用这个密钥来验证所有请求(因为能连接到这个进程的Client必然能访问这些启动参数)。这种方式安全性较低,因为同一台机器上能查看进程列表的用户都可能获取密钥。
- 实现 :在Server的main函数中读取环境变量
MCP_API_KEY,并将其存储在一个全局变量中。在处理每个请求时,比较请求中携带的密钥(可以放在JSON-RPC请求的params或自定义字段中)是否匹配。
方案三:基于TLS的进程间通信(IPC) 这是最安全但最复杂的方式。可以为Client和Server之间的STDIO管道建立TLS加密,并使用客户端证书(mTLS)进行认证。这通常需要修改进程启动和管道建立的方式,超出了标准MCP的简单性。
实操心得:STDIO鉴权实现片段 假设我们采用 初始化握手 方案。在Server的主循环中,可以这样处理:
// 伪代码,展示逻辑 public void runServer() { boolean isAuthenticated = false; String clientId = null; List<String> allowedTools = null; while ((message = readFromStdin()) != null) { JsonRpcRequest request = parseJsonRpc(message); if (!isAuthenticated) { // 必须第一个请求是认证请求 if (!"auth/initialize".equals(request.getMethod())) { sendError(request.getId(), "Unauthorized: Authentication required first"); continue; } // 验证凭证,例如API Key ApiKeyCredential cred = extractCredential(request); ClientInfo clientInfo = authService.validateApiKey(cred.getKey()); if (clientInfo == null) { sendError(request.getId(), "Unauthorized: Invalid API Key"); // 可以考虑在此处终止连接 break; } isAuthenticated = true; clientId = clientInfo.getId(); allowedTools = clientInfo.getAllowedTools(); sendSuccess(request.getId(), Map.of("status", "authenticated")); } else { // 处理已认证的请求 if ("tools/list".equals(request.getMethod())) { // 根据 allowedTools 过滤返回的工具列表 sendFilteredToolsList(request.getId(), allowedTools); } else if ("tools/call".equals(request.getMethod())) { String toolName = request.getParams().getToolName(); // 检查请求的工具是否在允许列表中 if (!allowedTools.contains(toolName)) { sendError(request.getId(), "Forbidden: Tool not allowed"); continue; } // 执行工具调用 executeToolCall(request); } } } }
4.2 生产环境安全加固要点
无论采用哪种传输方式,以下加固措施都至关重要:
-
密钥管理 :
- 绝对不要 将密钥硬编码在代码或配置文件中提交到代码仓库。
- 使用专业的密钥管理服务(如HashiCorp Vault、AWS Secrets Manager、Azure Key Vault)或在部署时通过环境变量注入。
- 为不同的环境(开发、测试、生产)使用不同的密钥。
-
令牌安全 :
- JWT的签名密钥(Secret Key)必须足够长且随机(至少32字节)。
- 设置合理的令牌过期时间(如24小时),对于高权限操作可以考虑更短。
- 考虑实现令牌吊销列表(黑名单)机制,用于在令牌泄露或客户端权限变更时立即失效特定令牌。这需要引入一点状态存储(如Redis),但提升了安全性。
-
输入验证与输出过滤 :
- MCP工具的参数来自不可控的客户端,必须进行严格的输入验证(类型、范围、长度、SQL注入防护等)。
- 对工具返回的数据进行过滤,避免敏感信息泄露。例如,
query_user_orders工具不应返回用户的密码哈希或完整银行卡号。
-
限流与防刷 :
- 为每个客户端或每个工具接口设置速率限制(Rate Limiting),防止滥用或DDoS攻击。可以使用Spring Boot集成Resilience4j或Sentinel,或者通过API网关实现。
-
全面的日志与审计 :
- 记录所有认证成功和失败的事件。
- 记录所有工具调用的详细信息:客户端ID、工具名、参数(脱敏后)、时间戳、结果状态。这对于安全事件追溯和运营分析至关重要。
- 确保日志不会被未授权访问。
-
网络层隔离 :
- 将MCP Server部署在内网,不直接暴露在公网。
- 如果必须对外提供服务,前面一定要有API网关(如Kong, APISIX)进行统一的认证、授权、限流和日志。
5. 常见问题排查与调试技巧
在实际开发和运维中,你肯定会遇到各种鉴权相关的问题。这里记录几个我踩过的坑和解决方法。
5.1 问题排查清单
| 问题现象 | 可能原因 | 排查步骤 |
|---|---|---|
| 401 Unauthorized | 1. 请求头缺少 Authorization 。 2. Token格式错误(未以 Bearer 开头)。 3. Token已过期。 4. Token签名验证失败(密钥不匹配)。 |
1. 检查请求头是否准确包含 Authorization: Bearer <token> 。 2. 用在线工具(如 jwt.io)解码Token,检查 exp (过期时间)和 iss (签发者)。 3. 确认Server使用的签名密钥与生成Token时的一致。 |
| 403 Forbidden | 1. Token有效,但权限不足(缺少所需的Authority或Role)。 2. 在STDIO模式下,初始化握手未完成或失败。 |
1. 检查JWT Claims中的 tools 或 scope 字段,是否包含当前请求的工具名对应的权限标识。 2. 检查Spring Security的 @PreAuthorize 条件是否写对(注意 hasAuthority vs hasRole ,后者会自动添加 ROLE_ 前缀)。 3. 对于STDIO,检查Client是否在连接后首先发送了正确的认证请求。 |
| 工具列表为空或不全 | 1. /mcp/tools 端点未根据客户端权限过滤。 2. 客户端Token中的权限列表为空或配置错误。 |
1. 实现 listTools 方法,使其根据当前认证用户的权限动态返回工具列表。 2. 检查生成Token时传入的 allowedTools 参数是否正确。 |
| STDIO连接立即关闭 | 1. Server启动失败(依赖缺失、端口冲突)。 2. 第一个请求不是认证请求,且Server配置了强制先鉴权。 3. 认证请求格式不符合Server预期。 |
1. 查看Server进程的启动日志和标准错误输出。 2. 在Server代码中增加详细的入站请求日志,打印收到的原始JSON。 3. 确保Client严格按照MCP协议和你的Server要求发送初始化消息。 |
5.2 调试技巧与工具
-
深入日志 :为你的
JwtAuthenticationFilter和JwtService设置DEBUG级别日志。你会看到Token的解析过程、提取的客户端ID和权限列表,这对于调试权限问题非常有用。# application.yml logging: level: com.yourcompany.mcp.auth: DEBUG -
手动测试Token :编写一个简单的测试类或使用单元测试,模拟生成和解析Token的过程,确保逻辑正确。
@SpringBootTest class JwtServiceTest { @Autowired JwtService jwtService; @Test void testTokenRoundTrip() { String token = jwtService.generateToken("test-client", List.of("USER"), List.of("tool:a", "tool:b")); assertTrue(jwtService.validateToken(token)); assertEquals("test-client", jwtService.extractClientId(token)); assertTrue(jwtService.extractAllowedTools(token).contains("tool:a")); } } -
使用
curl或httpie测试API :这是测试HTTP端点最快捷的方式。# 获取Token (测试端点) curl -X POST "http://localhost:8080/test/token?clientId=myclient&tools=order:query,report:generate" # 将返回的Token存入变量 TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." # 调用需要认证的端点 curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/mcp/tools curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"toolName":"query_user_orders","arguments":{"userId":"123"}}' http://localhost:8080/mcp/tool-call -
集成测试 :为你的MCP Server编写集成测试,模拟完整的Client-Server交互流程,包括认证失败、成功调用、权限不足等场景。使用Spring的
MockMvc或TestRestTemplate可以很方便地模拟HTTP请求并验证安全拦截是否生效。
鉴权是MCP Server可靠运行的基石。从简单的API Key到复杂的JWT+RBAC,选择哪种方案取决于你的具体场景。核心原则是: 最小权限、默认拒绝、全程加密、有效审计 。希望这篇从设计到实现的详细指南,能帮助你构建出安全、健壮的Java MCP服务。在实际项目中,记得将文中的测试端点移除,并将所有安全配置(尤其是密钥)放到合适的安全管理系统中去。
更多推荐



所有评论(0)