Spring AI 和 OAuth2 中的 MCP 授权实践

工程 | Daniel Garnier-Moiroux | 2025年5月19日 | ...

上个月,我们探讨了如何使用 OAuth2 授权框架来保护 Spring AI MCP 服务器[1]。在那篇文章的结论中,我们提到将探索使用独立的授权服务器进行 MCP 安全,并偏离当时的规范。

自从我们发布这篇文章以来,社区一直在积极修订规范的原始版本。 新草案更简单,并且主要更改确实符合我们对安全的设想。MCP 服务器仍然是 OAuth2 资源服务器,这意味着它们使用通过标头传递的访问令牌来授权传入请求。但是,它们本身不需要是授权服务器:访问令牌现在可以由外部授权服务器颁发。

在这篇博文中,我们将描述如何在 MCP 服务器中实现最新的规范修订,以及如何保护您的 MCP 客户端。

请随意查看之前的博文,以回顾 OAuth2 和 MCP。

保护 MCP 服务器

在此示例中,我们将为示例 MCP 服务器添加 OAuth 2 支持——来自我们 Spring AI 示例存储库的 “天气” MCP 工具

首先,我们在 pom.xml 中导入所需的 Boot 启动器。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

然后,我们通过更新 application.properties 将 MCP 服务器配置为 OAuth2 资源服务器。

# Update the port so it does not clash with our Client application
server.port=8090

# Turn on OAuth2 Resource Server
# This assumes we have an Authorization Server running at https://:9000
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://:9000

感谢 Spring Security 和 Spring Boot 的支持,我们的 MCP 服务器现在已完全受保护:每个请求都需要在 Authorization 标头中包含 JWT 令牌。

如果您想了解有关 Spring Security 中 OAuth2 资源服务器支持的更多信息,请参阅 参考文档

构建 OAuth2 授权服务器

我们的 MCP 服务器现在期望授权服务器在 https://:9000 运行。在企业场景中,授权服务器通常已通过云服务或本地部署的 Keycloak 等服务器提供。对于此演示,您可以使用我们随演示提供的授权服务器,并使用 ./mvnw spring-boot:run 运行它。

或者,您只需几行配置即可构建自己的。首先,我们需要依赖项

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

然后,Spring Boot 将在 application.yml 中获取一些配置。

server:
  port: 9000

  # Cookies are per-domain, multiple apps running on localhost on different ports share cookies.
  # This can create conflicts. We ensure the session cookie is different from the cookie that
  # the client application uses.
  servlet:
    session:
      cookie:
        name: MCP_AUTHSERVER_SESSION

spring:
  security:
    # Provide a default "user"
    user:
      name: user
      password: password

    # Configure the Authorization Server
    oauth2:
      authorizationserver:
        client:
          oidc-client:
            registration:
              client-id: "mcp-client"
              client-secret: "{noop}mcp-secret"
              client-authentication-methods:
                - "client_secret_basic"
              authorization-grant-types:
                - "authorization_code"
                - "client_credentials"
                - "refresh_token"
              redirect-uris:
                # The client application can technically run on any port
                - "http://127.0.0.1:8080/authorize/oauth2/code/authserver"
                - "https://:8080/authorize/oauth2/code/authserver"

如果您想了解有关 Spring 中 OAuth2 授权服务器支持的更多信息,请参阅参考文档

构建 MCP 客户端

MCP 服务器和授权服务器设置简单,配置直接。我们需要多做一些工作来保护 MCP 客户端。要开始构建 MCP 客户端,无论是否授权,请参阅参考文档

⚠️ 目前,Spring AI 仅支持使用 WebClientSYNC MCP 客户端添加安全性。

确保您的应用程序具有正确的依赖项。

<!-- Use Spring WebMVC -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Use WebClient-based MCP-client -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-client-webflux</artifactId>
</dependency>

<!-- Bring in Spring Security -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

然后更新您的 application.properties

# Configure MCP
spring.ai.mcp.client.sse.connections.server1.url=https://:8090
spring.ai.mcp.client.type=SYNC

# Authserver common config
spring.security.oauth2.client.provider.authserver.issuer-uri=https://:9000

# Security: for getting tokens used when calling MCP tools
spring.security.oauth2.client.registration.authserver.client-id=mcp-client
spring.security.oauth2.client.registration.authserver.client-secret=mcp-secret
spring.security.oauth2.client.registration.authserver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.authserver.provider=authserver

# Security: for getting tokens used when listing tools, initializing, etc.
spring.security.oauth2.client.registration.authserver-client-credentials.client-id=mcp-client
spring.security.oauth2.client.registration.authserver-client-credentials.client-secret=mcp-secret
spring.security.oauth2.client.registration.authserver-client-credentials.authorization-grant-type=client_credentials
spring.security.oauth2.client.registration.authserver-client-credentials.provider=authserver

请注意,这里我们注册了两个 OAuth2 客户端。第一个使用 client_credentials 授权,用于初始化我们的客户端应用程序。它允许使用机器到机器通信设置与 MCP 客户端的会话,以及列出可用工具:该流程中不涉及用户。第二个使用 authorization_code 授权,并允许我们的应用程序代表最终用户获取令牌。该客户端用于调用工具。

虽然这里没有解释,但您需要将您选择的 LLM 模型添加到您的应用程序中,以使其完整。

下一步是为 Spring AI 配置 MCP 客户端,通过提供一个 @Bean

@Bean
ChatClient chatClient(ChatClient.Builder chatClientBuilder, List<McpSyncClient> mcpClients) {
    return chatClientBuilder.defaultToolCallbacks(new SyncMcpToolCallbackProvider(mcpClients)).build();
}

要将 OAuth2 添加到我们的 MCP 客户端,我们配置一个 Spring Security SecurityFilterChain 来启用 OAuth2,以及一个 MCP 客户端使用的自定义 WebClient.Builder

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
        .oauth2Client(Customizer.withDefaults())
        .csrf(CsrfConfigurer::disable)
        .build();
}

/**
 * Overload Boot's default {@link WebClient.Builder}, so that we can inject an
 * oauth2-enabled {@link ExchangeFilterFunction} that adds OAuth2 tokens to requests
 * sent to the MCP server.
 */
@Bean
WebClient.Builder webClientBuilder(McpSyncClientExchangeFilterFunction filterFunction) {
    return WebClient.builder().apply(filterFunction.configuration());
}

要将令牌添加到 MCP 客户端请求中,我们需要一个自定义的 ExchangeFilterFunction,它根据上下文(用户交互或应用程序初始化)决定使用哪个 OAuth2 令牌。对于 Spring Security 初学者来说,这可能有点令人困惑,但请随意按原样使用它。

/**
 * A wrapper around Spring Security's
 * {@link ServletOAuth2AuthorizedClientExchangeFilterFunction}, which adds OAuth2
 * {@code access_token}s to requests sent to the MCP server.
 * <p>
 * The end goal is to use access_token that represent the end-user's permissions. Those
 * tokens are obtained using the {@code authorization_code} OAuth2 flow, but it requires a
 * user to be present and using their browser.
 * <p>
 * By default, the MCP tools are initialized on app startup, so some requests to the MCP
 * server happen, to establish the session (/sse), and to send the {@code initialize} and
 * e.g. {@code tools/list} requests. For this to work, we need an access_token, but we
 * cannot get one using the authorization_code flow (no user is present). Instead, we rely
 * on the OAuth2 {@code client_credentials} flow for machine-to-machine communication.
 */
@Component
public class McpSyncClientExchangeFilterFunction implements ExchangeFilterFunction {

  private final ClientCredentialsOAuth2AuthorizedClientProvider clientCredentialTokenProvider = new ClientCredentialsOAuth2AuthorizedClientProvider();

  private final ServletOAuth2AuthorizedClientExchangeFilterFunction delegate;

  private final ClientRegistrationRepository clientRegistrationRepository;

  // Must match registration id in property
  // spring.security.oauth2.client.registration.<REGISTRATION-ID>.authorization-grant-type=authorization_code
  private static final String AUTHORIZATION_CODE_CLIENT_REGISTRATION_ID = "authserver";

  // Must match registration id in property
  // spring.security.oauth2.client.registration.<REGISTRATION-ID>.authorization-grant-type=client_credentials
  private static final String CLIENT_CREDENTIALS_CLIENT_REGISTRATION_ID = "authserver-client-credentials";

  public McpSyncClientExchangeFilterFunction(OAuth2AuthorizedClientManager clientManager,
      ClientRegistrationRepository clientRegistrationRepository) {
    this.delegate = new ServletOAuth2AuthorizedClientExchangeFilterFunction(clientManager);
    this.delegate.setDefaultClientRegistrationId(AUTHORIZATION_CODE_CLIENT_REGISTRATION_ID);
    this.clientRegistrationRepository = clientRegistrationRepository;
  }

  /**
   * Add an {@code access_token} to the request sent to the MCP server.
   * <p>
   * If we are in the context of a ServletRequest, this means a user is currently
   * involved, and we should add a token on behalf of the user, using the
   * {@code authorization_code} grant. This typically happens when doing an MCP
   * {@code tools/call}.
   * <p>
   * If we are NOT in the context of a ServletRequest, this means we are in the startup
   * phases of the application, where the MCP client is initialized. We use the
   * {@code client_credentials} grant in that case, and add a token on behalf of the
   * application itself.
   */
  @Override
  public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
    if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes) {
      return this.delegate.filter(request, next);
    }
    else {
      var accessToken = getClientCredentialsAccessToken();
      var requestWithToken = ClientRequest.from(request)
        .headers(headers -> headers.setBearerAuth(accessToken))
        .build();
      return next.exchange(requestWithToken);
    }
  }

  private String getClientCredentialsAccessToken() {
    var clientRegistration = this.clientRegistrationRepository
      .findByRegistrationId(CLIENT_CREDENTIALS_CLIENT_REGISTRATION_ID);

    var authRequest = OAuth2AuthorizationContext.withClientRegistration(clientRegistration)
      .principal(new AnonymousAuthenticationToken("client-credentials-client", "client-credentials-client",
          AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")))
      .build();
    return this.clientCredentialTokenProvider.authorize(authRequest).getAccessToken().getTokenValue();
  }

  /**
   * Configure a {@link WebClient} to use this exchange filter function.
   */
  public Consumer<WebClient.Builder> configuration() {
    return builder -> builder.defaultRequest(this.delegate.defaultRequest()).filter(this);
  }

}

有了这些,我们就拥有了所需的一切!向我们的 LLM 提出与天气相关的问题将触发调用我们的天气 MCP 工具。

var chatResponse = chatClient.prompt("What is the weather in %s right now?".formatted(query))
        .call()
        .content();

如果您想亲自尝试,我们提供了一个完整打包的演示应用程序,可在 GitHub 上获取。

下一步是什么?

这是实现完整端到端授权的第一步。通过利用 Spring 强大的可扩展性,我们可以将 OAuth2 添加到我们的 MCP 客户端和服务器中,但这需要编写一些代码。

Spring 团队正在努力构建更简单的集成,并提供愉快的配置驱动的 Boot 用户体验。

我们还在为 MCP 服务器开发细粒度权限。在更高级的用例中,并非 MCP 服务器中的所有工具/资源/提示都需要相同的权限:“thing-reader”工具将对所有用户可用,但“thing-writer”仅对管理员可用。


[1]: 模型上下文协议,简称 MCP,是一种允许 AI 模型以结构化方式与外部工具和资源交互并访问它们的协议。Spring AI 为 MCP 服务器和 MCP 客户端提供开箱即用的支持

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获得支持

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件,只需一份简单的订阅。

了解更多

即将举行的活动

查看 Spring 社区所有即将举行的活动。

查看所有