Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

Claude 自訂連接器如何找到 osFoundry 的 OAuth 伺服器,又為什麼每次呼叫都要檢查存取權限?

遠端 MCP client 在沒有 token 的情況下呼叫 osFoundry 的工具時,會拿到下面這個回應。這個請求是 2026-11-06 在東京用一台 Mac 透過 curl 送出的: $ curl -s -D - -X POST https://api.osfoundry.io/api/v1/external-mcp \ -H 'content-type: application/json' \ -H '

遠端 MCP client 在沒有 token 的情況下呼叫 osFoundry 的工具時,會拿到下面這個回應。這個請求是 2026-11-06 在東京用一台 Mac 透過 curl 送出的:

$ curl -s -D - -X POST https://api.osfoundry.io/api/v1/external-mcp \
    -H 'content-type: application/json' \
    -H 'accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
         "params":{"name":"list_project_resources","arguments":{}}}'

HTTP/2 401
www-authenticate: Bearer resource_metadata="https://api.osfoundry.io/.well-known/oauth-protected-resource/api/v1/external-mcp", error="invalid_token", error_description="Connect osFoundry to continue"

{"jsonrpc":"2.0","id":3,"result":{"isError":true,
  "content":[{"type":"text","text":"Authentication required"}],
  "_meta":{"mcp/www_authenticate":["Bearer resource_metadata=\"https://api.osfoundry.io/.well-known/oauth-protected-resource/api/v1/external-mcp\", ..."]}}}

光靠這一個 header,Claude 的自訂連接器(custom connector),或 ChatGPT 開發者模式的 MCP 連線,就能找到登入頁面。使用者只需要貼上網址。以下內容全部來自對正式環境 endpoint 的未驗證請求,再加上一個在本機執行的小型 Python 重建版本,用來量測 curl 看不到的部分:登入之後,存取權限會發生什麼事。

拿到 token 之前,client 看得到什麼?

三個呼叫,三種不同的回應:

請求 狀態碼 回傳內容
initialize 200 協定 2025-06-18、伺服器 osfoundry 0.1.0,外加 instructions
tools/list 200 59 個工具,45,368 bytes
tools/call 401 WWW-Authenticate 指向 resource metadata
對 endpoint 發 GET 405 GET 不提供 SSE stream

握手和工具目錄是公開的,只有呼叫工具需要 token。這樣 host 可以在任何人登入之前,就先讓使用者看到有哪些工具。目錄裡沒有任何使用者資料,initialize 裡的 instructions 字串也是通用的:

Use list_granted_resources first. Every Note edit must use the exact tab ID.
Local-only data is unavailable to cloud clients.

tools/list 裡的每個工具都會宣告自己需要的 OAuth scope,以及是否只做讀取:

{"name": "edit_record_item",
 "annotations": {"readOnlyHint": false, "destructiveHint": true, "openWorldHint": false},
 "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]}

59 個工具中:30 個需要 osfoundry:read 並標記為 readOnlyHint: true,26 個需要 osfoundry:write,3 個需要 osfoundry:run(request_session_run、run_graph_session、cancel_graph_run)。host 可以根據 readOnlyHint 決定哪些呼叫要跳出確認提示。

401 把它導向哪裡?

resource_metadata 遵循 RFC 9728(OAuth Protected Resource Metadata)。well-known 後綴緊接在 host 後面,再把 resource 路徑接在後綴之後,而不是接在 resource URL 的尾端:

GET /.well-known/oauth-protected-resource/api/v1/external-mcp   200
GET /.well-known/oauth-protected-resource                       200 (same document)
GET /api/v1/external-mcp/.well-known/oauth-protected-resource   404
{"resource": "https://api.osfoundry.io/api/v1/external-mcp",
 "authorization_servers": ["https://api.osfoundry.io"],
 "scopes_supported": ["osfoundry:read", "osfoundry:write", "osfoundry:run", "osfoundry:models"]}

如果你自己手刻 client,最先踩到的坑就是這個 404。直接從 header 讀 URL,不要自己組。

授權伺服器怎麼描述自己?

/.well-known/oauth-authorization-server(RFC 8414):

{"issuer": "https://api.osfoundry.io",
 "authorization_endpoint": "https://api.osfoundry.io/api/v1/external-oauth/authorize",
 "token_endpoint": "https://api.osfoundry.io/api/v1/external-oauth/token",
 "registration_endpoint": "https://api.osfoundry.io/api/v1/external-oauth/register",
 "grant_types_supported": ["authorization_code", "refresh_token"],
 "token_endpoint_auth_methods_supported": ["none"],
 "code_challenge_methods_supported": ["S256"],
 "authorization_response_iss_parameter_supported": true}

把它當成一連串的設計決策來讀。有 registration_endpoint,所以 host 可以自行註冊(動態 client 註冊),使用者完全不用碰 client ID。token 驗證方式是 none,所以 host 是沒有 client secret 的 public client,負責把授權碼綁定到發出請求的 client 的,是 S256 的 PKCE。授權回應裡的 iss,則是在 host 同時對接多個授權伺服器時防止搞混。

authorize 步驟會把瀏覽器導到 osFoundry 的同意頁面 https://osfoundry.io/external-assistant-authorize。使用者在那裡只能選一個 workspace,並勾選 "I approve connecting this assistant to …"(我同意將此助理連接到 …)。這個核准只指定一個 workspace,除此之外什麼都沒有。新建立的連線沒有任何專案,owner 之後再到 app 裡加入(key icon → Connected → Add to project)。

為什麼光有 scope 不夠?

scope 在 token 簽發的那一刻就固定了。osfoundry:write 表示這個連線可以執行寫入類的操作,但它沒有說是哪一則 Note,也不可能知道那則 Note 十分鐘前被移到了別的專案。osFoundry 的設計筆記列出了每次呼叫都會重新讀取的項目:連線狀態、workspace、專案成員資格、resource 目前所屬的專案與 ACL、頻道成員資格或 session 角色、工具本身,以及 OAuth scope。核准連線的那個人,也必須仍然對該項目擁有編輯權限。移除或重新指派會「立即生效」。

另一種做法是把專案清單放進 token,或放進以 token 為 key 的快取,在到期前一律信任它。這樣寫起來比較省事。下面的重建版本兩種都實作了,好量測行為上的差異。

重建版本

整個過程沒有用到 Claude 的執行檔。client.py 扮演 host,server.py 扮演 MCP 伺服器。兩者都只用標準函式庫,加起來大約 280 行,在 Apple M1 上以 Python 3.9.6 執行。伺服器在純 HTTP 的 JSON-RPC 上實作了 initialize、tools/list 和 tools/call,加上兩份 well-known 文件、/authorize(同意頁面對單一 workspace 自動核准),以及支援 S256 PKCE 的 /token。另外用幾個 /admin/* 路由代替 owner 在 Connected 分頁裡的點擊操作。

每個工具執行前都會跑的檢查:

def access(token_info, tool, note_id):
    if TOOL_SCOPE[tool] not in token_info["scopes"]:
        return "insufficient_scope"
    if CHECK == "token_only":                     # design B: trust issue-time state
        ok = note_id is None or NOTES[note_id]["project"] in token_info["projects_at_issue"]
        return None if ok else "not_in_project"
    conn = CONNECTIONS.get(token_info["connection"])
    if not conn or conn["status"] != "active":
        return "connection_revoked"
    if note_id is None:
        return None
    note = NOTES.get(note_id)
    if not note or note["workspace"] != conn["workspace"]:
        return "not_found"
    if (token_info["connection"], note["project"]) not in PROJECT_MEMBERS:
        return "not_in_project"
    return None

client 一開始沒有 token。它呼叫工具、收到 401、從 WWW-Authenticate 解析出 resource_metadata、抓取兩份 metadata 文件、用新的 PKCE verifier 跑完 authorize 和 token,接著依序執行情境:

$ CHECK=per_call python3 server.py &  python3 client.py 8767
discovery: first call -> 401, metadata /.well-known/oauth-protected-resource/mcp, 5 round trips, 11.3 ms
1 fresh connection, no project yet           200 OK   []
2 after owner adds project A                 200 OK   ["note-a1"]
3 read note in A                             200 OK   "Q3 launch checklist"
4 read note in B (never added)               200 DENY not_in_project
5 write with a read-only token               403 DENY insufficient_scope
6 write with read+write token                200 OK   "saved"
7 same read after note moved to B            200 DENY not_in_project
8 same read after owner removes project A    200 DENY not_in_project
9 same read after revoke                     200 DENY connection_revoked

同一個 client 改對 CHECK=token_only 執行。這時 token 裡帶的是上次 refresh 時的專案清單:

7 same read after note moved to B            200 DENY not_in_project
8 same read after owner removes project A    200 OK   "Q3 launch checklist v2"
9 same read after revoke                     200 OK   "Q3 launch checklist v2"

步驟 1–7 完全相同。在只看 token 的設計下,步驟 7 依然被拒絕,因為 note 所屬的專案是即時查詢的。只看 token 的設計,只有在存放於 token 裡的資訊上才會過時。步驟 8 和 9 才是 owner 在意的:他們移除了專案,接著撤銷了連線,聊天卻還是讀得到。在只看 token 的設計下,這種狀態會一直持續到 token 過期,在這個重建版本中是 expires_in: 3600。

拒絕分成兩層。缺少 scope 屬於協定層級的失敗:回 403,帶上 error="insufficient_scope",並在 WWW-Authenticate 裡附上所需的 scope,讓 host 可以要求更多權限。凡是跟「涉及哪個項目」有關的,都是一般的工具結果,帶 isError: true。重新授權也解決不了,所以沒必要叫 host 去重試。

每次呼叫都檢查,成本是多少?

在伺服器內用 time.perf_counter_ns() 包住 access() 量測,每輪 1,000 次呼叫(500 次允許、500 次拒絕),量測時這台 Mac 同時也在跑其他工作:

access() 中位數 access() p99 loopback 工具呼叫中位數
每次呼叫檢查,6 輪 1.3–1.6 µs 6.1–7.0 µs 0.43–0.78 ms
只看 token,3 輪 0.7–1.6 µs 3.5–7.7 µs 0.35–1.36 ms(2 輪)

在記憶體裡,重新檢查的成本完全淹沒在 loopback HTTP 呼叫的雜訊中。真正的伺服器做的是資料庫查詢,而不是 set 成員判斷,成本會更高。這個重建版本量不到那部分,所以不提供數字。拿來對照的話,從東京到正式環境 endpoint 的網路來回,用 urllib 且每次都建立新的 TLS 連線,每個請求大約 500 ms 到 800 ms(各 n=10:initialize 中位數 790 ms、tools/list 816 ms、回 401 的 tools/call 565 ms、每份 metadata 文件約 520–530 ms)。連接器的時間不是花在存取檢查上。

探索流程本身在本機是 5 次 HTTP 來回:401、resource metadata、server metadata、authorize、token。七輪花了 7.5–13.4 ms,Mac 忙碌時有一次是 34 ms。對正式伺服器而言,不需要憑證就能打的三段 GET/POST,從冷啟動算起合計大約 1.6 s。authorize 那段反正本來就要等人操作。

為什麼 Claude 能呼叫 osFoundry,osFoundry 卻打不開你的 Claude 聊天?

在這個架構裡,發起呼叫的是 host。它建立連線、列出工具,並決定什麼時候呼叫哪個工具。伺服器只負責回應。這個架構裡沒有任何東西讓伺服器能碰到 host 的對話。osFoundry 的規劃在能力表中寫得很清楚:開啟或列舉任意個人 ChatGPT/Claude 聊天是 "No",並註明 "No general host API is provided for this"。

這決定了工具的設計。link_current_chat 是由聊天本身從內部建立連結。它的標籤由聊天自行回報,osFoundry 只拿來做路由,不當成身分。指派給已連結 Claude 聊天的工作,會一直等到使用者打開那個聊天、請它去確認為止。ChatGPT 對已訂閱的聊天提供 MCP Events,osFoundry 的規劃把這視為 host 專屬的功能。

所有跟資料有關的決定都在伺服器端,因為只有它知道專案目前的狀態。所有關於何時行動、要不要詢問使用者的決定,都在 host 端。每次呼叫都檢查,正是讓伺服器在兩邊狀態不同步時仍能保持正確的關鍵。

如果你要自己串接

  • 在 401 的 WWW-Authenticate 裡放 resource_metadata,並在 /.well-known/oauth-protected-resource/<resource path> 提供 RFC 9728 文件。自己手動組 URL 的 client 可能會組錯,有這個 header,每個 client 都不用冒這個險。
  • 先決定登入前哪些東西公開。osFoundry 公開 initialize 和 tools/list,保護 tools/call。如果你的工具說明會洩漏任何跟 tenant 有關的資訊,tools/list 也要保護起來。
  • 每個工具都宣告 scope 和 readOnlyHint。host 可以據此做出確認提示,而 403 insufficient_scope 則讓 host 有明確的東西可以重新要求。
  • token 保持精簡:誰、哪個連線、哪些 scope。workspace、專案成員資格、resource 所屬專案,以及核准者目前的角色,每次呼叫都重新讀取,這樣撤銷和「移到其他專案」在下一次呼叫就會生效。
  • resource 層級的拒絕要用工具結果回傳,不要用 401/403。換一個 token 也解決不了「這則 Note 在專案 B」。
  • 寫入操作要帶 expected_version 和 idempotency key。host 會重試,模型也會重送呼叫。

規格中 MCP 的部分在 MCP 授權頁面。osFoundry 自己的簡短定義在它的 MCP 詞彙條目。

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.