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 社群所有即將舉行的活動。

檢視所有