SpringAI中MCP的鉴权问题
前言
目前SpringAI框架虽然迭代到了2.0.1版本,但是对于很多东西都还是需要自己拓展,对于目前而言MCP的鉴权问题,虽然Spring社区有mcp-security模块 但是这个模块目前并不是Spring官方的推荐,所以我个人目前还是倾向于个人拓展。因为和前端交互基本都是响应式的输出,所以一下的操作都是基于返回 Flux<String>格式的方案
全手动
客户端
调用链路
业务线程(MVC,有 SecurityContext)
│
│ 1. contextWrite(ctx -> ctx.put("authentication", auth))
▼
Reactor Context(响应式上下文存放 authentication)
│
│ 2. Spring AI 自动捕获,存入 ToolCallReactiveContextHolder(ThreadLocal)
▼
ToolCallReactiveContextHolder
│
│ 3. McpTransportAuthCustomizer 中直接 getContext() 读取
▼
HTTP 请求头 Authorization: Bearer xxx
第一步 就是需要在控制层把接收到鉴权参数,传入的响应式的上下文中,contextWrite是Spring WebFlux中的一个操作符,可以把数据写入到响应式上下文中 contextWrite只能写在调用链的末尾,并且contextWrite只对下游可见
Flux<String> res = dsChatClient.prompt()
.user(question)
.advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID, key))//传递会话id
.stream()
.content()
.contextWrite(ctx -> ctx.put("authentication","Bearer token-123"));
然后需要在McpTransportAuthCustomizer把参数获取并注入到请求头中,手动实现时,ToolCallReactiveContextHolder 里已经 包含了 Reactor Context 的全部 数据,McpTransportAuthCustomizer 可以直接从中读取,不需要额外配置 transportContextProvider 来搬运到 McpTransportContext。McpTransportContext 是 mcp-security 模块方案中的中间容器,手动实现可以跳过
@Component
public class McpTransportAuthCustomizer
implements McpClientCustomizer<HttpClientStreamableHttpTransport.Builder> {
@Override
public void customize(String name, HttpClientStreamableHttpTransport.Builder builder) {
builder.httpRequestCustomizer((requestBuilder, method, endpoint, body, context) -> {
var ctx = ToolCallReactiveContextHolder.getContext();
if (ctx.hasKey("authentication")) {
requestBuilder.header("Authorization", ctx.get("authentication").toString());
}
});
}
}
这样的话客户端就完整地把鉴权地参数,或者一些其他的参数传递给了Mcp服务器了,然后现在需要的就是在服务端获取
服务端
服务端就很简单了,只需要在http的请求头中获取参数,然后存放到上下文中,其他的方法就可以直接获取了,重点就是从TransportContextExtractor中获取传递过来的参数
@Bean
public WebMvcStreamableServerTransportProvider streamableTransportProvider() {
return WebMvcStreamableServerTransportProvider.builder()
.mcpEndpoint("/mcp")
.contextExtractor(request -> {
// 从 HTTP 请求头中提取 Authorization
String authHeader = request.headers()
.firstHeader("Authorization");
Map<String, Object> context = new HashMap<>();
context.put("authorization", authHeader);
return McpTransportContext.create(context);
})
.build();
}
最后就是在逻辑中校验了,我这里没有登录,所以随便传入的一个字符串作为鉴权的参数,McpTransportContext对于stateless和steamable都支持
@McpTool(description = "根据城市名称查询该城市的实时天气信息,包括天气状况、温度、风向、风力、湿度等")
public String getWeatherByCity(
@McpToolParam(description = "城市名称,支持中文(如:北京、上海)或英文(如:Tokyo)") String city,
McpTransportContext context) {
String url = "/api/v1/misc/weather?city=" + city;
// 1. 从上下文中取出 Authorization 头
String authHeader = (String) context.get("authorization");
if (!"Bearer token-123".equals(authHeader)) {
log.warn("鉴权失败:令牌无效");
return "鉴权失败:令牌无效";
}
// 3. 鉴权通过,返回数据
try {
String responseBody = restClient.get()
.uri(url)
.retrieve()
.body(String.class);
return responseBody;
} catch (Exception e) {
return "查询天气失败:" + e.getMessage();
}
}
全自动
全自动的话就是需要使用到mcp-security模块,这个模块就是把Spring-Security的思维使用到了MCP中,用Spring-Security的方式来鉴权,核心就是@PreAuthorize注解,然后 我这里是参考官方的建议,采用了客户端,授权服务器,mcp服务器分层的一个方式,还有一种就是mcp服务器既当作授权服务器也当成资源服务器的方式
客户端
啊啊啊啊啊,无解了,试了一下午,自动配置始终有问题,要么就是获取不到token,暂时先不写了,后面发现问题了把全自动注入再补上
tips看了mcp-security作者的例子后终于走通了
作者在例子里面多引入了一个依赖包
<dependency>
<groupId>org.springaicommunity</groupId>
<artifactId>mcp-client-security-spring-boot</artifactId>
<version>0.1.15-SNAPSHOT</version>
</dependency>
除了这个官网文档里面没有看到的依赖以外,剩下的就是文档里面写了必须引入的依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- 我这里是接入的deepseek的模型,所以引入的是deepseek的包 -->
<!-- <dependency>-->
<!-- <groupId>org.springframework.ai</groupId>-->
<!-- <artifactId>spring-ai-starter-model-deepseek</artifactId>-->
<!-- </dependency>-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
<dependency>
<groupId>org.springaicommunity</groupId>
<artifactId>mcp-client-security</artifactId>
<version>${mcp.security.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
yml的配置,这里的配置,主要也是参考了官网文档和模块作者的例子,这里不得不吐槽一下官方的文档,给出的配置参数如果直接用根本跑不起来,需要参照作者的例子,然后 对照补充
security:
oauth2:
client:
registration:
authserver:
client-id: leezihongClient
client-secret: leezihong-secret
authorization-grant-type: client_credentials
provider: authserver
provider:
authserver:
issuer-uri: http://localhost:8090
resourceserver:
jwt:
issuer-uri: http://localhost:8090
mcp:
client:
type: sync
initialized: false
toolcallback:
enabled: true
streamable-http:
connections:
localMcpServer:
url: http://localhost:8089
endpoint: /mcp
authentication:
dynamic-client-registration:
enabled: true
allow-loopback-addresses: true
因为我是从前端调用开始走的,框架获取到了token后传入到上下文,这里主要的就是.contextWrite(AuthenticationMcpTransportContextProvider.writeToReactorContext())这一段,其实 和全手动差不多
都是需要获取token,然后传递,加了AuthenticationMcpTransportContextProvider.writeToReactorContext()就不需要手动传递了,框架自己获取并且传递
Flux<String> res = dsChatClient.prompt()
.user(question)
.advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID, key))//传递会话id
.stream()
.content()
.contextWrite(AuthenticationMcpTransportContextProvider.writeToReactorContext());
这一步就是最重要的一步,参数的传递,然后就是SpringSecurity的相关配置了,需要加一个安全过滤器链路,
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/ai/**")
.csrf(CsrfConfigurer::disable) // API 不需要 CSRF
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults())) // 必须能访问 8090/.well-known/openid-configuration
// .oauth2Client(Customizer.withDefaults()) // 只有走 client_credentials 换服务级 token 时才要
.build();
}
授权服务器
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
<groupId>org.springaicommunity</groupId>
<artifactId>mcp-authorization-server-spring-boot</artifactId>
<version>${mcp.security.version}</version>
</dependency>
yml配置,这里没啥说了,用过SpringSecurity的应该都知道
security:
oauth2:
authorizationserver:
client:
default-client:
token:
access-token-time-to-live: 2h
registration:
client-id: "leezihongClient"
client-secret: "{noop}leezihong-secret"
client-authentication-methods:
- "client_secret_basic"
authorization-grant-types:
- "authorization_code"
- "client_credentials"
redirect-uris:
- "http://127.0.0.1:8090/authorize/oauth2/code/authserver"
- "https://:8090/authorize/oauth2/code/authserver"
同样的,这里也需要加一个安全过滤器链来开启授权功能
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
// .requestMatchers("/oauth2/token").permitAll()
// .requestMatchers("/oauth2/jwks").permitAll()
.anyRequest().authenticated())
.with(McpAuthorizationServerConfigurer.mcpAuthorizationServer(), withDefaults())
.formLogin(withDefaults())
.build();
}
服务端
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springaicommunity</groupId>
<artifactId>mcp-server-security</artifactId>
<version>${mcp.security.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springaicommunity</groupId>
<artifactId>mcp-authorization-server</artifactId>
<version>${mcp.security.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
服务端的配置就相对少了很多,主要是暴露出mcp的端口和配置授权服务器地址
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8090
ai:
mcp:
server:
protocol: STREAMABLE
type: sync
annotation-scanner:
enabled: true
streamable-http:
mcp-endpoint: /mcp
这里也是一样,需要配置一个安全链路
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuerUrl) throws Exception {
return http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.with(mcpServerOAuth2(), (mcpAuthorization) -> {
mcpAuthorization.authorizationServer(issuerUrl).resourcePath("/mcp");
})
// MCP inspector
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(CsrfConfigurer::disable)
.build();
}
如果需要放开请求头的限制就需要加上这个配置
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("*"));
configuration.setAllowedMethods(List.of("*"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setExposedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
这是我加的一个获取天气的工具,@PreAuthorize("isAuthenticated()")加上之后就会被框架处理了
@Component
@Slf4j
public class WeatherTool {
private final RestClient restClient;
public WeatherTool() {
this.restClient = RestClient.builder()
.baseUrl("https://uapis.cn")
.build();
}
/**
* 根据城市名称查询实时天气
* API 文档:https://uapis.cn/docs/api-reference/get-misc-weather
* 返回字段包含:province, city, weather, temperature, wind_direction, wind_power, humidity
*/
@PreAuthorize("isAuthenticated()")
@McpTool(description = "根据城市名称查询该城市的实时天气信息,包括天气状况、温度、风向、风力、湿度等")
public String getWeatherByCity(
@McpToolParam(description = "城市名称,支持中文(如:北京、上海)或英文(如:Tokyo)") String city,
McpTransportContext context) {
String url = "/api/v1/misc/weather?city=" + city;
// 1. 从上下文中取出 Authorization 头
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
log.info("auth={}, authenticated={}, anonymous={}",
auth,
auth != null && auth.isAuthenticated(),
auth instanceof AnonymousAuthenticationToken);
// if (!"Bearer token-123".equals(authHeader)) {
// log.warn("鉴权失败:令牌无效");
// return "鉴权失败:令牌无效";
// }
// 3. 鉴权通过,返回数据
try {
String responseBody = restClient.get()
.uri(url)
.retrieve()
.body(String.class);
return responseBody;
} catch (Exception e) {
return "查询天气失败:" + e.getMessage();
}
}
}
测试
不带token

获取token

带token

客户端日志

服务端日志

补充,工具过滤
鉴权只是在请求阶段处理工具的调用,如果想一开始客户端就不能获取到权限不足不能调用的工具,可以在客户端设置工具过滤,官网已经有详细的例子,主要就是使用字符来匹配
@Configuration
public class CustomMcpToolFilter implements McpToolFilter {
@Override
public boolean test(McpConnectionInfo mcpConnectionInfo, McpSchema.Tool tool) {
// if (tool.name().contains("Time")) {
// return false;
// }
return true;
}
}
参考
https://github.com/spring-ai-community/mcp-security/tree/main/samples
