部落格技術
遠端 MCP 伺服器的 OAuth 實戰:上線經驗總結
一個以 Supabase Auth 作為授權伺服器、已在正式環境運作的 MCP 伺服器實作筆記:規範要求什麼、我們哪裡做錯了,以及還有哪些尚未測試。
先說結論
遠端 MCP 伺服器是一種 OAuth 受保護資源。請求沒帶權杖時,它會回傳 401,並指向自己的 RFC 9728 中繼資料;用戶端順著中繼資料找到授權伺服器、完成註冊,用 PKCE 和 resource 參數讓使用者登入,最後帶著為該伺服器 URL 簽發的 Bearer 權杖回來。我們在正式環境中用 Supabase Auth 執行這套流程。真正花工夫的是三個細節:401 要帶 scope 提示、不帶錯誤碼;用權杖 hook 把我們的受眾和 scope 寫進 Supabase 簽發的權杖;還有動態用戶端註冊,因為我們的授權伺服器不支援用戶端 ID 中繼資料文件。
MCP 伺服器採用 OAuth 時,各方分別負責什麼?
MCP 伺服器的工作最少:只負責接受或拒絕 Bearer 權杖。MCP 授權規範(2025-11-25 版)把它定為 OAuth 2.1 資源伺服器,把 AI 助理定為 OAuth 用戶端,並允許負責使用者登入和簽發權杖的授權伺服器是獨立的服務。
我們的授權伺服器就是獨立的。所有權杖都由 Supabase Auth 的 OAuth 2.1 伺服器簽發,登入和授權同意頁面由我們的網站提供,https://mcp.scorestarling.com/mcp 這個端點只負責驗證權杖。它以 Streamable HTTP 執行,不使用 MCP 工作階段,每個請求都會驗證權杖。ScoreStarling 能把錄音變成可編輯的樂譜,AI 助理就是透過這個端點使用它;使用者要做的步驟請見連線指南。
MCP 用戶端連線時會發生什麼事?
用戶端一開始只有一個 URL。我們伺服器的兩次回應(以下為 2026年10月3日的實際內容)告訴它去哪裡登入,之後再經過六個步驟取得權杖。首先,沒帶權杖的請求會收到 401,其 WWW-Authenticate 回應標頭寫明了中繼資料的位置和要申請的 scope:
$ curl -si -X POST https://mcp.scorestarling.com/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' -d '{}'
HTTP/2 401
content-type: application/json
www-authenticate: Bearer resource_metadata="https://mcp.scorestarling.com/.well-known/oauth-protected-resource/mcp", scope="openid email profile"
{"error_description": "Authentication required"}
接著,用戶端取得這份中繼資料。依照 RFC 9728,中繼資料網址是在主機名稱和路徑之間插入 /.well-known/oauth-protected-resource 而成;它還要求 resource 與用戶端正在使用的 URL 完全相同,否則用戶端必須停止。以下已把我們的 Supabase 專案 ID 換成佔位文字:
{
"resource": "https://mcp.scorestarling.com/mcp",
"authorization_servers": ["https://<project-ref>.supabase.co/auth/v1"],
"scopes_supported": ["openid", "email", "profile"],
"bearer_methods_supported": ["header"]
}
其餘六個步驟:
用戶端讀取授權伺服器的中繼資料。Supabase 的 issuer 帶有路徑,所以依照 RFC 8414,well-known 區段要放在路徑前面:
https://<project-ref>.supabase.co/.well-known/oauth-authorization-server/auth/v1。用戶端會檢查issuer是否完全相符,以及code_challenge_methods_supported是否列出S256;MCP 規範規定,缺少這個欄位就必須停止。用戶端把自己的中繼資料 POST 到中繼資料所公布的
registration_endpoint(動態用戶端註冊,RFC 7591),取得一個client_id。我們的測試用戶端以公用用戶端身分註冊,不帶用戶端密碼;Anthropic 的文件說 Claude 也是如此。用戶端在瀏覽器中開啟授權端點,帶上 PKCE S256 challenge、401 給出的 scope,以及
resource=https://mcp.scorestarling.com/mcp(RFC 8707)。Supabase 把瀏覽器轉交給我們的授權同意頁面:必要時先讓使用者登入,再請對方核准或拒絕這個用戶端。用戶端用 PKCE verifier 和同一個
resource交換授權碼,取得一個效期一小時的存取權杖和一個更新權杖。之後的每個 MCP 請求都帶上
Authorization: Bearer …。我們用 issuer 公布的金鑰驗證簽章,再檢查 issuer、受眾、到期時間和寫入 scope,最後確認帳號沒有被停用。權杖到期,或我們回傳帶
invalid_token的 401 時,用戶端會更新權杖(同樣帶上resource),取得新的存取權杖和更新權杖。
即使授權伺服器會忽略 resource,用戶端也必須在授權請求和權杖請求中帶上它。Supabase 的 OAuth 流程文件沒有提到這個參數(2026年10月3日確認)。在我們所有的正式環境實測中,Supabase 都接受了它,而我們權杖裡的受眾來自 hook。
401 回應的 WWW-Authenticate 標頭該包含什麼?
請求沒帶權杖時:寫明中繼資料在哪裡、該申請哪些 scope,不帶錯誤碼。RFC 6750 §3.1 指出,完全不帶驗證資訊的請求不應收到錯誤碼。invalid_token 是給帶了權杖但驗證失敗的情況,告訴用戶端可以換個新權杖再重試。MCP 規範還補充,401 應帶有 scope 提示,用戶端必須把它當成要申請的 scope。
我們是在撰寫連線指南時,發現自己的伺服器在這點上做錯了。在 MCP Python SDK 1.30.0 中,RequireAuthMiddleware 對所有未驗證的請求都回傳 error="invalid_token",而且不帶 scope,結果一個從沒送過權杖的用戶端被告知「權杖無效」。現在我們在 MCP 路由外包了一層小小的 ASGI 中介軟體,只改寫 SDK 回傳的 401;放不放行仍由 SDK 決定,它回傳的 403 insufficient_scope 也原封不動:
# No token, before the fix (the SDK's default; wrapped for reading)
WWW-Authenticate: Bearer error="invalid_token",
error_description="Authentication required",
resource_metadata="…/.well-known/oauth-protected-resource/mcp"
# No token, now
WWW-Authenticate: Bearer
resource_metadata="…/.well-known/oauth-protected-resource/mcp",
scope="openid email profile"
# Rejected token: expired, wrong audience, malformed or account turned off
WWW-Authenticate: Bearer error="invalid_token",
error_description="Invalid or expired access token",
resource_metadata="…/.well-known/oauth-protected-resource/mcp",
scope="openid email profile"
提示中寫的是 openid email profile,也就是 Supabase 能授予的 scope,與 scopes_supported 一致。現在只要沒帶權杖時的挑戰中出現錯誤碼,我們的部署檢查就會失敗。這項修正在 2026年10月2日上線,距離合併不到四分鐘。Anthropic 的連接器文件還為 Claude 補充了兩條規則:登入只會由 401 觸發(Claude 會忽略 200 回應中的 WWW-Authenticate),而且只使用 authorization_servers 中的第一個項目。
如何把 MCP 受眾和 scope 寫進 Supabase 的權杖?
用自訂存取權杖 hook。MCP 規範要求伺服器只接受為自己簽發的權杖,實務上就是要檢查 aud;我們也想要一個自己的寫入 scope,而 Supabase 的文件說它的 OAuth 伺服器不支援自訂 scope(2026年10月3日確認)。這個 hook 是一個 Postgres 函式,Supabase Auth 在簽發權杖前執行它,它可以改寫權杖中的 claims。以下是我們的版本,依遷移檔稍作簡化:
create or replace function scorestarling.access_token_hook(event jsonb)
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
declare claims jsonb := event->'claims';
begin
-- Tokens issued to an OAuth client carry its client_id; website sessions don't.
if coalesce(claims->>'client_id', '') <> '' then
claims := jsonb_set(claims, '{aud}', '"https://mcp.scorestarling.com/mcp"');
claims := jsonb_set(claims, '{scope}', '"openid email profile scorestarling:write"');
end if;
return jsonb_build_object('claims', claims);
end;
$$;
grant usage on schema scorestarling to supabase_auth_admin;
grant execute on function scorestarling.access_token_hook(jsonb) to supabase_auth_admin;
revoke all on function scorestarling.access_token_hook(jsonb) from public, anon, authenticated;
MCP 端用 PyJWT 驗證這些 claims,再套用兩條自己的規則:
claims = jwt.decode(token, key, algorithms=["RS256", "ES256"],
issuer=ISSUER, audience=ACCEPTED_AUDIENCES,
options={"require": ["exp", "iat", "iss", "aud", "sub"]})
if "scorestarling:write" not in claims.get("scope", "").split():
return None # the client gets 401 invalid_token
# then: has this account been turned off?
ACCEPTED_AUDIENCES 是 MCP 的 URL,搬遷期間再加上舊的 URL。對 client_id 的判斷把網站自己的登入擋在外面:同一個 Supabase 專案簽發的瀏覽器工作階段權杖,issuer 和金鑰都一樣,但沒有 client_id,所以永遠拿不到 MCP 受眾。
這個 hook 有兩個代價。第一,它寫入的是一個固定的受眾,而不是依用戶端傳來的 resource;只要一個授權伺服器只服務一個 MCP 伺服器,這樣就沒問題。第二,我們的授權同意頁面只能顯示用戶端申請的 scope,所以寫入 scope 其實是在使用者核准用戶端時授予的。
有一則尚未關閉的問題回報 supabase/auth#2820(2026年9月20日提出,截至 10月3日無人回覆)指出,對公用用戶端、offline_access 或 resource,Supabase 取得授權詳細資料的請求會回傳 400。這些情況我們都無法重現;在我們讀過的 Supabase Auth 原始碼中,只有授權已不在待處理狀態時(例如同一個授權被讀取了兩次),才會出現這個 400。
動態用戶端註冊,還是用戶端 ID 中繼資料文件?
授權伺服器宣告支援哪種就用哪種;目前用 Supabase 的話,就是動態用戶端註冊(DCR)。2025-11-25 版 MCP 規範建議,在用戶端和伺服器素未謀面時使用用戶端 ID 中繼資料文件(CIMD),保留 DCR 則是為了向下相容。用戶端應先嘗試預先提供的用戶端 ID,再來是 CIMD,然後是 DCR,都不行才詢問使用者。
| 方式 | 用戶端如何表明身分 | 伺服器中繼資料中的標示 | Claude 表單中的選項 | 我們的伺服器(2026年10月3日) |
|---|---|---|---|---|
| 預先註冊 | 預先建立的用戶端 ID,手動輸入或內建在用戶端中 | 不需要 | Use your own OAuth client(使用自己的 OAuth 用戶端) | 未提供 |
| 用戶端 ID 中繼資料文件 | client_id 本身是一個 HTTPS URL,指向用戶端代管的 JSON 檔案,由伺服器去讀取 | client_id_metadata_document_supported | Use Claude’s published identity(推薦,使用 Claude 已發布的身分) | 未宣告支援 |
| 動態用戶端註冊 | 把自己的中繼資料 POST 到註冊端點,取得 client_id | registration_endpoint | Register automatically(自動註冊) | 已宣告支援 |
我們在 2026年10月3日再次讀取 Supabase 的中繼資料:其中列出了註冊端點、S256 和權杖端點驗證方式 none,沒有 CIMD 標示;它的 MCP 指南會請你在儀表板中開啟動態註冊。
這決定了使用者在 Claude 自訂連接器表單中該怎麼選。Anthropic 的文件把推薦選項,也就是 Claude 已發布的身分(published identity),描述為由 Anthropic 代管、需要伺服器支援的 CIMD。所以我們的指南要求選擇「Sign in now」(我們沒有任何工具能在不登入的情況下使用)和「Register automatically」。Anthropic 的開發者文件說,伺服器未宣告支援 CIMD 時,Claude 會退回使用 DCR(CIMD 還要求 token_endpoint_auth_methods_supported 中包含 none);我們沒測試過保留表單預設值的情況,所以要求明確選擇。我們在 2026年10月2日讀過的用戶端文件顯示,ChatGPT 開發者模式、Cursor、VS Code 和 Gemini CLI 都使用 DCR。
DCR 也有代價:Anthropic 提到,Claude 每次全新連線都會註冊一個新的用戶端;Supabase 也提醒,開啟動態註冊後,任何 MCP 用戶端都能在你的專案中註冊。不過,註冊本身不會授予任何權限。使用者仍然要登入並核准這個用戶端,權杖驗證也會在每個請求上檢查帳號。由於任何用戶端都能用任意名稱註冊,從 2026年10月6日起,我們的授權同意頁面會顯示核准後使用者將被帶往何處;如果某個應用程式自稱 ChatGPT 或 Claude,重新導向網址卻不是該助理的網址,頁面就會發出警告。
存取權杖到期後,MCP 用戶端還能維持登入嗎?
可以。存取權杖的效期是一小時,用戶端會用更新權杖續期。在依賴這一點之前,我們讀過 Supabase Auth 的原始碼(master 分支,截至 2026年9月22日):
- 每次授權碼交換都會回傳更新權杖,無論是否申請了
offline_access; - 更新必須由該工作階段所屬的用戶端發起,更新時會輪替更新權杖,並保留
client_idclaim; - 更新時 hook 同樣會執行(此時
authentication_method為token_refresh),所以每次都會重新寫入我們的受眾和 scope。
2026年10月2日在正式環境的實測結果一致:更新權杖有輪替,續期後的存取權杖也能正常使用。Anthropic 的文件說,Claude 會在收到 401 後,以及權杖到期前五分鐘內更新權杖;如果授權伺服器在 scopes_supported 中列出 offline_access,Claude 也會申請它。Supabase 的中繼資料有列出它,而我們額外申請 offline_access 的檢查也沒有問題。
以下情況仍會結束工作階段:
- 帳號被停用,或使用者被刪除、停權(在我們的正式環境實測中,停用帳號後,瀏覽器工作階段和兩個 MCP 權杖都被拒絕);
- 舊的更新權杖在 Supabase 的重複使用間隔(預設 10 秒)之後又被使用,這會讓整個工作階段失效;
- 開啟了工作階段時限、閒置逾時或「single session per user」(每位使用者只保留一個工作階段)。最後這項務必關閉,否則在網站上登入一次,助理的工作階段就會結束;
- 使用者以 Supabase 預設的
logout登出網站,這會結束該帳號的所有工作階段,包括助理的。我們的網站在 2026年10月6日之前就是如此,這很可能就是某個 ChatGPT 連線無法再續期權杖的原因;現在「登出」會送出scope=local,只結束目前這個瀏覽器的工作階段。
Supabase 沒有公布 revocation 端點(2026年10月3日確認),所以我們切斷存取權的方式就是停用帳號。
issuer 結尾多一個斜線,為什麼會出問題?
因為 issuer 是逐字元比對的。RFC 8414 要求中繼資料中的 issuer 與用戶端用來找到它的值完全相同,RFC 9728 對 resource 也有同樣要求。對嚴格的用戶端來說,https://auth.example.com 和 https://auth.example.com/ 是兩個不同的 issuer。
MCP Python SDK 以 pydantic 的 AnyHttpUrl 儲存 issuer,而我們 lockfile 中鎖定的 pydantic 2.13.5 會把 https://auth.example.com 變成 https://auth.example.com/。由此產生的中繼資料,寫的是授權伺服器從未公布過的 issuer,所以我們的伺服器自己產生受保護資源中繼資料,issuer 與設定值完全一致。Supabase 的 issuer 以路徑結尾,pydantic 不會改動,所以我們在正式環境中從沒遇過這個問題;但如果 issuer 只是不帶路徑的來源(origin),就會中招。現在只要授權伺服器的 issuer 與我們的不完全相同,部署檢查就會失敗。
哪些已經驗證,哪些還沒有
最初的證據來自一個依 MCP 規範撰寫的腳本用戶端。2026年10月2日,我們對正式環境跑了一輪測試,涵蓋沒帶權杖時的 401、公用用戶端的動態註冊、帶 resource 的授權同意與授權碼交換、帶輪替的權杖更新、用兩個權杖分別呼叫 tools/list,以及停用帳號(停用後兩個權杖和瀏覽器工作階段都被拒絕)。第二輪額外申請了 offline_access,兩輪結果都沒有問題。那次測試用的還是我們先前的網址;搬到 mcp.scorestarling.com 的過程另有一篇文章。
此後,真實的用戶端也連上了。2026年10月3日,ChatGPT 開發者模式中的應用程式連上 mcp.scorestarling.com,完成登入並呼叫了一個工具;10月6日,Claude 也成功連線並執行了工具。尚未驗證的有:
- 權杖真正到期後的更新:腳本中的更新是在登入後立刻執行的,我們還沒追蹤過真實用戶端的更新;
- Supabase 代管端的工作階段設定,我們還沒有讀回確認。
參考資料
- MCP 規範(2025-11-25 版):授權 — Model Context Protocol
- RFC 9728:OAuth 2.0 受保護資源中繼資料 — IETF
- RFC 6750:OAuth 2.0 授權架構:Bearer 權杖的使用,§3.1 — IETF
- RFC 8707:OAuth 2.0 資源指標 — IETF
- RFC 7591:OAuth 2.0 動態用戶端註冊協定 — IETF
- RFC 8414:OAuth 2.0 授權伺服器中繼資料 — IETF
- OAuth 用戶端 ID 中繼資料文件(網際網路草案) — IETF OAuth 工作小組
- 連接器的身分驗證 — Anthropic
- 新增不在目錄中的連接器 — Anthropic
- OAuth 2.1 流程 — Supabase
- 自訂存取權杖 Hook — Supabase
- Model Context Protocol(MCP)身分驗證 — Supabase
- 使用者工作階段 — Supabase
- supabase/auth 的 issue #2820 — GitHub
常見問題
遠端 MCP 伺服器需要自己的授權伺服器嗎?
不需要。MCP 授權規範允許授權伺服器是獨立的服務。MCP 伺服器發布 RFC 9728 中繼資料指出授權伺服器,再驗證它簽發的權杖,包括權杖的受眾。我們的伺服器指向 Supabase Auth,自己只負責驗證權杖。
請求沒有帶權杖時,MCP 伺服器的 401 該包含 invalid_token 錯誤嗎?
不該。RFC 6750 §3.1 指出,不帶認證資訊的請求不應收到錯誤碼。應改為提供 resource_metadata 和 scope 提示,把 invalid_token 留給帶了權杖卻遭拒絕的情況,用來告訴用戶端更新權杖。
Supabase Auth 能為 MCP 伺服器簽發自訂 scope 嗎?
截至 2026年10月3日還不能:Supabase 的 OAuth 2.1 文件寫明不支援自訂 scope。我們請求的是 openid email profile,再透過自訂存取權杖 hook(custom access token hook)加上我們自己的 scope 和 MCP 受眾;每次更新權杖時,這個 hook 也會執行。
伺服器不支援 CIMD 時,在 Claude 的自訂連接器表單中該怎麼選?
在「OAuth client」下選「Register automatically」,也就是動態用戶端註冊;如果每個工具都需要帳號,再在「Authentication」下選「Sign in now」。Claude 推薦的選項「Use Claude’s published identity」是一份用戶端 ID 中繼資料文件,需要伺服器支援。我們的 Claude 連線步驟對這兩項選擇都有說明。
要從 Supabase 取得更新權杖,一定要申請 offline_access scope 嗎?
在我們的測試中不需要。不申請它,Supabase 在每次授權碼交換時也都回傳了更新權杖:從原始碼看是如此,2026年10月2日在正式環境的實測也是如此。有些用戶端還是會申請它;Anthropic 的文件說,授權伺服器有列出它時 Claude 就會申請,而我們帶上它的檢查也沒有問題。