Cursor 和 Claude Code 可以手貼 token。Claude.ai 和 ChatGPT 的網頁版不行,你只能給它一個網址,剩下的它要自己談。那個「自己談」的協定是 OAuth 2.1 加上動態用戶端註冊。
使用者做的事只有四步:
https://你的網域/mcp
從此之後,Claude.ai 可以讀寫這座知識庫,隨時可以在我的設定頁撤銷。

同意頁是整個流程裡唯一有人類判斷力的環節,所以要把對方是誰、會被導去哪裡寫清楚。
第一步是探測。Claude.ai 打 /mcp,拿到 401,而且標頭裡有:
WWW-Authenticate: Bearer resource_metadata="https://你的網域/.well-known/oauth-protected-resource/mcp"
之前我們已經提過這個標頭,這裡就是它的用途。沒有它,流程在這裡就結束了。
第二步是動態註冊。Claude.ai 讀到那份 metadata,發現這台伺服器支援動態註冊,於是 POST 到 /register 說「我是一個叫 Claude 的用戶端,我的 redirect URI 是這些」。伺服器產生一組 client id 存起來。
這一步是 OAuth 少見的地方:一般 OAuth 要你先去開發者後台申請 client id,這裡是程式自己申請。因為 MCP 的情境是「任意的 client 連任意的伺服器」,人工申請不可能規模化。
第三步是授權。它把使用者導到 /authorize,帶著 client id、redirect URI、還有 PKCE 的挑戰碼。伺服器驗證這些參數,把請求存起來,然後把使用者導到我自己的同意頁。使用者按允許,伺服器發一個短期的授權碼,導回 Claude.ai。
![OAuth 的時序:Claude.ai 打 /mcp 拿到 401 與 resource_metadata,讀 metadata、動態註冊、導向 /authorize、使用者在同意頁按允許、最後用授權碼換 token]
整段流程裡使用者只做了兩件事:貼一個網址、按一次允許。
第四步是換 token。Claude.ai 拿授權碼加上 PKCE 的驗證碼打 /token,換到 access token 跟 refresh token:
const ACCESS_TTL_S = 24 * 3600; // access token 24 hours
const REFRESH_TTL_S = 90 * 24 * 3600; // refresh token 90 days
之後所有 MCP 請求都帶那個 access token,跟手貼的 token 走同一套驗證邏輯,存進同一張 mcp_tokens 表,只是多了 client_id 跟 refresh 的欄位。使用者在設定頁看到的那份連線清單,兩種來源混在一起列,撤銷的方式也一樣。
授權碼要原子換用。一個授權碼只能換一次。實作上不能「先查詢,再刪除,再發 token」,那中間有窗口。要用一句 SQL 把標記跟取值綁在一起:
UPDATE oauth_requests SET code_used_at = now()
WHERE code_hash = $1 AND client_id = $2 AND code_used_at IS NULL
AND expires_at > now() AND user_id IS NOT NULL
RETURNING user_id, workspace_id, scopes, redirect_uri
跟之前講樂觀鎖時是同一招:條件寫進 WHERE,讓資料庫保證只有一個人會拿到那一列。同一個碼被同時送兩次,第二次更新到零列,回空的。
redirect URI 要精確比對。不能用前綴比對,不能允許萬用字元:https://good.com.evil.com 這種網址就是要混過草率的前綴檢查。這一段我沒有自己寫,MCP 官方 SDK 的授權路由已經幫你驗 client、redirect URI 跟 PKCE,我只負責把請求存起來、發碼、換 token。
PKCE 一律要求。現在的 OAuth 2.1 已經把它列為必要,別自己想例外。
scope 我一開始做錯了:寫進 metadata,但驗證的時候沒有檢查,那等於沒有。現在 token 帶著 scope,寫入類的工具會檢查有沒有 notes:write,refresh 換發時 scope 只能縮不能擴。
同意頁要顯示對方是誰。上面要寫清楚是哪個 client 在要求授權、它會被導回哪個網域。如果那個網域跟它自稱的官網不符,或者不是 https,要跳警告。
動態註冊代表任何人都能來註冊,所以要有上限:
if (JSON.stringify(client).length > 8 * 1024) throw new InvalidRequestError('client metadata too large (8 KB max)');
if ((client.client_name ?? '').length > 100) throw new InvalidRequestError('client_name too long (100 chars max)');
if (client.redirect_uris.length > 10) throw new InvalidRequestError('too many redirect_uris (10 max)');
加上一支每小時跑的清理:過期的授權請求隔一天刪掉,而註冊了三十天卻從來沒換過 token、也沒有待處理請求的 client 直接刪掉。
DELETE FROM oauth_clients c
WHERE c.created_at < now() - interval '30 days'
AND NOT EXISTS (SELECT 1 FROM mcp_tokens t WHERE t.client_id = c.client_id)
AND NOT EXISTS (SELECT 1 FROM oauth_requests r WHERE r.client_id = c.client_id)
沒有這支清理,一個公開的註冊端點就是一張任何人都能寫的表。
這段實作裡自己寫的部分不多:驗 client、redirect URI 與 PKCE 都是 SDK 做掉的。我寫的是四件:授權碼用一句 SQL 原子換用、scope 在每次呼叫真的檢查、同意頁把對方是誰跟導回哪裡寫清楚、公開的註冊端點加上限並且每小時清一次。