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)来发现和调用这些工具。

这里的安全边界非常清晰:

  1. 客户端身份(Who) :谁在调用?是一个内部运维系统,一个CI/CD流水线,还是一个经过审核的第三方集成?
  2. 操作权限(What) :这个身份允许调用哪些工具?对于“删除数据库”这种工具,显然不能对所有客户端开放。
  3. 请求完整性(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);
    }
}

现在,我们可以启动应用,进行测试:

  1. 调用 POST /test/token?clientId=internal-service&tools=order:query,report:generate 获取一个令牌。
  2. 使用这个令牌,调用 GET /mcp/tools (需要认证,所以Header里要带 Authorization: Bearer <token> )。
  3. 调用 POST /mcp/tool-call 尝试执行 query_user_orders ,应该成功。
  4. 尝试调用 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 生产环境安全加固要点

无论采用哪种传输方式,以下加固措施都至关重要:

  1. 密钥管理

    • 绝对不要 将密钥硬编码在代码或配置文件中提交到代码仓库。
    • 使用专业的密钥管理服务(如HashiCorp Vault、AWS Secrets Manager、Azure Key Vault)或在部署时通过环境变量注入。
    • 为不同的环境(开发、测试、生产)使用不同的密钥。
  2. 令牌安全

    • JWT的签名密钥(Secret Key)必须足够长且随机(至少32字节)。
    • 设置合理的令牌过期时间(如24小时),对于高权限操作可以考虑更短。
    • 考虑实现令牌吊销列表(黑名单)机制,用于在令牌泄露或客户端权限变更时立即失效特定令牌。这需要引入一点状态存储(如Redis),但提升了安全性。
  3. 输入验证与输出过滤

    • MCP工具的参数来自不可控的客户端,必须进行严格的输入验证(类型、范围、长度、SQL注入防护等)。
    • 对工具返回的数据进行过滤,避免敏感信息泄露。例如, query_user_orders 工具不应返回用户的密码哈希或完整银行卡号。
  4. 限流与防刷

    • 为每个客户端或每个工具接口设置速率限制(Rate Limiting),防止滥用或DDoS攻击。可以使用Spring Boot集成Resilience4j或Sentinel,或者通过API网关实现。
  5. 全面的日志与审计

    • 记录所有认证成功和失败的事件。
    • 记录所有工具调用的详细信息:客户端ID、工具名、参数(脱敏后)、时间戳、结果状态。这对于安全事件追溯和运营分析至关重要。
    • 确保日志不会被未授权访问。
  6. 网络层隔离

    • 将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 调试技巧与工具

  1. 深入日志 :为你的 JwtAuthenticationFilter JwtService 设置 DEBUG 级别日志。你会看到Token的解析过程、提取的客户端ID和权限列表,这对于调试权限问题非常有用。

    # application.yml
    logging:
      level:
        com.yourcompany.mcp.auth: DEBUG
    
  2. 手动测试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"));
        }
    }
    
  3. 使用 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
    
  4. 集成测试 :为你的MCP Server编写集成测试,模拟完整的Client-Server交互流程,包括认证失败、成功调用、权限不足等场景。使用Spring的 MockMvc TestRestTemplate 可以很方便地模拟HTTP请求并验证安全拦截是否生效。

鉴权是MCP Server可靠运行的基石。从简单的API Key到复杂的JWT+RBAC,选择哪种方案取决于你的具体场景。核心原则是: 最小权限、默认拒绝、全程加密、有效审计 。希望这篇从设计到实现的详细指南,能帮助你构建出安全、健壮的Java MCP服务。在实际项目中,记得将文中的测试端点移除,并将所有安全配置(尤其是密钥)放到合适的安全管理系统中去。

更多推荐