 - 40 MCP:基于Spring AI和OAuth2的MCP授權(quán)全流程實戰(zhàn)(TaoToken統(tǒng)一Key接入版))
1. 為什么 MCP 服務(wù)端必須補上 OAuth2 這一環(huán)MCP 剛火起來那陣子我見過太多項目把工具接口直接裸奔在 HTTP 上本地跑跑沒問題一旦要接企業(yè)內(nèi)網(wǎng)的數(shù)據(jù)源安全同學(xué)第一句話就是「這個接口誰能調(diào)」。Model Context Protocol 本身解決的是「大模型怎么發(fā)現(xiàn)并調(diào)用工具」的問題它并沒有規(guī)定鑒權(quán)怎么做。換句話說MCP 把工具描述、參數(shù) schema、調(diào)用協(xié)議都標(biāo)準(zhǔn)化了但「誰有權(quán)限調(diào)用哪個工具」這件事協(xié)議層是留白的。這就是 OAuth2 要補位的地方。你可以把 MCP 服務(wù)端理解成一個資源服務(wù)器它暴露的每一個 tool比如查訂單、算金額、讀工單都是受保護資源??蛻舳四弥跈?quán)服務(wù)器簽發(fā)的 JWT 來訪問服務(wù)端只負責(zé)驗簽和校驗 scope業(yè)務(wù)代碼里完全不用寫 if-else 判斷用戶身份。這種「認證與業(yè)務(wù)解耦」的設(shè)計在 Spring AI 體系里落地起來其實相當(dāng)順因為 Spring Security 的 OAuth2 資源服務(wù)器支持是現(xiàn)成的。這篇要交付的東西很具體一個能跑通的授權(quán)服務(wù)器、一個帶計算器工具的 MCP 服務(wù)端、一個能區(qū)分「用戶授權(quán)碼令牌」和「系統(tǒng)客戶端憑證令牌」的 MCP 客戶端。同時我會把大模型調(diào)用的 API 通道統(tǒng)一到 TaoToken 上這樣你本地不用維護一堆廠商的 Key一個統(tǒng)一 Key 就能把 Claude、GPT 這些模型接進來專注在 OAuth2 鏈路的調(diào)試上。適合誰看正在用 Spring AI 做 MCP 服務(wù)端、并且被「怎么加鑒權(quán)」卡住的同學(xué)或者你已經(jīng)跑通了 MCP 的 stdio 版本現(xiàn)在想升級到帶 SSE 的遠程安全版本。先說清楚整體架構(gòu)不然后面配置容易迷路。系統(tǒng)里有三個獨立進程授權(quán)服務(wù)器跑在 9000 端口負責(zé)發(fā)令牌MCP 服務(wù)端跑在 8090扮演資源服務(wù)器暴露計算器工具MCP 客戶端跑在 8080它既是 Web 應(yīng)用又是 MCP 客戶端負責(zé)拿令牌、調(diào)工具、再驅(qū)動大模型??蛻舳诉@里有個容易忽略的點——它啟動時要用客戶端憑證模式拿一個「系統(tǒng)級」令牌去初始化 MCP 連接而用戶通過瀏覽器訪問時又要用授權(quán)碼模式拿「用戶級」令牌。兩套令牌動態(tài)切換是整條鏈路里最容易踩坑的地方后面我會用自定義的 ExchangeFilterFunction 來解決。2. TaoToken 統(tǒng)一 Key 的前置準(zhǔn)備與模型通道配置在動手寫 OAuth2 之前先把大模型這條通道理順。MCP 客戶端最終是要驅(qū)動大模型去決定「該調(diào)哪個工具」的所以你得有一個能用的模型 API。傳統(tǒng)做法是去各家廠商注冊、拿 Key、配不同的 SDK光是環(huán)境變量就一堆。我現(xiàn)在的習(xí)慣是統(tǒng)一走 TaoToken 的 API 通道一個 Key 覆蓋多個模型省得在 OAuth2 調(diào)試時分心去處理模型鑒權(quán)。你需要先拿到兩樣?xùn)|西一個是 API Key一個是確認好要用的模型 ID。Key 在控制臺的 API Keys 頁面創(chuàng)建地址是 https://taotoken.net/api-keys 創(chuàng)建后復(fù)制出來形如sk-開頭的一串。模型 ID 則可以在模型對話頁面里先試一下確認你要用的模型能正常返回頁面在 https://taotoken.net/chat 。這一步別跳過因為后面 Spring AI 配置里要填的 model 名稱必須和平臺一致填錯了會報模型不存在的錯。拿到 Key 之后建議用環(huán)境變量的方式注入不要硬編碼進配置文件。Linux/macOS 下這樣設(shè)置export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/apiWindows PowerShell 用$env:TAOTOKEN_API_KEYsk-你的key $env:TAOTOKEN_BASE_URLhttps://taotoken.net/api這里有個關(guān)鍵點TaoToken 的 API 地址是https://taotoken.net/api它兼容 OpenAI 的接口規(guī)范所以 Spring AI 里可以直接用 OpenAI 的 starter只要把 base-url 指過來就行。如果你用的是 Anthropic 協(xié)議那套Spring AI 也有對應(yīng)的 starter但為了演示統(tǒng)一我這里用 OpenAI 兼容模式配置最省事。先驗證一下 Key 是否可用用 curl 打一個最簡請求curl https://taotoken.net/api/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -d { model: claude-3-5-sonnet-20241022, messages: [{role: user, content: reply with ok}], max_tokens: 16 }如果返回里有choices數(shù)組且 content 是 ok 之類的內(nèi)容說明通道沒問題。這一步很重要因為后面 MCP 客戶端調(diào)模型失敗時你要能快速區(qū)分是「模型通道掛了」還是「OAuth2 令牌沒拿到」。我踩過的坑就是有一次令牌鏈路全對但模型 base-url 寫成了官方地址結(jié)果一直超時排查了半天才發(fā)現(xiàn)是通道配錯。關(guān)于模型選擇MCP 場景下工具調(diào)用能力比較關(guān)鍵建議選支持 function calling 的模型。你可以在模型對話頁面里對比幾個模型的工具調(diào)用表現(xiàn)選一個穩(wěn)定的寫進配置。Coding Plan 那邊也有針對長期編碼和 Agent 場景的套餐如果你打算把 MCP 服務(wù)端長期跑著做開發(fā)助手可以看看 https://taotoken.net/coding-plan 按需選就行這里不展開。前置準(zhǔn)備做完你應(yīng)該有一個可用的 API Key、一個確認能返回的模型 ID、以及驗證通過的 curl 結(jié)果。接下來進入 OAuth2 授權(quán)服務(wù)器的搭建。3. 可復(fù)制的 OAuth2 授權(quán)服務(wù)器與 MCP 服務(wù)端配置這一節(jié)是全文的核心我會把授權(quán)服務(wù)器、MCP 服務(wù)端、MCP 客戶端三份配置都給全你直接復(fù)制改端口就能用。先說授權(quán)服務(wù)器它是最獨立的模塊跑起來之后其他兩個都依賴它發(fā)令牌。授權(quán)服務(wù)器的依賴就兩個spring-boot-starter-oauth2-authorization-server和spring-boot-starter-web。配置文件用 YAML注意 client 注冊那塊我注冊了一個mcp-client同時開了授權(quán)碼、客戶端憑證、刷新令牌三種模式scope 里除了 openid/profile還加了calc.read和calc.write這兩個是給計算器工具用的自定義 scopeserver: port: 9000 spring: security: user: name: user password: password 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: - http://localhost:8080/authorize/oauth2/code/authserver scopes: - openid - profile - calc.read - calc.write注意{noop}前綴這是告訴 Spring Security 這個 secret 是明文生產(chǎn)環(huán)境要換成 BCrypt 編碼。redirect-uri 必須和客戶端配置里的完全一致差一個字符都會報 redirect_uri_mismatch。然后是 MCP 服務(wù)端它要同時引入 MCP 服務(wù)端 starter 和 OAuth2 資源服務(wù)器 starterdependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-server-webmvc/artifactId version1.0.0-M7/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-resource-server/artifactId /dependency服務(wù)端配置里最關(guān)鍵的一行是issuer-uri它指向授權(quán)服務(wù)器Spring Boot 會自動去拉 JWK Set 來驗簽server.port8090 spring.security.oauth2.resourceserver.jwt.issuer-urihttp://localhost:9000 spring.ai.mcp.server.enabledtrue spring.ai.mcp.server.namemcp-calculator-server spring.ai.mcp.server.version1.0.0 spring.ai.mcp.server.stdiofalsestdiofalse表示走 SSE 遠程模式這樣才能配合 OAuth2 做 HTTP 鑒權(quán)。工具實現(xiàn)用Tool注解兩個算術(shù)方法Tool(description Add two numbers) public CalculationResult add( ToolParam(description First number) double a, ToolParam(description Second number) double b) { double result a b; return new CalculationResult(addition, a, b, result); } Tool(description Multiply two numbers) public CalculationResult multiply( ToolParam(description First number) double a, ToolParam(description Second number) double b) { double result a * b; return new CalculationResult(multiplication, a, b, result); }安全配置會自動把這些工具方法保護起來沒有合法 JWT 的請求直接 401。這里不需要你手寫過濾器資源服務(wù)器 starter 會接管。最后是 MCP 客戶端它最復(fù)雜因為要配兩套 client registration。一套給用戶授權(quán)碼模式一套給系統(tǒng)客戶端憑證模式server.port8080 spring.ai.mcp.client.sse.connections.server1.urlhttp://localhost:8090 spring.ai.mcp.client.typeSYNC spring.security.oauth2.client.provider.authserver.issuer-urihttp://localhost:9000 # 用戶授權(quán)碼模式 spring.security.oauth2.client.registration.authserver.client-idmcp-client spring.security.oauth2.client.registration.authserver.client-secretmcp-secret spring.security.oauth2.client.registration.authserver.authorization-grant-typeauthorization_code spring.security.oauth2.client.registration.authserver.providerauthserver spring.security.oauth2.client.registration.authserver.scopeopenid,profile,calc.read,calc.write spring.security.oauth2.client.registration.authserver.redirect-uri{baseUrl}/authorize/oauth2/code/{registrationId} # 系統(tǒng)客戶端憑證模式 spring.security.oauth2.client.registration.authserver-client-credentials.client-idmcp-client spring.security.oauth2.client.registration.authserver-client-credentials.client-secretmcp-secret spring.security.oauth2.client.registration.authserver-client-credentials.authorization-grant-typeclient_credentials spring.security.oauth2.client.registration.authserver-client-credentials.providerauthserver spring.security.oauth2.client.registration.authserver-client-credentials.scopecalc.read,calc.write # 模型通道走 TaoToken spring.ai.openai.api-key${TAOTOKEN_API_KEY} spring.ai.openai.base-url${TAOTOKEN_BASE_URL} spring.ai.openai.chat.options.modelclaude-3-5-sonnet-20241022注意 scope 這里我寫的是calc.read,calc.write和授權(quán)服務(wù)器里注冊的保持一致。模型那三行就是 TaoToken 的接入點base-url 指向https://taotoken.net/apiKey 從環(huán)境變量讀。這樣模型通道和 OAuth2 通道就都配齊了。4. 動態(tài)令牌切換與 curl 驗證授權(quán)全流程配置寫完不代表能跑客戶端這里有個隱蔽的坑MCP 客戶端在應(yīng)用啟動時就要建立 SSE 連接這時候還沒有用戶登錄拿不到授權(quán)碼令牌所以必須用客戶端憑證模式先拿一個系統(tǒng)令牌。而用戶通過瀏覽器訪問/calculate時又要用當(dāng)前登錄用戶的授權(quán)碼令牌。兩套令牌怎么在同一個 WebClient 里動態(tài)切換答案是自定義 ExchangeFilterFunction。先看安全配置放行所有請求但啟用 oauth2ClientBean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http.authorizeHttpRequests(auth - auth.anyRequest().permitAll()) .oauth2Client(Customizer.withDefaults()) .csrf(CsrfConfigurer::disable) .build(); }核心的過濾器實現(xiàn)如下邏輯是如果當(dāng)前線程有 ServletRequestAttributes說明是用戶請求就用 delegate 走授權(quán)碼令牌否則啟動初始化階段就用客戶端憑證模式現(xiàn)拿一個令牌塞進 headerComponent public class McpSyncClientExchangeFilterFunction implements ExchangeFilterFunction { private final ClientCredentialsOAuth2AuthorizedClientProvider clientCredentialTokenProvider new ClientCredentialsOAuth2AuthorizedClientProvider(); private final ServletOAuth2AuthorizedClientExchangeFilterFunction delegate; private final ClientRegistrationRepository clientRegistrationRepository; private static final String AUTHORIZATION_CODE_CLIENT_REGISTRATION_ID authserver; 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; } Override public MonoClientResponse 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(); } public ConsumerWebClient.Builder configuration() { return builder - builder.defaultRequest(this.delegate.defaultRequest()).filter(this); } }這段代碼是整個鏈路里最值得反復(fù)讀的部分。RequestContextHolder.getRequestAttributes()這個判斷是分水嶺啟動階段沒有請求上下文走 else 分支用戶請求進來時有上下文走 delegate。如果你發(fā)現(xiàn)啟動時報 401八成是這個判斷沒生效或者客戶端憑證的 registration id 寫錯了。配置好之后啟動順序不能亂先起授權(quán)服務(wù)器9000再起 MCP 服務(wù)端8090最后起 MCP 客戶端8080。服務(wù)端啟動時會去 9000 拉 JWK如果授權(quán)服務(wù)器沒起來服務(wù)端會啟動失敗。驗證分兩步。第一步直接用 curl 走客戶端憑證模式拿令牌確認授權(quán)服務(wù)器正常curl -X POST http://localhost:9000/oauth2/token \ -u mcp-client:mcp-secret \ -d grant_typeclient_credentials \ -d scopecalc.read calc.write返回里應(yīng)該有access_token字段。第二步拿這個令牌去調(diào) MCP 服務(wù)端的工具接口驗證資源服務(wù)器鑒權(quán)生效TOKEN$(curl -s -X POST http://localhost:9000/oauth2/token \ -u mcp-client:mcp-secret \ -d grant_typeclient_credentials \ -d scopecalc.read calc.write | jq -r .access_token) curl -H Authorization: Bearer $TOKEN http://localhost:8090/sse如果返回 SSE 事件流而不是 401說明令牌校驗通過。再故意不帶令牌請求一次應(yīng)該返回 401這樣一正一反就驗證了鑒權(quán)確實生效。最后走完整鏈路瀏覽器訪問http://localhost:8080/calculate?expression1525客戶端會引導(dǎo)你登錄授權(quán)服務(wù)器登錄后拿到授權(quán)碼令牌再帶著令牌去調(diào) MCP 服務(wù)端的計算器工具大模型根據(jù)工具返回結(jié)果組織答案。整個過程你能在日志里看到令牌的獲取和攜帶。5. 常見報錯排查401、local proxy failed 與 OAuth2 授權(quán)碼異常鏈路跑通之前報錯是常態(tài)。我把幾個高頻錯誤和對應(yīng)排查路徑列出來你對著日志定位會快很多。第一個是401 Unauthorized出現(xiàn)在 MCP 服務(wù)端。原因通常是三種令牌沒帶、令牌過期、或者 issuer 不匹配。先看請求 header 里有沒有Authorization: Bearer xxx沒有就是客戶端過濾器沒生效有的話把令牌貼到 jwt.io 解一下看iss字段是不是http://localhost:9000和資源服務(wù)器配的 issuer-uri 必須完全一致差個斜杠都會驗簽失敗。還有一種情況是時鐘偏移JWT 的exp和iat對時間敏感容器時間不對也會 401。第二個是local proxy failed或者連接被拒。這個多半是啟動順序問題MCP 服務(wù)端啟動時去拉授權(quán)服務(wù)器的 JWK如果 9000 還沒起來就會報連接失敗。解決辦法就是嚴格按 9000 → 8090 → 8080 的順序啟動或者給服務(wù)端加個重試。另外檢查一下spring.security.oauth2.resourceserver.jwt.issuer-uri有沒有寫成https本地是http寫錯協(xié)議也會連不上。第三個是reading choices相關(guān)的報錯這個出在模型通道。MCP 客戶端調(diào) TaoToken 時如果返回體里沒有choices字段通常是 base-url 或 model 名寫錯了。檢查spring.ai.openai.base-url是不是https://taotoken.net/api注意結(jié)尾不要多加/v1Spring AI 會自己拼。model 名要和平臺一致寫錯了會返回錯誤對象而不是 choices 數(shù)組。這時候用第 2 節(jié)的 curl 命令再驗一次能快速區(qū)分是通道問題還是代碼問題。第四個是 OAuth2 授權(quán)碼模式的redirect_uri_mismatch或invalid_client。redirect-uri 必須和授權(quán)服務(wù)器注冊的完全一致包括端口和路徑。invalid_client一般是 client-secret 錯了注意授權(quán)服務(wù)器里配的是{noop}mcp-secret客戶端里填的應(yīng)該是mcp-secret不要帶{noop}前綴。還有 scope 不匹配也會報錯客戶端請求的 scope 必須是授權(quán)服務(wù)器注冊過的子集。第五個是啟動時 MCP 客戶端報OAuth2AuthorizationContext相關(guān)異常。這通常是客戶端憑證模式的 registration id 和代碼里的常量對不上。檢查CLIENT_CREDENTIALS_CLIENT_REGISTRATION_ID是不是authserver-client-credentials和 properties 里的前綴一致。這個 id 是spring.security.oauth2.client.registration.后面那一段寫錯了就找不到注冊信息。排查的時候有個通用技巧把 Spring Security 的日志級別調(diào)到 DEBUG在 properties 里加logging.level.org.springframework.securityDEBUG令牌的獲取、校驗、拒絕過程都會打出來比猜快得多。另外 TaoToken 的接入文檔在 https://taotoken.net/doc 模型通道相關(guān)的參數(shù)對照著看能省不少時間。6. 把 MCP 鑒權(quán)鏈路沉淀成可復(fù)用模板走到這里你應(yīng)該已經(jīng)跑通了「授權(quán)服務(wù)器發(fā)令牌 → MCP 服務(wù)端驗令牌 → 客戶端動態(tài)切令牌 → 大模型驅(qū)動工具調(diào)用」的完整閉環(huán)?;仡^看OAuth2 在這套體系里的價值不是增加復(fù)雜度而是把「誰能調(diào)什么」這件事從業(yè)務(wù)代碼里抽出來交給標(biāo)準(zhǔn)協(xié)議處理。你后面要加新工具只需要在Tool方法上寫注解鑒權(quán)自動生效不用改一行安全代碼。幾個可以立刻用起來的經(jīng)驗。第一把三份配置抽成獨立的 profile本地用application-local.properties測試環(huán)境換 issuer-uri 就行代碼不用動。第二客戶端憑證的 scope 建議只給讀權(quán)限用戶授權(quán)碼模式再給寫權(quán)限最小權(quán)限原則在 MCP 場景同樣適用。第三模型通道統(tǒng)一走 TaoToken 之后你換模型只改一行spring.ai.openai.chat.options.modelOAuth2 鏈路完全不受影響這種解耦在調(diào)試期特別省心。如果你打算把這套東西用到生產(chǎn)下一步可以考慮把授權(quán)服務(wù)器換成支持持久化的實現(xiàn)客戶端注冊信息從數(shù)據(jù)庫讀而不是寫死在 YAML 里。MCP 服務(wù)端這邊工具方法里的業(yè)務(wù)邏輯記得做參數(shù)校驗和異常兜底因為大模型傳過來的參數(shù)不一定符合預(yù)期。至于模型側(cè)長期跑 Agent 的話可以關(guān)注一下 Coding Plan 的額度方案按調(diào)用量選比按次買劃算。最后留一個可以直接復(fù)用的驗證腳本把三端啟動和令牌校驗串起來每次改完配置跑一遍比手動點瀏覽器快#!/bin/bash set -e echo 1. 獲取客戶端憑證令牌 TOKEN$(curl -s -X POST http://localhost:9000/oauth2/token \ -u mcp-client:mcp-secret \ -d grant_typeclient_credentials \ -d scopecalc.read calc.write | jq -r .access_token) echo token: ${TOKEN:0:20}... echo 2. 帶令牌訪問 MCP 服務(wù)端 curl -s -o /dev/null -w %{http_code}\n \ -H Authorization: Bearer $TOKEN http://localhost:8090/sse echo 3. 不帶令牌應(yīng)返回 401 curl -s -o /dev/null -w %{http_code}\n http://localhost:8090/sse echo 4. 驗證模型通道 curl -s https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d {model:claude-3-5-sonnet-20241022,messages:[{role:user,content:ok}],max_tokens:8} \ | jq -r .choices[0].message.content這個腳本跑完四步結(jié)果都對說明整條鏈路是健康的。哪一步掛了就回到對應(yīng)章節(jié)查配置。MCP 加 OAuth2 這套組合第一次配會覺得環(huán)節(jié)多但配通一次之后就是模板后面加工具、換模型都是改配置的事。