<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: BBT</title>
    <description>The latest articles on DEV Community by BBT (@bbt_c84feb0f535).</description>
    <link>https://dev.to/bbt_c84feb0f535</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4229917%2Fe6da46e1-ca41-48b9-8094-cab6891d1f98.png</url>
      <title>DEV Community: BBT</title>
      <link>https://dev.to/bbt_c84feb0f535</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bbt_c84feb0f535"/>
    <language>en</language>
    <item>
      <title>Claude 自訂連接器如何找到 osFoundry 的 OAuth 伺服器，又為什麼每次呼叫都要檢查存取權限？</title>
      <dc:creator>BBT</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:58:52 +0000</pubDate>
      <link>https://dev.to/bbt-zh/claude-zi-ding-lian-jie-qi-ru-he-zhao-dao-osfoundry-de-oauth-si-fu-qi-you-wei-shi-mo-mei-ci-hu-jiao-du-yao-jian-cha-cun-qu-quan-xian--115l</link>
      <guid>https://dev.to/bbt-zh/claude-zi-ding-lian-jie-qi-ru-he-zhao-dao-osfoundry-de-oauth-si-fu-qi-you-wei-shi-mo-mei-ci-hu-jiao-du-yao-jian-cha-cun-qu-quan-xian--115l</guid>
      <description>&lt;p&gt;遠端 MCP client 在沒有 token 的情況下呼叫 &lt;a href="https://osfoundry.io/zh" rel="noopener noreferrer"&gt;osFoundry&lt;/a&gt; 的工具時，會拿到下面這個回應。這個請求是 2026-11-06 在東京用一台 Mac 透過 curl 送出的：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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\", ..."]}}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  拿到 token 之前，client 看得到什麼？
&lt;/h2&gt;

&lt;p&gt;三個呼叫，三種不同的回應：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;請求&lt;/th&gt;
&lt;th&gt;狀態碼&lt;/th&gt;
&lt;th&gt;回傳內容&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initialize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;協定 &lt;code&gt;2025-06-18&lt;/code&gt;、伺服器 &lt;code&gt;osfoundry 0.1.0&lt;/code&gt;，外加 instructions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;59 個工具，45,368 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WWW-Authenticate&lt;/code&gt; 指向 resource metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;對 endpoint 發 &lt;code&gt;GET&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;405&lt;/td&gt;
&lt;td&gt;GET 不提供 SSE stream&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use list_granted_resources first. Every Note edit must use the exact tab ID.
Local-only data is unavailable to cloud clients.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tools/list&lt;/code&gt; 裡的每個工具都會宣告自己需要的 OAuth scope，以及是否只做讀取：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"edit_record_item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"readOnlyHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destructiveHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"openWorldHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"securitySchemes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oauth2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;]}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;59 個工具中：30 個需要 &lt;code&gt;osfoundry:read&lt;/code&gt; 並標記為 &lt;code&gt;readOnlyHint: true&lt;/code&gt;，26 個需要 &lt;code&gt;osfoundry:write&lt;/code&gt;，3 個需要 &lt;code&gt;osfoundry:run&lt;/code&gt;（&lt;code&gt;request_session_run&lt;/code&gt;、&lt;code&gt;run_graph_session&lt;/code&gt;、&lt;code&gt;cancel_graph_run&lt;/code&gt;）。host 可以根據 &lt;code&gt;readOnlyHint&lt;/code&gt; 決定哪些呼叫要跳出確認提示。&lt;/p&gt;

&lt;h2&gt;
  
  
  401 把它導向哪裡？
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;resource_metadata&lt;/code&gt; 遵循 RFC 9728（OAuth Protected Resource Metadata）。well-known 後綴緊接在 host 後面，再把 resource 路徑接在後綴之後，而不是接在 resource URL 的尾端：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:models"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;如果你自己手刻 client，最先踩到的坑就是這個 404。直接從 header 讀 URL，不要自己組。&lt;/p&gt;

&lt;h2&gt;
  
  
  授權伺服器怎麼描述自己？
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt;（RFC 8414）：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"issuer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/authorize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"registration_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/register"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"grant_types_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"authorization_code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint_auth_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"code_challenge_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"S256"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_response_iss_parameter_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;h2&gt;
  
  
  為什麼光有 scope 不夠？
&lt;/h2&gt;

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

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

&lt;h2&gt;
  
  
  重建版本
&lt;/h2&gt;

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

&lt;p&gt;每個工具執行前都會跑的檢查：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;TOOL_SCOPE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scopes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insufficient_scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;CHECK&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                     &lt;span class="c1"&gt;# design B: trust issue-time state
&lt;/span&gt;        &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;projects_at_issue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CONNECTIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_revoked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROJECT_MEMBERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ CHECK=per_call python3 server.py &amp;amp;  python3 client.py 8767
discovery: first call -&amp;gt; 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;同一個 client 改對 &lt;code&gt;CHECK=token_only&lt;/code&gt; 執行。這時 token 裡帶的是上次 refresh 時的專案清單：&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;h2&gt;
  
  
  每次呼叫都檢查，成本是多少？
&lt;/h2&gt;

&lt;p&gt;在伺服器內用 &lt;code&gt;time.perf_counter_ns()&lt;/code&gt; 包住 &lt;code&gt;access()&lt;/code&gt; 量測，每輪 1,000 次呼叫（500 次允許、500 次拒絕），量測時這台 Mac 同時也在跑其他工作：&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;access() 中位數&lt;/th&gt;
&lt;th&gt;access() p99&lt;/th&gt;
&lt;th&gt;loopback 工具呼叫中位數&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;每次呼叫檢查，6 輪&lt;/td&gt;
&lt;td&gt;1.3–1.6 µs&lt;/td&gt;
&lt;td&gt;6.1–7.0 µs&lt;/td&gt;
&lt;td&gt;0.43–0.78 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;只看 token，3 輪&lt;/td&gt;
&lt;td&gt;0.7–1.6 µs&lt;/td&gt;
&lt;td&gt;3.5–7.7 µs&lt;/td&gt;
&lt;td&gt;0.35–1.36 ms（2 輪）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

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

&lt;h2&gt;
  
  
  為什麼 Claude 能呼叫 osFoundry，osFoundry 卻打不開你的 Claude 聊天？
&lt;/h2&gt;

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

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

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

&lt;h2&gt;
  
  
  如果你要自己串接
&lt;/h2&gt;

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

&lt;p&gt;規格中 MCP 的部分在 &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization" rel="noopener noreferrer"&gt;MCP 授權頁面&lt;/a&gt;。osFoundry 自己的簡短定義在它的 &lt;a href="https://osfoundry.io/zh/glossary/mcp" rel="noopener noreferrer"&gt;MCP 詞彙條目&lt;/a&gt;。&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>claude</category>
      <category>security</category>
    </item>
    <item>
      <title>Claude 커스텀 커넥터는 osFoundry의 OAuth를 어떻게 찾고, 왜 권한을 매번 확인할까</title>
      <dc:creator>BBT</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:52:54 +0000</pubDate>
      <link>https://dev.to/bbt-ko/claude-keoseuteom-keonegteoneun-osfoundryyi-oauthreul-eoddeohge-cajgo-wae-gweonhaneul-maebeon-hwaginhalgga-4dcl</link>
      <guid>https://dev.to/bbt-ko/claude-keoseuteom-keonegteoneun-osfoundryyi-oauthreul-eoddeohge-cajgo-wae-gweonhaneul-maebeon-hwaginhalgga-4dcl</guid>
      <description>&lt;p&gt;토큰 없이 &lt;a href="https://osfoundry.io/ko" rel="noopener noreferrer"&gt;osFoundry&lt;/a&gt;의 원격 MCP 서버에 tool을 호출하면 이런 응답이 온다. 2026-11-06에 도쿄에 있는 Mac에서 curl로 직접 찔러 본 결과다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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\", ..."]}}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude 커스텀 커넥터든 ChatGPT 개발자 모드의 MCP 연결이든, 로그인 화면까지 가는 단서는 이 헤더 한 줄이다. 사용자가 붙여 넣는 건 URL 하나뿐이다. 앞부분은 실서버에 인증 없이 보낸 요청만으로 정리했다. 뒷부분에서는 curl로는 안 보이는 부분, 즉 로그인한 뒤 권한이 어떻게 다뤄지는지를 Python으로 작게 다시 만들어서 측정했다.&lt;/p&gt;

&lt;h2&gt;
  
  
  토큰이 없을 때 클라이언트에 보이는 것
&lt;/h2&gt;

&lt;p&gt;메서드마다 응답이 다르다.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;요청&lt;/th&gt;
&lt;th&gt;상태&lt;/th&gt;
&lt;th&gt;내용&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initialize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;프로토콜 &lt;code&gt;2025-06-18&lt;/code&gt;, 서버 &lt;code&gt;osfoundry 0.1.0&lt;/code&gt;, instructions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;tool 59개, 45,368바이트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;리소스 메타데이터를 가리키는 &lt;code&gt;WWW-Authenticate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;엔드포인트에 &lt;code&gt;GET&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;405&lt;/td&gt;
&lt;td&gt;GET으로 여는 SSE 스트림은 없다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;핸드셰이크와 tool 목록은 공개돼 있다. 토큰이 필요한 건 tool 실행뿐이다. 덕분에 호스트는 로그인 전에도 어떤 tool이 있는지 사용자에게 보여 줄 수 있다. 목록에 사용자 데이터는 없고, &lt;code&gt;initialize&lt;/code&gt;의 instructions도 일반적인 안내문이다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use list_granted_resources first. Every Note edit must use the exact tab ID.
Local-only data is unavailable to cloud clients.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tools/list&lt;/code&gt;에 나오는 tool마다 필요한 OAuth scope와 읽기 전용 여부를 스스로 선언한다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"edit_record_item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"readOnlyHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destructiveHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"openWorldHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"securitySchemes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oauth2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;]}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;59개를 세 보면 &lt;code&gt;osfoundry:read&lt;/code&gt; 30개(전부 &lt;code&gt;readOnlyHint: true&lt;/code&gt;), &lt;code&gt;osfoundry:write&lt;/code&gt; 26개, &lt;code&gt;osfoundry:run&lt;/code&gt; 3개(&lt;code&gt;request_session_run&lt;/code&gt;, &lt;code&gt;run_graph_session&lt;/code&gt;, &lt;code&gt;cancel_graph_run&lt;/code&gt;)다. 호스트는 &lt;code&gt;readOnlyHint&lt;/code&gt;를 보고 어떤 호출에서 승인을 받을지 정할 수 있다.&lt;/p&gt;

&lt;h2&gt;
  
  
  401은 어디를 가리키나
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;resource_metadata&lt;/code&gt;는 RFC 9728(OAuth Protected Resource Metadata) 형식이다. well-known은 호스트 바로 아래에 두고, 그 뒤에 리소스 경로를 이어 붙인다. 리소스 URL 끝에 붙이는 게 아니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /.well-known/oauth-protected-resource/api/v1/external-mcp   200
GET /.well-known/oauth-protected-resource                       200 (같은 문서)
GET /api/v1/external-mcp/.well-known/oauth-protected-resource   404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:models"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;클라이언트를 직접 짜면 제일 먼저 이 404에서 삽질하게 된다. URL은 조립하지 말고 헤더에 적힌 걸 그대로 쓰면 된다.&lt;/p&gt;

&lt;h2&gt;
  
  
  인가 서버 메타데이터
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt;(RFC 8414)는 이렇다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"issuer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/authorize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"registration_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/register"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"grant_types_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"authorization_code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint_auth_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"code_challenge_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"S256"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_response_iss_parameter_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;필드 하나하나가 설계 결정이다.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;registration_endpoint&lt;/code&gt;가 있다. 호스트가 동적 클라이언트 등록(DCR)으로 스스로 등록하니까 사용자가 client ID를 만질 일이 없다.&lt;/li&gt;
&lt;li&gt;토큰 엔드포인트 인증이 &lt;code&gt;none&lt;/code&gt;이다. 호스트는 시크릿이 없는 public client고, 인가 코드를 요청한 쪽과 묶어 주는 건 PKCE(S256)다.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;iss&lt;/code&gt; 파라미터를 지원한다. 인가 서버 여러 개와 붙는 호스트에서 서로 헷갈리지 않게 막아 준다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;authorize 단계에서 브라우저는 osFoundry 동의 화면 &lt;code&gt;https://osfoundry.io/external-assistant-authorize&lt;/code&gt;로 넘어간다. 여기서 사용자는 워크스페이스를 딱 하나 고르고 "I approve connecting this assistant to …"에 체크한다. 이 승인으로 정해지는 건 워크스페이스뿐이다. 새 연결에는 프로젝트가 하나도 없다. 프로젝트는 나중에 앱(열쇠 아이콘 → Connected → Add to project)에서 소유자가 붙인다.&lt;/p&gt;

&lt;h2&gt;
  
  
  scope만으로는 부족한 이유
&lt;/h2&gt;

&lt;p&gt;scope는 토큰을 발급할 때 정해진다. &lt;code&gt;osfoundry:write&lt;/code&gt;는 쓰기 계열 동작을 해도 된다는 뜻일 뿐이다. 어느 노트인지는 모르고, 그 노트가 10분 전에 다른 프로젝트로 옮겨졌다는 것도 모른다. osFoundry 설계 문서에는 호출할 때마다 다시 읽는 항목이 정해져 있다. 연결 상태, 워크스페이스, 프로젝트 소속, 리소스가 지금 속한 프로젝트와 ACL, 채널 소속이나 세션 역할, tool, OAuth scope다. 연결을 승인한 사람이 지금도 그 항목의 편집 권한을 갖고 있어야 한다는 조건도 붙는다. 빼거나 옮긴 변경은 즉시 반영된다고 적혀 있다.&lt;/p&gt;

&lt;p&gt;반대 방식도 있다. 프로젝트 목록을 토큰(또는 토큰을 키로 쓰는 캐시)에 넣어 두고 만료될 때까지 믿는 방식이다. 구현은 이쪽이 편하다. 동작이 얼마나 달라지는지 보려고 둘 다 만들어 봤다.&lt;/p&gt;

&lt;h2&gt;
  
  
  최소 구성으로 다시 만들기
&lt;/h2&gt;

&lt;p&gt;Claude 바이너리는 쓰지 않았다. &lt;code&gt;client.py&lt;/code&gt;가 호스트를, &lt;code&gt;server.py&lt;/code&gt;가 MCP 서버를 흉내 낸다. 둘 다 표준 라이브러리만 썼고 합쳐서 약 280줄이다. Apple M1, Python 3.9.6에서 돌렸다. 서버가 구현한 건 다음과 같다.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;일반 HTTP JSON-RPC 위의 &lt;code&gt;initialize&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt;, &lt;code&gt;tools/call&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;well-known 메타데이터 두 개&lt;/li&gt;
&lt;li&gt;동의 화면 대신 워크스페이스 하나로 자동 승인하는 &lt;code&gt;/authorize&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;PKCE S256을 검증하는 &lt;code&gt;/token&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Connected 탭에서 소유자가 누르는 동작은 &lt;code&gt;/admin/*&lt;/code&gt;로 대신했다.&lt;/p&gt;

&lt;p&gt;tool 실행 전에 매번 거치는 체크는 이게 전부다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;TOOL_SCOPE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scopes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insufficient_scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;CHECK&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                     &lt;span class="c1"&gt;# design B: trust issue-time state
&lt;/span&gt;        &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;projects_at_issue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CONNECTIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_revoked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROJECT_MEMBERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;클라이언트는 토큰 없이 출발한다. 먼저 tool을 호출해 401을 받고, &lt;code&gt;WWW-Authenticate&lt;/code&gt;에서 &lt;code&gt;resource_metadata&lt;/code&gt;를 꺼낸다. 메타데이터 두 개를 가져온 뒤 새 PKCE verifier로 authorize와 token을 통과하고, 시나리오를 차례로 돌린다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ CHECK=per_call python3 server.py &amp;amp;  python3 client.py 8767
discovery: first call -&amp;gt; 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;같은 클라이언트를 &lt;code&gt;CHECK=token_only&lt;/code&gt; 서버에 붙이면 이렇게 나온다. 이쪽 토큰은 마지막으로 갱신할 때의 프로젝트 목록을 들고 있다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1~7은 똑같다. token_only에서도 7이 막히는 건 노트의 소속 프로젝트만은 그때그때 조회하기 때문이다. 낡는 건 토큰에 박아 둔 사실뿐이다. 차이는 8과 9에서 났고, 둘 다 소유자에게 중요한 동작이다. 프로젝트에서 빼고 연결까지 취소했는데 채팅은 계속 읽는다. 이 상태는 토큰이 만료될 때까지 이어지고, 이번 재구현에서는 &lt;code&gt;expires_in: 3600&lt;/code&gt;이었다.&lt;/p&gt;

&lt;p&gt;거부는 두 층으로 나눠서 돌려준다. scope가 모자라면 프로토콜 문제로 보고 403과 &lt;code&gt;error="insufficient_scope"&lt;/code&gt;에 필요한 scope를 붙여 보낸다. 그러면 호스트가 권한을 더 요청할 수 있다. 어느 항목이냐에 걸린 거부는 &lt;code&gt;isError: true&lt;/code&gt;가 붙은 평범한 tool 결과로 보낸다. 이건 재인가해도 해결되지 않으니 호스트에게 다시 해 보라고 할 이유가 없다.&lt;/p&gt;

&lt;h2&gt;
  
  
  매번 체크하는 비용
&lt;/h2&gt;

&lt;p&gt;서버 안에서 &lt;code&gt;access()&lt;/code&gt; 앞뒤를 &lt;code&gt;time.perf_counter_ns()&lt;/code&gt;로 감싸 실행마다 1,000번(허용 500, 거부 500) 쟀다. 측정하는 동안 Mac에서는 다른 작업도 돌고 있었다.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;access() 중앙값&lt;/th&gt;
&lt;th&gt;access() p99&lt;/th&gt;
&lt;th&gt;루프백 tool 호출 중앙값&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;매번 체크 (6회 실행)&lt;/td&gt;
&lt;td&gt;1.3–1.6 µs&lt;/td&gt;
&lt;td&gt;6.1–7.0 µs&lt;/td&gt;
&lt;td&gt;0.43–0.78 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;token_only (3회 실행)&lt;/td&gt;
&lt;td&gt;0.7–1.6 µs&lt;/td&gt;
&lt;td&gt;3.5–7.7 µs&lt;/td&gt;
&lt;td&gt;0.35–1.36 ms (2회분)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;메모리 안에서 끝나는 구성에서는 매번 하는 재확인이 루프백 HTTP의 편차에 묻혔다. 실제 서버는 집합 조회 대신 DB를 보니까 더 무겁다. 다만 그 수치는 이 재구현으로는 잴 수 없어서 적지 않는다. 비교용으로 실서버를 보면, 도쿄에서 왕복 한 번에 약 500~800ms가 걸렸다. urllib으로 매번 TLS를 새로 맺고 각 10회 잰 값이고, 중앙값은 &lt;code&gt;initialize&lt;/code&gt; 790ms, &lt;code&gt;tools/list&lt;/code&gt; 816ms, 401이 나는 &lt;code&gt;tools/call&lt;/code&gt; 565ms, 메타데이터 각 520~530ms였다. 커넥터의 시간을 잡아먹는 건 권한 체크가 아니다.&lt;/p&gt;

&lt;p&gt;디스커버리는 로컬에서 5번 왕복했다(401, 리소스 메타데이터, 인가 서버 메타데이터, authorize, token). 7번은 7.5~13.4ms 안에 끝났고, Mac이 바빴던 한 번만 34ms였다. 실서버에 인증 없이 보낼 수 있는 세 구간을 더하면 콜드 스타트에서 약 1.6초다. authorize는 어차피 사람의 클릭을 기다리니까 여기서는 문제가 안 된다.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude는 호출하는데 osFoundry는 채팅을 못 여는 이유
&lt;/h2&gt;

&lt;p&gt;이 구성에서 호출하는 쪽은 호스트다. 연결을 열고, tool 목록을 받고, 언제 호출할지 정하는 건 호스트고, 서버는 답만 한다. 서버가 호스트의 대화를 붙잡을 방법은 없다. osFoundry 계획 문서의 기능 표에도 개인 ChatGPT/Claude 채팅을 열거나 목록으로 보는 칸은 "No"이고, 이유는 "No general host API is provided for this"라고 적혀 있다.&lt;/p&gt;

&lt;p&gt;tool 설계도 여기에 맞춰져 있다. &lt;code&gt;link_current_chat&lt;/code&gt;은 채팅 안에서 그 채팅이 스스로 링크를 만들게 하는 tool이다. 라벨은 자기 신고고, osFoundry는 이걸 신원 확인이 아니라 라우팅용 메모로 쓴다. 링크된 Claude 채팅에 배정한 작업은 사용자가 그 채팅을 열고 확인해 달라고 할 때까지 기다린다. 구독한 채팅에 전달되는 MCP Events는 ChatGPT 쪽 기능이고, 계획 문서에서도 호스트별 기능으로 다룬다.&lt;/p&gt;

&lt;p&gt;데이터에 관한 판단은 전부 서버가 한다. 프로젝트의 지금 상태를 아는 쪽이 서버뿐이기 때문이다. 언제 움직이고 사용자에게 물어볼지는 호스트가 정한다. 두 쪽의 인식이 어긋나도 매번 체크하면 서버의 판단은 계속 맞는 상태로 남는다.&lt;/p&gt;

&lt;h2&gt;
  
  
  직접 붙인다면 정해 둘 것
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;401의 &lt;code&gt;WWW-Authenticate&lt;/code&gt;에 &lt;code&gt;resource_metadata&lt;/code&gt;를 넣고, RFC 9728 메타데이터를 &lt;code&gt;/.well-known/oauth-protected-resource/&amp;lt;리소스 경로&amp;gt;&lt;/code&gt;에서 내준다. URL을 직접 조립하는 클라이언트는 틀리기 쉬운데, 헤더가 있으면 어떤 클라이언트도 헤매지 않는다.&lt;/li&gt;
&lt;li&gt;로그인 전에 뭘 공개할지 정한다. osFoundry는 &lt;code&gt;initialize&lt;/code&gt;와 &lt;code&gt;tools/list&lt;/code&gt;를 열어 두고 &lt;code&gt;tools/call&lt;/code&gt;만 막았다. tool 설명에서 테넌트 정보가 새어 나간다면 &lt;code&gt;tools/list&lt;/code&gt;도 막는다.&lt;/li&gt;
&lt;li&gt;tool마다 scope와 &lt;code&gt;readOnlyHint&lt;/code&gt;를 선언한다. 호스트는 이걸 보고 승인 절차를 넣을 수 있고, 403 &lt;code&gt;insufficient_scope&lt;/code&gt;를 받으면 무엇을 더 요청해야 할지 안다.&lt;/li&gt;
&lt;li&gt;토큰에는 누구인지, 어느 연결인지, 어떤 scope인지만 넣는다. 워크스페이스, 프로젝트 소속, 리소스의 소속 프로젝트, 승인한 사람의 현재 권한은 매번 다시 읽는다. 그래야 취소도, 다른 프로젝트로 옮기기도 다음 호출부터 바로 반영된다.&lt;/li&gt;
&lt;li&gt;리소스 단위 거부는 401/403이 아니라 tool 결과로 돌려준다. "이 노트는 프로젝트 B에 있다"는 토큰을 바꿔도 그대로다.&lt;/li&gt;
&lt;li&gt;쓰기에는 &lt;code&gt;expected_version&lt;/code&gt;과 멱등 키를 필수로 받는다. 호스트는 재시도하고, 모델은 같은 호출을 다시 보낸다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;스펙의 MCP 쪽 내용은 &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization" rel="noopener noreferrer"&gt;MCP 인가 문서&lt;/a&gt;에 있다. osFoundry가 정리한 짧은 정의는 &lt;a href="https://osfoundry.io/ko/glossary/mcp" rel="noopener noreferrer"&gt;용어집의 MCP 항목&lt;/a&gt;에 있다.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>claude</category>
      <category>security</category>
    </item>
    <item>
      <title>Claude का custom connector osFoundry का OAuth server कैसे ढूंढता है, और हर call पर access क्यों check होता है?</title>
      <dc:creator>BBT</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:52:06 +0000</pubDate>
      <link>https://dev.to/bbt-hi/claude-kaa-custom-connector-osfoundry-kaa-oauth-server-kaise-ddhuunddhtaa-hai-aur-hr-call-pr-access-kyon-g18</link>
      <guid>https://dev.to/bbt-hi/claude-kaa-custom-connector-osfoundry-kaa-oauth-server-kaise-ddhuunddhtaa-hai-aur-hr-call-pr-access-kyon-g18</guid>
      <description>&lt;p&gt;जब कोई remote MCP client बिना token के &lt;a href="https://osfoundry.io/hi" rel="noopener noreferrer"&gt;osFoundry&lt;/a&gt; का कोई tool call करता है, तो उसे यह मिलता है। यह request 2026-11-06 को Tokyo में एक Mac से curl से भेजी गई थी:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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\", ..."]}}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude के custom connector को, या ChatGPT के developer mode वाले MCP connection को, sign-in page ढूंढने के लिए बस यही एक header काफ़ी है। User को सिर्फ़ URL paste करना होता है। नीचे जो कुछ भी है वह live endpoint पर बिना authentication वाली requests से लिया गया है, साथ में Python का एक छोटा rebuild है जो locally चलाया गया। यह rebuild वह हिस्सा मापता है जो curl नहीं दिखा सकता: sign-in के बाद access के साथ क्या होता है।&lt;/p&gt;

&lt;h2&gt;
  
  
  Token मिलने से पहले client को क्या दिखता है?
&lt;/h2&gt;

&lt;p&gt;तीन calls, तीन अलग जवाब:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;क्या वापस आता है&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initialize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;protocol &lt;code&gt;2025-06-18&lt;/code&gt;, server &lt;code&gt;osfoundry 0.1.0&lt;/code&gt;, साथ में instructions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;59 tools, 45,368 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WWW-Authenticate&lt;/code&gt;, जो resource metadata की ओर इशारा करता है&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;endpoint पर &lt;code&gt;GET&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;405&lt;/td&gt;
&lt;td&gt;GET पर कोई SSE stream नहीं&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Handshake और tool catalogue public हैं। सिर्फ़ tool call करने के लिए token चाहिए। इससे host किसी के sign in करने से पहले ही user को दिखा सकता है कि कौन-कौन से tools मौजूद हैं। Catalogue में कोई user data नहीं है, और &lt;code&gt;initialize&lt;/code&gt; में मौजूद &lt;code&gt;instructions&lt;/code&gt; string generic है:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use list_granted_resources first. Every Note edit must use the exact tab ID.
Local-only data is unavailable to cloud clients.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tools/list&lt;/code&gt; का हर tool बताता है कि उसे कौन-सा OAuth scope चाहिए और क्या वह सिर्फ़ read करता है:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"edit_record_item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"readOnlyHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destructiveHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"openWorldHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"securitySchemes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oauth2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;]}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;59 tools में से: 30 को &lt;code&gt;osfoundry:read&lt;/code&gt; चाहिए और वे &lt;code&gt;readOnlyHint: true&lt;/code&gt; से marked हैं, 26 को &lt;code&gt;osfoundry:write&lt;/code&gt; चाहिए, और 3 को &lt;code&gt;osfoundry:run&lt;/code&gt; चाहिए (&lt;code&gt;request_session_run&lt;/code&gt;, &lt;code&gt;run_graph_session&lt;/code&gt;, &lt;code&gt;cancel_graph_run&lt;/code&gt;)। Host &lt;code&gt;readOnlyHint&lt;/code&gt; के आधार पर तय कर सकता है कि किन calls पर approval prompt दिखाना है।&lt;/p&gt;

&lt;h2&gt;
  
  
  401 उसे कहां भेजता है?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;resource_metadata&lt;/code&gt; RFC 9728 (OAuth Protected Resource Metadata) को follow करता है। Well-known suffix host के ठीक बाद आता है, और resource path उसके पीछे जुड़ता है। इसे resource URL के आख़िर में नहीं जोड़ा जाता:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:models"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;अगर आप client हाथ से बना रहे हैं, तो सबसे पहली गलती आप यही 404 वाली करेंगे। URL ख़ुद बनाने की बजाय header से पढ़िए।&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorization server अपने बारे में क्या बताता है?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt; (RFC 8414):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"issuer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/authorize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"registration_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/register"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"grant_types_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"authorization_code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint_auth_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"code_challenge_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"S256"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_response_iss_parameter_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;इसे फ़ैसलों की एक chain की तरह पढ़िए। इसमें &lt;code&gt;registration_endpoint&lt;/code&gt; है, इसलिए host ख़ुद को register कर सकता है (dynamic client registration) और user को कभी client ID से जूझना नहीं पड़ता। Token auth &lt;code&gt;none&lt;/code&gt; है, यानी host एक public client है जिसके पास client secret नहीं है, और S256 वाला PKCE ही code को उस client से बांधता है जिसने उसे मांगा था। Authorization response में &lt;code&gt;iss&lt;/code&gt; उस गड़बड़ी से बचाता है जब कोई host कई authorization servers से बात कर रहा हो।&lt;/p&gt;

&lt;p&gt;Authorize step browser को osFoundry के consent page &lt;code&gt;https://osfoundry.io/external-assistant-authorize&lt;/code&gt; पर भेजता है। वहां user ठीक एक workspace चुनता है और "I approve connecting this assistant to …" (मैं इस assistant को … से जोड़ने की अनुमति देता हूं) पर tick करता है। Approval में सिर्फ़ एक workspace का नाम होता है, और कुछ नहीं। नए connection में कोई project नहीं होता। Owner उन्हें बाद में app में जोड़ता है (key icon → Connected → Add to project)।&lt;/p&gt;

&lt;h2&gt;
  
  
  सिर्फ़ scope काफ़ी क्यों नहीं है?
&lt;/h2&gt;

&lt;p&gt;Scope उसी समय तय हो जाता है जब token mint होता है। &lt;code&gt;osfoundry:write&lt;/code&gt; बताता है कि connection write वाले actions कर सकता है। यह नहीं बताता कि कौन-सा Note, और इसे यह पता भी नहीं हो सकता कि वह Note दस मिनट पहले किसी दूसरे project में चला गया। osFoundry के design notes में लिखा है कि हर call पर क्या दोबारा पढ़ा जाता है: connection status, workspace, project membership, resource का मौजूदा project और ACL, channel membership या session role, tool, और OAuth scope। जिस व्यक्ति ने connection approve किया था, उसके पास उस item पर अभी भी edit rights होने चाहिए। हटाना या reassign करना "तुरंत लागू होता है"।&lt;/p&gt;

&lt;p&gt;दूसरा रास्ता यह है कि project list को token में डाल दिया जाए, या token को key बनाकर cache में रख दिया जाए, और expiry तक उस पर भरोसा किया जाए। यह लिखने में सस्ता है। नीचे वाला rebuild दोनों को implement करता है ताकि behaviour का फ़र्क़ मापा जा सके।&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebuild
&lt;/h2&gt;

&lt;p&gt;इसमें कोई Claude binary शामिल नहीं है। &lt;code&gt;client.py&lt;/code&gt; host का रोल निभाता है और &lt;code&gt;server.py&lt;/code&gt; MCP server का। दोनों सिर्फ़ standard library इस्तेमाल करते हैं, मिलाकर लगभग 280 lines, और Apple M1 पर Python 3.9.6 में चलाए गए। Server plain HTTP JSON-RPC पर &lt;code&gt;initialize&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt; और &lt;code&gt;tools/call&lt;/code&gt; implement करता है, साथ में दोनों well-known documents, &lt;code&gt;/authorize&lt;/code&gt; (consent page एक workspace के लिए अपने आप approve हो जाता है), और S256 PKCE के साथ &lt;code&gt;/token&lt;/code&gt;। कुछ &lt;code&gt;/admin/*&lt;/code&gt; routes Connected tab में owner के clicks की जगह लेते हैं।&lt;/p&gt;

&lt;p&gt;हर tool से पहले चलने वाला check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;TOOL_SCOPE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scopes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insufficient_scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;CHECK&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                     &lt;span class="c1"&gt;# design B: trust issue-time state
&lt;/span&gt;        &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;projects_at_issue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CONNECTIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_revoked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROJECT_MEMBERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Client बिना token के शुरू होता है। वह एक tool call करता है, 401 लेता है, &lt;code&gt;WWW-Authenticate&lt;/code&gt; से &lt;code&gt;resource_metadata&lt;/code&gt; parse करता है, दोनों metadata documents fetch करता है, नए PKCE verifier के साथ authorize और token चलाता है, और फिर scenario से गुज़रता है:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ CHECK=per_call python3 server.py &amp;amp;  python3 client.py 8767
discovery: first call -&amp;gt; 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;वही client &lt;code&gt;CHECK=token_only&lt;/code&gt; के ख़िलाफ़। यहां token में उसके पिछले refresh के समय की project list होती है:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 1–7 एक जैसे हैं। Token-only design में भी step 7 deny होता है, क्योंकि note का project live lookup किया जाता है। Token-only design सिर्फ़ उन्हीं बातों पर पुराना पड़ता है जो token के अंदर रखी हैं। Owner के लिए असली मायने steps 8 और 9 के हैं: उन्होंने project हटाया, फिर connection revoke किया, और chat फिर भी पढ़ती रही। Token-only design में यह token expire होने तक चलता है, जो इस rebuild में &lt;code&gt;expires_in: 3600&lt;/code&gt; है।&lt;/p&gt;

&lt;p&gt;Denials दो layers में बंटे हैं। Scope न होना protocol level की failure है: &lt;code&gt;error="insufficient_scope"&lt;/code&gt; के साथ 403, और ज़रूरी scope &lt;code&gt;WWW-Authenticate&lt;/code&gt; में, ताकि host ज़्यादा scope मांग सके। कौन-सा item शामिल है, उससे जुड़ी हर चीज़ एक सामान्य tool result है जिसमें &lt;code&gt;isError: true&lt;/code&gt; होता है। दोबारा authorise करने से वह ठीक नहीं होगा, इसलिए host को retry करने को कहने का कोई मतलब नहीं।&lt;/p&gt;

&lt;h2&gt;
  
  
  हर call पर check की क़ीमत क्या है?
&lt;/h2&gt;

&lt;p&gt;Server के अंदर &lt;code&gt;access()&lt;/code&gt; के चारों ओर &lt;code&gt;time.perf_counter_ns()&lt;/code&gt; से मापा गया, हर run में 1,000 calls (500 allowed, 500 denied), एक ऐसे Mac पर जिस पर दूसरे jobs भी चल रहे थे:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;access() median&lt;/th&gt;
&lt;th&gt;access() p99&lt;/th&gt;
&lt;th&gt;loopback tool call, median&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;हर call पर check, 6 runs&lt;/td&gt;
&lt;td&gt;1.3–1.6 µs&lt;/td&gt;
&lt;td&gt;6.1–7.0 µs&lt;/td&gt;
&lt;td&gt;0.43–0.78 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;सिर्फ़ token, 3 runs&lt;/td&gt;
&lt;td&gt;0.7–1.6 µs&lt;/td&gt;
&lt;td&gt;3.5–7.7 µs&lt;/td&gt;
&lt;td&gt;0.35–1.36 ms (2 runs)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Memory में यह recheck loopback HTTP call के noise में खो जाता है। असली server set membership की जगह database lookups करता है, इसलिए उसकी क़ीमत ज़्यादा होती है। यह rebuild उसे माप नहीं सकता, इसलिए उसके लिए कोई number नहीं दिया गया है। अंदाज़े के लिए, Tokyo से live endpoint तक network leg urllib में हर बार नए TLS connection के साथ लगभग 500 ms से 800 ms प्रति request है (हर एक के लिए n=10: &lt;code&gt;initialize&lt;/code&gt; median 790 ms, &lt;code&gt;tools/list&lt;/code&gt; 816 ms, 401 वाला &lt;code&gt;tools/call&lt;/code&gt; 565 ms, हर metadata document लगभग 520–530 ms)। Connector का समय access check में नहीं जाता।&lt;/p&gt;

&lt;p&gt;Discovery ख़ुद locally 5 HTTP round trips थी: 401, resource metadata, server metadata, authorize, token। सात runs में इसमें 7.5–13.4 ms लगे, और एक बार Mac busy होने पर 34 ms। Live server के ख़िलाफ़, बिना credentials के किए जा सकने वाले तीन GET/POST legs cold start से मिलाकर लगभग 1.6 s लेते हैं। Authorize leg तो वैसे भी किसी इंसान का इंतज़ार करता है।&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude osFoundry को call कर सकता है, पर osFoundry आपकी Claude chats क्यों नहीं खोल सकता?
&lt;/h2&gt;

&lt;p&gt;इस setup में caller host है। वही connection खोलता है, tools list करता है और तय करता है कि कब कौन-सा tool call करना है। Server सिर्फ़ जवाब देता है। इस setup में ऐसा कुछ नहीं है जो server को host की conversations तक पहुंच दे। osFoundry का plan यह बात अपनी capability table में कहता है: मनचाही personal ChatGPT/Claude chats खोलना या उनकी list निकालना "No" है, साथ में "No general host API is provided for this"।&lt;/p&gt;

&lt;p&gt;इसी से tools का design तय होता है। &lt;code&gt;link_current_chat&lt;/code&gt; में chat ख़ुद अपने अंदर से link बनाती है। उसका label chat ख़ुद बताती है, और osFoundry उसे routing के लिए इस्तेमाल करता है, identity के तौर पर नहीं। किसी linked Claude chat को दिया गया काम तब तक इंतज़ार करता है जब तक user वह chat खोलकर उससे check करने को न कहे। ChatGPT में subscribed chats के लिए MCP Events हैं, और osFoundry का plan इसे host-specific मानता है।&lt;/p&gt;

&lt;p&gt;Data से जुड़े सारे फ़ैसले server side के पास हैं, क्योंकि सिर्फ़ उसे ही किसी project की मौजूदा स्थिति पता होती है। कब action लेना है और user से पूछना है या नहीं, ये सारे फ़ैसले host side के पास हैं। हर call पर होने वाला check ही server को सही बनाए रखता है जब ये दोनों sides एक-दूसरे से अलग हो जाती हैं।&lt;/p&gt;

&lt;h2&gt;
  
  
  अगर आप इसे ख़ुद wire up करें
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;401 के &lt;code&gt;WWW-Authenticate&lt;/code&gt; में &lt;code&gt;resource_metadata&lt;/code&gt; डालिए और RFC 9728 को &lt;code&gt;/.well-known/oauth-protected-resource/&amp;lt;resource path&amp;gt;&lt;/code&gt; पर serve कीजिए। जो client URL हाथ से बनाता है, वह गलती कर सकता है। Header हर client को इससे बचा लेता है।&lt;/li&gt;
&lt;li&gt;Sign-in से पहले क्या public है, यह तय कीजिए। osFoundry &lt;code&gt;initialize&lt;/code&gt; और &lt;code&gt;tools/list&lt;/code&gt; खुला रखता है और &lt;code&gt;tools/call&lt;/code&gt; को guard करता है। अगर आपके tool descriptions tenants के बारे में कुछ भी leak करते हैं, तो &lt;code&gt;tools/list&lt;/code&gt; को भी guard कीजिए।&lt;/li&gt;
&lt;li&gt;हर tool के लिए एक scope और &lt;code&gt;readOnlyHint&lt;/code&gt; declare कीजिए। Hosts इनसे approval prompts बना सकते हैं, और 403 &lt;code&gt;insufficient_scope&lt;/code&gt; host को दोबारा मांगने के लिए कुछ ठोस देता है।&lt;/li&gt;
&lt;li&gt;Token छोटा रखिए: कौन, कौन-सा connection, कौन-से scopes। Workspace, project membership, resource का project और approve करने वाले का मौजूदा role हर call पर दोबारा पढ़िए, ताकि revoke और "दूसरे project में move करना" अगली ही call पर असर करें।&lt;/li&gt;
&lt;li&gt;Resource denials को 401/403 की बजाय tool results के रूप में लौटाइए। दूसरा token "यह Note project B में है" को ठीक नहीं करेगा।&lt;/li&gt;
&lt;li&gt;Writes के साथ &lt;code&gt;expected_version&lt;/code&gt; और एक idempotency key भेजिए। Hosts retry करते हैं, और models calls दोबारा भेजते हैं।&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Spec का MCP वाला हिस्सा &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization" rel="noopener noreferrer"&gt;MCP authorization page&lt;/a&gt; पर है। osFoundry की अपनी छोटी परिभाषा उसकी &lt;a href="https://osfoundry.io/hi/glossary/mcp" rel="noopener noreferrer"&gt;MCP वाली glossary entry&lt;/a&gt; में है।&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>claude</category>
      <category>security</category>
    </item>
    <item>
      <title>Bagaimana custom connector Claude menemukan server OAuth osFoundry, dan kenapa akses dicek di setiap panggilan?</title>
      <dc:creator>BBT</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:45:59 +0000</pubDate>
      <link>https://dev.to/bbt-id/bagaimana-custom-connector-claude-menemukan-server-oauth-osfoundry-dan-kenapa-akses-dicek-di-4084</link>
      <guid>https://dev.to/bbt-id/bagaimana-custom-connector-claude-menemukan-server-oauth-osfoundry-dan-kenapa-akses-dicek-di-4084</guid>
      <description>&lt;p&gt;Ini yang diterima client MCP remote saat memanggil tool &lt;a href="https://osfoundry.io/id" rel="noopener noreferrer"&gt;osFoundry&lt;/a&gt; tanpa token. Request-nya dikirim dengan curl dari sebuah Mac di Tokyo pada 2026-11-06:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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\", ..."]}}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Satu header itu sudah cukup bagi custom connector Claude, atau koneksi MCP di developer mode ChatGPT, untuk menemukan halaman login. Pengguna cukup menempelkan URL-nya. Semua yang ada di bawah berasal dari request tanpa autentikasi ke endpoint live, ditambah rebuild kecil dalam Python yang dijalankan secara lokal untuk mengukur bagian yang tidak bisa diperlihatkan curl: apa yang terjadi pada akses setelah login.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apa yang dilihat client sebelum punya token?
&lt;/h2&gt;

&lt;p&gt;Tiga panggilan, tiga jawaban berbeda:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Yang dikembalikan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initialize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;protokol &lt;code&gt;2025-06-18&lt;/code&gt;, server &lt;code&gt;osfoundry 0.1.0&lt;/code&gt;, plus instruksi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;59 tool, 45,368 byte&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WWW-Authenticate&lt;/code&gt; yang menunjuk ke resource metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET&lt;/code&gt; ke endpoint&lt;/td&gt;
&lt;td&gt;405&lt;/td&gt;
&lt;td&gt;tidak ada stream SSE lewat GET&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Handshake dan katalog tool bersifat publik. Hanya pemanggilan tool yang butuh token. Dengan begitu host bisa menunjukkan ke pengguna tool apa saja yang ada sebelum siapa pun login. Tidak ada data pengguna di katalog, dan string &lt;code&gt;instructions&lt;/code&gt; di &lt;code&gt;initialize&lt;/code&gt; bersifat umum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use list_granted_resources first. Every Note edit must use the exact tab ID.
Local-only data is unavailable to cloud clients.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setiap tool di &lt;code&gt;tools/list&lt;/code&gt; mendeklarasikan scope OAuth yang dibutuhkannya dan apakah tool itu hanya membaca:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"edit_record_item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"readOnlyHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destructiveHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"openWorldHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"securitySchemes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oauth2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;]}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dari 59 tool: 30 butuh &lt;code&gt;osfoundry:read&lt;/code&gt; dan ditandai &lt;code&gt;readOnlyHint: true&lt;/code&gt;, 26 butuh &lt;code&gt;osfoundry:write&lt;/code&gt;, dan 3 butuh &lt;code&gt;osfoundry:run&lt;/code&gt; (&lt;code&gt;request_session_run&lt;/code&gt;, &lt;code&gt;run_graph_session&lt;/code&gt;, &lt;code&gt;cancel_graph_run&lt;/code&gt;). Host bisa memakai &lt;code&gt;readOnlyHint&lt;/code&gt; untuk menentukan panggilan mana yang perlu prompt persetujuan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ke mana 401 itu mengarahkannya?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;resource_metadata&lt;/code&gt; mengikuti RFC 9728 (OAuth Protected Resource Metadata). Suffix well-known diletakkan tepat setelah host, lalu path resource ditambahkan di belakangnya. Suffix itu tidak ditempel di ujung URL resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:models"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kalau kamu membuat client sendiri, 404 itu adalah kesalahan pertama yang akan kamu buat. Ambil URL dari header, jangan menyusunnya sendiri.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apa kata authorization server tentang dirinya?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt; (RFC 8414):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"issuer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/authorize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"registration_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/register"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"grant_types_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"authorization_code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint_auth_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"code_challenge_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"S256"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_response_iss_parameter_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Baca ini sebagai rangkaian keputusan. Ada &lt;code&gt;registration_endpoint&lt;/code&gt;, jadi host bisa mendaftarkan dirinya sendiri (dynamic client registration) dan pengguna tidak pernah berurusan dengan client ID. Autentikasi token-nya &lt;code&gt;none&lt;/code&gt;, jadi host adalah public client tanpa client secret, dan PKCE dengan S256 yang mengikat code ke client yang memintanya. &lt;code&gt;iss&lt;/code&gt; di authorization response mencegah salah tukar saat sebuah host berbicara dengan beberapa authorization server.&lt;/p&gt;

&lt;p&gt;Langkah authorize mengarahkan browser ke halaman consent osFoundry, &lt;code&gt;https://osfoundry.io/external-assistant-authorize&lt;/code&gt;. Di sana pengguna memilih tepat satu workspace dan mencentang "I approve connecting this assistant to …" (saya menyetujui asisten ini dihubungkan ke …). Persetujuan itu hanya menyebut satu workspace, tidak lebih. Koneksi baru belum punya project. Owner menambahkannya belakangan di aplikasi (key icon → Connected → Add to project).&lt;/p&gt;

&lt;h2&gt;
  
  
  Kenapa scope saja tidak cukup?
&lt;/h2&gt;

&lt;p&gt;Scope ditetapkan saat token diterbitkan. &lt;code&gt;osfoundry:write&lt;/code&gt; menyatakan bahwa koneksi boleh melakukan aksi tulis. Scope itu tidak menyebut Note yang mana, dan tidak bisa tahu bahwa Note tersebut dipindahkan ke project lain sepuluh menit lalu. Catatan desain osFoundry mendaftar apa saja yang dibaca ulang di setiap panggilan: status koneksi, workspace, keanggotaan project, project dan ACL resource saat ini, keanggotaan channel atau role di session, tool-nya, dan scope OAuth. Orang yang menyetujui koneksi juga harus masih punya hak edit atas item tersebut. Penghapusan atau pemindahan "langsung berlaku".&lt;/p&gt;

&lt;p&gt;Alternatifnya adalah memasukkan daftar project ke dalam token, atau ke cache yang di-key oleh token, lalu memercayainya sampai kedaluwarsa. Itu lebih murah untuk ditulis. Rebuild di bawah mengimplementasikan keduanya supaya perbedaan perilakunya bisa diukur.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebuild-nya
&lt;/h2&gt;

&lt;p&gt;Tidak ada binary Claude yang terlibat. &lt;code&gt;client.py&lt;/code&gt; berperan sebagai host dan &lt;code&gt;server.py&lt;/code&gt; sebagai server MCP. Keduanya hanya memakai standard library, sekitar 280 baris jika digabung, dan dijalankan di Python 3.9.6 pada Apple M1. Server-nya mengimplementasikan &lt;code&gt;initialize&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt;, dan &lt;code&gt;tools/call&lt;/code&gt; lewat JSON-RPC di atas HTTP biasa, dua dokumen well-known, &lt;code&gt;/authorize&lt;/code&gt; (halaman consent otomatis disetujui untuk satu workspace), dan &lt;code&gt;/token&lt;/code&gt; dengan PKCE S256. Beberapa route &lt;code&gt;/admin/*&lt;/code&gt; menggantikan klik owner di tab Connected.&lt;/p&gt;

&lt;p&gt;Pengecekan yang berjalan sebelum setiap tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;TOOL_SCOPE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scopes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insufficient_scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;CHECK&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                     &lt;span class="c1"&gt;# design B: trust issue-time state
&lt;/span&gt;        &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;projects_at_issue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CONNECTIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_revoked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROJECT_MEMBERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Client mulai tanpa token. Ia memanggil sebuah tool, menerima 401, mem-parse &lt;code&gt;resource_metadata&lt;/code&gt; dari &lt;code&gt;WWW-Authenticate&lt;/code&gt;, mengambil kedua dokumen metadata, menjalankan authorize dan token dengan PKCE verifier baru, lalu menjalankan skenarionya:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ CHECK=per_call python3 server.py &amp;amp;  python3 client.py 8767
discovery: first call -&amp;gt; 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Client yang sama terhadap &lt;code&gt;CHECK=token_only&lt;/code&gt;. Di sini token membawa daftar project dari refresh terakhirnya:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Langkah 1–7 identik. Langkah 7 tetap ditolak di desain token-only karena project milik note dicek secara live. Desain token-only hanya jadi basi untuk fakta yang disimpan di dalam token. Langkah 8 dan 9 adalah yang penting bagi owner: mereka sudah menghapus project, lalu mencabut koneksi, tapi chat tetap bisa membaca. Di desain token-only, kondisi itu bertahan sampai token kedaluwarsa, yaitu &lt;code&gt;expires_in: 3600&lt;/code&gt; di rebuild ini.&lt;/p&gt;

&lt;p&gt;Penolakan terbagi di dua lapisan. Scope yang kurang adalah kegagalan di level protokol: 403 dengan &lt;code&gt;error="insufficient_scope"&lt;/code&gt; dan scope yang dibutuhkan di &lt;code&gt;WWW-Authenticate&lt;/code&gt;, sehingga host bisa meminta lebih. Semua yang berkaitan dengan item mana yang terlibat adalah hasil tool biasa dengan &lt;code&gt;isError: true&lt;/code&gt;. Otorisasi ulang tidak akan memperbaikinya, jadi tidak ada gunanya menyuruh host mencoba lagi.&lt;/p&gt;

&lt;h2&gt;
  
  
  Berapa biaya pengecekan di setiap panggilan?
&lt;/h2&gt;

&lt;p&gt;Diukur dengan &lt;code&gt;time.perf_counter_ns()&lt;/code&gt; di sekitar &lt;code&gt;access()&lt;/code&gt; di dalam server, 1,000 panggilan per run (500 diizinkan, 500 ditolak), di Mac yang juga sedang menjalankan job lain:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;median access()&lt;/th&gt;
&lt;th&gt;p99 access()&lt;/th&gt;
&lt;th&gt;median tool call loopback&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;cek per panggilan, 6 run&lt;/td&gt;
&lt;td&gt;1.3–1.6 µs&lt;/td&gt;
&lt;td&gt;6.1–7.0 µs&lt;/td&gt;
&lt;td&gt;0.43–0.78 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;token-only, 3 run&lt;/td&gt;
&lt;td&gt;0.7–1.6 µs&lt;/td&gt;
&lt;td&gt;3.5–7.7 µs&lt;/td&gt;
&lt;td&gt;0.35–1.36 ms (2 run)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Di memori, pengecekan ulang tenggelam di noise panggilan HTTP loopback. Server sungguhan melakukan query database, bukan sekadar cek keanggotaan set, jadi biayanya lebih tinggi. Rebuild ini tidak bisa mengukurnya, jadi tidak ada angka yang diberikan. Sebagai gambaran, perjalanan jaringan ke endpoint live dari Tokyo sekitar 500 ms sampai 800 ms per request dengan urllib dan koneksi TLS baru setiap kali (masing-masing n=10: median &lt;code&gt;initialize&lt;/code&gt; 790 ms, &lt;code&gt;tools/list&lt;/code&gt; 816 ms, &lt;code&gt;tools/call&lt;/code&gt; yang 401 565 ms, tiap dokumen metadata sekitar 520–530 ms). Waktu sebuah connector tidak habis di pengecekan akses.&lt;/p&gt;

&lt;p&gt;Discovery-nya sendiri butuh 5 round trip HTTP secara lokal: 401, resource metadata, server metadata, authorize, token. Itu memakan 7.5–13.4 ms dalam tujuh run dan 34 ms sekali saat Mac sedang sibuk. Terhadap server live, tiga leg GET/POST yang bisa dilakukan tanpa kredensial totalnya kira-kira 1.6 s dari cold start. Leg authorize toh menunggu manusia.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kenapa Claude bisa memanggil osFoundry, tapi osFoundry tidak bisa membuka chat Claude kamu?
&lt;/h2&gt;

&lt;p&gt;Di setup ini, host-lah yang memanggil. Host membuka koneksi, mengambil daftar tool, dan memutuskan kapan memanggil salah satunya. Server menjawab. Tidak ada apa pun di setup ini yang memberi server akses ke percakapan milik host. Rencana osFoundry menuliskannya di tabel kapabilitasnya: membuka atau mendaftar chat pribadi ChatGPT/Claude sembarang adalah "No", dengan keterangan "No general host API is provided for this".&lt;/p&gt;

&lt;p&gt;Hal itu membentuk tool-nya. Dengan &lt;code&gt;link_current_chat&lt;/code&gt;, chat itu sendiri yang membuat link dari dalam chat. Label-nya dilaporkan sendiri oleh chat, dan osFoundry memakainya untuk routing, bukan sebagai identitas. Pekerjaan yang ditugaskan ke chat Claude yang ter-link menunggu sampai pengguna membuka chat itu dan memintanya untuk mengecek. ChatGPT punya MCP Events untuk chat yang berlangganan, dan rencana osFoundry memperlakukannya sebagai fitur khusus host tersebut.&lt;/p&gt;

&lt;p&gt;Sisi server memegang semua keputusan soal data, karena hanya sisi itu yang tahu kondisi terkini sebuah project. Sisi host memegang semua keputusan soal kapan bertindak dan apakah perlu bertanya ke pengguna. Pengecekan per panggilan inilah yang membuat server tetap benar ketika kedua sisi itu tidak lagi sinkron.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kalau kamu memasangnya sendiri
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Taruh &lt;code&gt;resource_metadata&lt;/code&gt; di &lt;code&gt;WWW-Authenticate&lt;/code&gt; milik respons 401 dan sajikan RFC 9728 di &lt;code&gt;/.well-known/oauth-protected-resource/&amp;lt;resource path&amp;gt;&lt;/code&gt;. Client yang menyusun URL sendiri bisa salah. Header itu menyelamatkan setiap client dari kesalahan tersebut.&lt;/li&gt;
&lt;li&gt;Tentukan apa yang publik sebelum login. osFoundry membuka &lt;code&gt;initialize&lt;/code&gt; dan &lt;code&gt;tools/list&lt;/code&gt; dan menjaga &lt;code&gt;tools/call&lt;/code&gt;. Kalau deskripsi tool kamu membocorkan sesuatu tentang tenant, jaga juga &lt;code&gt;tools/list&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Deklarasikan scope dan &lt;code&gt;readOnlyHint&lt;/code&gt; per tool. Host bisa membangun prompt persetujuan dari situ, dan 403 &lt;code&gt;insufficient_scope&lt;/code&gt; memberi host sesuatu yang konkret untuk diminta ulang.&lt;/li&gt;
&lt;li&gt;Buat token tetap kecil: siapa, koneksi mana, scope apa. Baca ulang workspace, keanggotaan project, project milik resource, dan role terkini orang yang menyetujui di setiap panggilan, supaya pencabutan dan "pindah ke project lain" langsung berlaku di panggilan berikutnya.&lt;/li&gt;
&lt;li&gt;Kembalikan penolakan resource sebagai hasil tool, bukan sebagai 401/403. Token yang berbeda tidak akan memperbaiki "Note ini ada di project B".&lt;/li&gt;
&lt;li&gt;Buat operasi tulis membawa &lt;code&gt;expected_version&lt;/code&gt; dan idempotency key. Host melakukan retry, dan model mengulang panggilan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sisi MCP dari spesifikasinya ada di &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization" rel="noopener noreferrer"&gt;halaman otorisasi MCP&lt;/a&gt;. Definisi singkat versi osFoundry ada di &lt;a href="https://osfoundry.io/id/glossary/mcp" rel="noopener noreferrer"&gt;entri glosariumnya tentang MCP&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>claude</category>
      <category>keamanan</category>
    </item>
    <item>
      <title>Come fa un connettore personalizzato di Claude a trovare il server OAuth di osFoundry, e perché l'accesso viene controllato a ogni chiamata?</title>
      <dc:creator>BBT</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:45:13 +0000</pubDate>
      <link>https://dev.to/bbt-it/come-fa-un-connettore-personalizzato-di-claude-a-trovare-il-server-oauth-di-osfoundry-e-perche-2ip5</link>
      <guid>https://dev.to/bbt-it/come-fa-un-connettore-personalizzato-di-claude-a-trovare-il-server-oauth-di-osfoundry-e-perche-2ip5</guid>
      <description>&lt;p&gt;Ecco cosa riceve un client MCP remoto quando chiama uno strumento di &lt;a href="https://osfoundry.io/it" rel="noopener noreferrer"&gt;osFoundry&lt;/a&gt; senza token. La richiesta è stata inviata con curl da un Mac a Tokyo il 2026-11-06:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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\", ..."]}}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quell'unico header basta al connettore personalizzato di Claude, o alla connessione MCP della modalità sviluppatore di ChatGPT, per trovare la pagina di accesso. L'utente incolla solo l'URL. Tutto quello che segue viene da richieste non autenticate all'endpoint in produzione, più una piccola ricostruzione in Python, eseguita in locale, che misura quello che un curl non può mostrare: cosa succede all'accesso dopo il login.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cosa vede il client prima di avere un token?
&lt;/h2&gt;

&lt;p&gt;Tre chiamate, tre risposte diverse:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Richiesta&lt;/th&gt;
&lt;th&gt;Stato&lt;/th&gt;
&lt;th&gt;Cosa torna indietro&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initialize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;protocollo &lt;code&gt;2025-06-18&lt;/code&gt;, server &lt;code&gt;osfoundry 0.1.0&lt;/code&gt;, più le istruzioni&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;59 strumenti, 45,368 byte&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WWW-Authenticate&lt;/code&gt; che punta ai metadati della risorsa&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET&lt;/code&gt; sull'endpoint&lt;/td&gt;
&lt;td&gt;405&lt;/td&gt;
&lt;td&gt;nessuno stream SSE in GET&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;L'handshake e il catalogo degli strumenti sono pubblici. Solo la chiamata a uno strumento richiede un token. Così l'host può mostrare all'utente quali strumenti esistono prima che qualcuno faccia login. Nel catalogo non ci sono dati utente, e la stringa &lt;code&gt;instructions&lt;/code&gt; in &lt;code&gt;initialize&lt;/code&gt; è generica:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use list_granted_resources first. Every Note edit must use the exact tab ID.
Local-only data is unavailable to cloud clients.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ogni strumento in &lt;code&gt;tools/list&lt;/code&gt; dichiara lo scope OAuth che gli serve e se fa solo letture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"edit_record_item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"readOnlyHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destructiveHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"openWorldHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"securitySchemes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oauth2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;]}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sui 59 strumenti: 30 richiedono &lt;code&gt;osfoundry:read&lt;/code&gt; e sono marcati &lt;code&gt;readOnlyHint: true&lt;/code&gt;, 26 richiedono &lt;code&gt;osfoundry:write&lt;/code&gt; e 3 richiedono &lt;code&gt;osfoundry:run&lt;/code&gt; (&lt;code&gt;request_session_run&lt;/code&gt;, &lt;code&gt;run_graph_session&lt;/code&gt;, &lt;code&gt;cancel_graph_run&lt;/code&gt;). Un host può usare &lt;code&gt;readOnlyHint&lt;/code&gt; per decidere quali chiamate richiedono una conferma.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dove lo manda il 401?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;resource_metadata&lt;/code&gt; segue la RFC 9728 (OAuth Protected Resource Metadata). Il suffisso well-known va subito dopo l'host, con il percorso della risorsa aggiunto in coda. Non va aggiunto in fondo all'URL della risorsa:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:models"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Se scrivi un client a mano, quel 404 è il primo errore che farai. Leggi l'URL dall'header invece di costruirlo tu.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cosa dice di sé il server di autorizzazione?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt; (RFC 8414):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"issuer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/authorize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"registration_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/register"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"grant_types_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"authorization_code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint_auth_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"code_challenge_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"S256"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_response_iss_parameter_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leggilo come una catena di decisioni. C'è un &lt;code&gt;registration_endpoint&lt;/code&gt;, quindi l'host può registrarsi da solo (registrazione dinamica del client) e l'utente non ha mai a che fare con un client ID. L'autenticazione al token endpoint è &lt;code&gt;none&lt;/code&gt;, quindi l'host è un client pubblico senza client secret, ed è PKCE con S256 a legare il codice al client che l'ha richiesto. &lt;code&gt;iss&lt;/code&gt; nella risposta di autorizzazione evita scambi quando un host parla con più server di autorizzazione.&lt;/p&gt;

&lt;p&gt;Il passo di authorize porta il browser sulla pagina di consenso di osFoundry, &lt;code&gt;https://osfoundry.io/external-assistant-authorize&lt;/code&gt;. Lì l'utente sceglie esattamente un workspace e spunta "I approve connecting this assistant to …" (approvo il collegamento di questo assistente a …). L'approvazione indica un workspace e nient'altro. Una connessione nuova non ha progetti. Il proprietario li aggiunge dopo nell'app (key icon → Connected → Add to project).&lt;/p&gt;

&lt;h2&gt;
  
  
  Perché lo scope non basta?
&lt;/h2&gt;

&lt;p&gt;Uno scope viene fissato quando il token viene emesso. &lt;code&gt;osfoundry:write&lt;/code&gt; dice che la connessione può eseguire azioni di scrittura. Non dice quale Note, e non può sapere che quella Note è stata spostata in un altro progetto dieci minuti fa. Le note di design di osFoundry elencano cosa viene riletto a ogni chiamata: stato della connessione, workspace, appartenenza al progetto, progetto attuale e ACL della risorsa, appartenenza al canale o ruolo nella sessione, lo strumento e lo scope OAuth. In più, chi ha approvato la connessione deve avere ancora i permessi di modifica sull'elemento. Una rimozione o una riassegnazione "ha effetto immediato".&lt;/p&gt;

&lt;p&gt;L'alternativa è mettere l'elenco dei progetti nel token, o in una cache con il token come chiave, e fidarsi fino alla scadenza. È più economico da scrivere. La ricostruzione qui sotto implementa entrambe le cose, così la differenza di comportamento si può misurare.&lt;/p&gt;

&lt;h2&gt;
  
  
  La ricostruzione
&lt;/h2&gt;

&lt;p&gt;Non c'è nessun binario di Claude di mezzo. &lt;code&gt;client.py&lt;/code&gt; fa da host e &lt;code&gt;server.py&lt;/code&gt; fa da server MCP. Entrambi usano solo la libreria standard, circa 280 righe in tutto, e sono stati eseguiti con Python 3.9.6 su un Apple M1. Il server implementa &lt;code&gt;initialize&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt; e &lt;code&gt;tools/call&lt;/code&gt; su JSON-RPC via HTTP semplice, i due documenti well-known, &lt;code&gt;/authorize&lt;/code&gt; (la pagina di consenso si approva da sola per un workspace) e &lt;code&gt;/token&lt;/code&gt; con PKCE S256. Alcune route &lt;code&gt;/admin/*&lt;/code&gt; sostituiscono i clic del proprietario nella scheda Connected.&lt;/p&gt;

&lt;p&gt;Il controllo che gira prima di ogni strumento:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;TOOL_SCOPE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scopes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insufficient_scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;CHECK&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                     &lt;span class="c1"&gt;# design B: trust issue-time state
&lt;/span&gt;        &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;projects_at_issue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CONNECTIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_revoked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROJECT_MEMBERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Il client parte senza token. Chiama uno strumento, riceve il 401, estrae &lt;code&gt;resource_metadata&lt;/code&gt; da &lt;code&gt;WWW-Authenticate&lt;/code&gt;, scarica i due documenti di metadati, esegue authorize e token con un verifier PKCE nuovo e poi percorre lo scenario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ CHECK=per_call python3 server.py &amp;amp;  python3 client.py 8767
discovery: first call -&amp;gt; 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lo stesso client contro &lt;code&gt;CHECK=token_only&lt;/code&gt;. Qui il token porta l'elenco dei progetti dell'ultimo refresh:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I passi 1–7 sono identici. Il passo 7 viene negato anche nel design solo-token, perché il progetto della nota viene letto in tempo reale. Il design solo-token diventa obsoleto solo sui dati che vivono nel token. I passi 8 e 9 sono quelli che interessano al proprietario: ha rimosso il progetto, poi ha revocato la connessione, e la chat ha continuato a leggere. Nel design solo-token questo dura finché il token non scade, cioè &lt;code&gt;expires_in: 3600&lt;/code&gt; in questa ricostruzione.&lt;/p&gt;

&lt;p&gt;I rifiuti si dividono su due livelli. Uno scope mancante è un errore a livello di protocollo: 403 con &lt;code&gt;error="insufficient_scope"&lt;/code&gt; e lo scope necessario in &lt;code&gt;WWW-Authenticate&lt;/code&gt;, così l'host può chiederne di più. Tutto ciò che riguarda quale elemento è coinvolto è un normale risultato dello strumento con &lt;code&gt;isError: true&lt;/code&gt;. Una nuova autorizzazione non lo risolverebbe, quindi non ha senso dire all'host di riprovare.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quanto costa il controllo a ogni chiamata?
&lt;/h2&gt;

&lt;p&gt;Misurato con &lt;code&gt;time.perf_counter_ns()&lt;/code&gt; attorno ad &lt;code&gt;access()&lt;/code&gt; dentro il server, 1,000 chiamate per esecuzione (500 consentite, 500 negate), su un Mac che nel frattempo eseguiva anche altri job:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;access() mediana&lt;/th&gt;
&lt;th&gt;access() p99&lt;/th&gt;
&lt;th&gt;chiamata a strumento in loopback, mediana&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;controllo a ogni chiamata, 6 esecuzioni&lt;/td&gt;
&lt;td&gt;1.3–1.6 µs&lt;/td&gt;
&lt;td&gt;6.1–7.0 µs&lt;/td&gt;
&lt;td&gt;0.43–0.78 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;solo token, 3 esecuzioni&lt;/td&gt;
&lt;td&gt;0.7–1.6 µs&lt;/td&gt;
&lt;td&gt;3.5–7.7 µs&lt;/td&gt;
&lt;td&gt;0.35–1.36 ms (2 esecuzioni)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In memoria, il ricontrollo sparisce nel rumore di una chiamata HTTP in loopback. Un server vero fa query al database invece di verificare l'appartenenza a un set, quindi il suo costo è più alto. Questa ricostruzione non può misurarlo, quindi non viene dato nessun numero. Per avere un ordine di grandezza, il tratto di rete verso l'endpoint in produzione da Tokyo è di circa 500 ms–800 ms per richiesta con urllib e una nuova connessione TLS ogni volta (n=10 ciascuno: &lt;code&gt;initialize&lt;/code&gt; mediana 790 ms, &lt;code&gt;tools/list&lt;/code&gt; 816 ms, il &lt;code&gt;tools/call&lt;/code&gt; con 401 565 ms, ogni documento di metadati circa 520–530 ms). Non è nel controllo degli accessi che un connettore passa il suo tempo.&lt;/p&gt;

&lt;p&gt;La discovery in sé erano 5 round trip HTTP in locale: 401, metadati della risorsa, metadati del server, authorize, token. Ci sono voluti 7.5–13.4 ms in sette esecuzioni, e 34 ms una volta con il Mac sotto carico. Contro il server in produzione, i tre tratti GET/POST che puoi fare senza credenziali sommano circa 1.6 s a freddo. Il tratto di authorize aspetta comunque una persona.&lt;/p&gt;

&lt;h2&gt;
  
  
  Perché Claude può chiamare osFoundry, ma osFoundry non può aprire le tue chat di Claude?
&lt;/h2&gt;

&lt;p&gt;In questo setup è l'host a chiamare. Apre la connessione, elenca gli strumenti e decide quando chiamarne uno. Il server risponde. Niente in questo setup dà al server un appiglio sulle conversazioni dell'host. Il piano di osFoundry lo scrive nella sua tabella delle capacità: aprire o elencare chat personali arbitrarie di ChatGPT/Claude è "No", con "No general host API is provided for this".&lt;/p&gt;

&lt;p&gt;Questo dà forma agli strumenti. Con &lt;code&gt;link_current_chat&lt;/code&gt; è la chat stessa a creare un collegamento dall'interno. La sua etichetta è dichiarata dalla chat, e osFoundry la usa per l'instradamento, non come identità. Il lavoro assegnato a una chat di Claude collegata resta in attesa finché l'utente non apre quella chat e le chiede di controllare. ChatGPT ha gli MCP Events per le chat iscritte, e il piano di osFoundry lo tratta come specifico di quell'host.&lt;/p&gt;

&lt;p&gt;Il lato server ha in mano tutte le decisioni sui dati, perché è l'unico a conoscere lo stato attuale di un progetto. Il lato host ha in mano tutte le decisioni su quando agire e se chiedere all'utente. Il controllo a ogni chiamata è ciò che permette al server di restare corretto quando i due lati vanno fuori sincrono.&lt;/p&gt;

&lt;h2&gt;
  
  
  Se lo colleghi tu
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Metti &lt;code&gt;resource_metadata&lt;/code&gt; nel &lt;code&gt;WWW-Authenticate&lt;/code&gt; del 401 e servi la RFC 9728 su &lt;code&gt;/.well-known/oauth-protected-resource/&amp;lt;resource path&amp;gt;&lt;/code&gt;. Un client che costruisce l'URL a mano può sbagliarlo. L'header risparmia questo errore a ogni client.&lt;/li&gt;
&lt;li&gt;Decidi cosa è pubblico prima del login. osFoundry espone &lt;code&gt;initialize&lt;/code&gt; e &lt;code&gt;tools/list&lt;/code&gt; e protegge &lt;code&gt;tools/call&lt;/code&gt;. Se le descrizioni dei tuoi strumenti lasciano trapelare qualcosa sui tenant, proteggi anche &lt;code&gt;tools/list&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Dichiara uno scope e &lt;code&gt;readOnlyHint&lt;/code&gt; per ogni strumento. Gli host possono costruirci sopra le richieste di conferma, e un 403 &lt;code&gt;insufficient_scope&lt;/code&gt; dà all'host qualcosa di concreto da richiedere di nuovo.&lt;/li&gt;
&lt;li&gt;Tieni il token piccolo: chi, quale connessione, quali scope. Rileggi a ogni chiamata il workspace, l'appartenenza al progetto, il progetto della risorsa e il ruolo attuale di chi ha approvato, così la revoca e "sposta in un altro progetto" valgono già dalla chiamata successiva.&lt;/li&gt;
&lt;li&gt;Restituisci i rifiuti sulle risorse come risultati dello strumento, non come 401/403. Un altro token non sistemerà "questa Note è nel progetto B".&lt;/li&gt;
&lt;li&gt;Fai in modo che le scritture portino &lt;code&gt;expected_version&lt;/code&gt; e una chiave di idempotenza. Gli host ritentano, e i modelli ripetono le chiamate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La parte MCP della specifica è nella &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization" rel="noopener noreferrer"&gt;pagina sull'autorizzazione di MCP&lt;/a&gt;. La definizione breve di osFoundry è nella sua &lt;a href="https://osfoundry.io/it/glossary/mcp" rel="noopener noreferrer"&gt;voce di glossario su MCP&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>claude</category>
      <category>sicurezza</category>
    </item>
    <item>
      <title>Comment un connecteur personnalisé Claude trouve-t-il le serveur OAuth d'osFoundry, et pourquoi l'accès est-il vérifié à chaque appel ?</title>
      <dc:creator>BBT</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:39:16 +0000</pubDate>
      <link>https://dev.to/bbt-fr/comment-un-connecteur-personnalise-claude-trouve-t-il-le-serveur-oauth-dosfoundry-et-pourquoi-4npf</link>
      <guid>https://dev.to/bbt-fr/comment-un-connecteur-personnalise-claude-trouve-t-il-le-serveur-oauth-dosfoundry-et-pourquoi-4npf</guid>
      <description>&lt;p&gt;Voici ce que reçoit un client MCP distant quand il appelle un outil d'&lt;a href="https://osfoundry.io/fr" rel="noopener noreferrer"&gt;osFoundry&lt;/a&gt; sans jeton. La requête a été envoyée avec curl depuis un Mac à Tokyo le 2026-11-06 :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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\", ..."]}}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cet unique en-tête suffit au connecteur personnalisé de Claude, ou à la connexion MCP du mode développeur de ChatGPT, pour trouver la page de connexion. L'utilisateur ne fait que coller l'URL. Tout ce qui suit vient de requêtes non authentifiées vers l'endpoint en production, plus une petite reconstruction en Python, exécutée en local, qui mesure ce qu'un curl ne peut pas montrer : ce qui arrive à l'accès après la connexion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Que voit le client avant d'avoir un jeton ?
&lt;/h2&gt;

&lt;p&gt;Trois appels, trois réponses différentes :&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requête&lt;/th&gt;
&lt;th&gt;Statut&lt;/th&gt;
&lt;th&gt;Ce qui revient&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initialize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;protocole &lt;code&gt;2025-06-18&lt;/code&gt;, serveur &lt;code&gt;osfoundry 0.1.0&lt;/code&gt;, plus des instructions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;59 outils, 45,368 octets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WWW-Authenticate&lt;/code&gt; qui pointe vers les métadonnées de la ressource&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET&lt;/code&gt; sur l'endpoint&lt;/td&gt;
&lt;td&gt;405&lt;/td&gt;
&lt;td&gt;pas de flux SSE en GET&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Le handshake et le catalogue d'outils sont publics. Seul l'appel d'un outil exige un jeton. Le host peut donc montrer à l'utilisateur quels outils existent avant que quiconque se connecte. Rien dans le catalogue n'est une donnée utilisateur, et la chaîne &lt;code&gt;instructions&lt;/code&gt; de &lt;code&gt;initialize&lt;/code&gt; est générique :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use list_granted_resources first. Every Note edit must use the exact tab ID.
Local-only data is unavailable to cloud clients.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chaque outil de &lt;code&gt;tools/list&lt;/code&gt; déclare le scope OAuth dont il a besoin et s'il fait seulement de la lecture :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"edit_record_item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"readOnlyHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destructiveHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"openWorldHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"securitySchemes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oauth2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;]}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sur les 59 outils : 30 demandent &lt;code&gt;osfoundry:read&lt;/code&gt; et sont marqués &lt;code&gt;readOnlyHint: true&lt;/code&gt;, 26 demandent &lt;code&gt;osfoundry:write&lt;/code&gt;, et 3 demandent &lt;code&gt;osfoundry:run&lt;/code&gt; (&lt;code&gt;request_session_run&lt;/code&gt;, &lt;code&gt;run_graph_session&lt;/code&gt;, &lt;code&gt;cancel_graph_run&lt;/code&gt;). Un host peut s'appuyer sur &lt;code&gt;readOnlyHint&lt;/code&gt; pour décider quels appels nécessitent une demande de confirmation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Où le 401 l'envoie-t-il ?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;resource_metadata&lt;/code&gt; suit la RFC 9728 (OAuth Protected Resource Metadata). Le suffixe well-known se place juste après l'hôte, suivi du chemin de la ressource. Il ne s'ajoute pas à la fin de l'URL de la ressource :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:models"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Si vous écrivez un client à la main, ce 404 sera votre première erreur. Lisez l'URL dans l'en-tête au lieu de la construire vous-même.&lt;/p&gt;

&lt;h2&gt;
  
  
  Que dit le serveur d'autorisation de lui-même ?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt; (RFC 8414) :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"issuer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/authorize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"registration_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/register"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"grant_types_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"authorization_code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint_auth_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"code_challenge_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"S256"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_response_iss_parameter_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lisez-le comme une chaîne de décisions. Il y a un &lt;code&gt;registration_endpoint&lt;/code&gt;, donc le host peut s'enregistrer tout seul (enregistrement dynamique de client) et l'utilisateur ne manipule jamais de client ID. L'authentification au token endpoint est &lt;code&gt;none&lt;/code&gt;, donc le host est un client public sans client secret, et c'est PKCE en S256 qui lie le code au client qui l'a demandé. &lt;code&gt;iss&lt;/code&gt; dans la réponse d'autorisation évite les confusions quand un host parle à plusieurs serveurs d'autorisation.&lt;/p&gt;

&lt;p&gt;L'étape authorize envoie le navigateur sur la page de consentement d'osFoundry, &lt;code&gt;https://osfoundry.io/external-assistant-authorize&lt;/code&gt;. L'utilisateur y choisit exactement un workspace et coche "I approve connecting this assistant to …" (j'autorise la connexion de cet assistant à …). L'approbation désigne un workspace et rien d'autre. Une nouvelle connexion n'a aucun projet. Le propriétaire les ajoute ensuite dans l'app (key icon → Connected → Add to project).&lt;/p&gt;

&lt;h2&gt;
  
  
  Pourquoi le scope ne suffit-il pas ?
&lt;/h2&gt;

&lt;p&gt;Un scope est figé au moment où le jeton est émis. &lt;code&gt;osfoundry:write&lt;/code&gt; dit que la connexion peut effectuer des actions d'écriture. Il ne dit pas quelle Note, et il ne peut pas savoir que la Note a été déplacée dans un autre projet il y a dix minutes. Les notes de conception d'osFoundry listent ce qui est relu à chaque appel : statut de la connexion, workspace, appartenance au projet, projet actuel et ACL de la ressource, appartenance au canal ou rôle dans la session, l'outil et le scope OAuth. La personne qui a approuvé la connexion doit aussi avoir encore les droits d'édition sur l'élément. Un retrait ou une réaffectation "prend effet immédiatement".&lt;/p&gt;

&lt;p&gt;L'alternative consiste à mettre la liste des projets dans le jeton, ou dans un cache indexé par le jeton, et à lui faire confiance jusqu'à expiration. C'est moins coûteux à écrire. La reconstruction ci-dessous implémente les deux pour pouvoir mesurer la différence de comportement.&lt;/p&gt;

&lt;h2&gt;
  
  
  La reconstruction
&lt;/h2&gt;

&lt;p&gt;Aucun binaire Claude n'intervient. &lt;code&gt;client.py&lt;/code&gt; joue le host et &lt;code&gt;server.py&lt;/code&gt; joue le serveur MCP. Les deux n'utilisent que la bibliothèque standard, environ 280 lignes à eux deux, et ont tourné sous Python 3.9.6 sur un Apple M1. Le serveur implémente &lt;code&gt;initialize&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt; et &lt;code&gt;tools/call&lt;/code&gt; en JSON-RPC sur HTTP simple, les deux documents well-known, &lt;code&gt;/authorize&lt;/code&gt; (la page de consentement est approuvée automatiquement pour un workspace) et &lt;code&gt;/token&lt;/code&gt; avec PKCE S256. Quelques routes &lt;code&gt;/admin/*&lt;/code&gt; remplacent les clics du propriétaire dans l'onglet Connected.&lt;/p&gt;

&lt;p&gt;La vérification exécutée avant chaque outil :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;TOOL_SCOPE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scopes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insufficient_scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;CHECK&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                     &lt;span class="c1"&gt;# design B: trust issue-time state
&lt;/span&gt;        &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;projects_at_issue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CONNECTIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_revoked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROJECT_MEMBERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le client démarre sans jeton. Il appelle un outil, reçoit le 401, extrait &lt;code&gt;resource_metadata&lt;/code&gt; de &lt;code&gt;WWW-Authenticate&lt;/code&gt;, récupère les deux documents de métadonnées, enchaîne authorize et token avec un nouveau verifier PKCE, puis déroule le scénario :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ CHECK=per_call python3 server.py &amp;amp;  python3 client.py 8767
discovery: first call -&amp;gt; 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le même client face à &lt;code&gt;CHECK=token_only&lt;/code&gt;. Ici, le jeton porte la liste des projets de son dernier refresh :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Les étapes 1–7 sont identiques. L'étape 7 reste refusée dans la conception « jeton seul », parce que le projet de la note est consulté en direct. La conception « jeton seul » ne devient périmée que sur les faits stockés dans le jeton. Les étapes 8 et 9 sont celles qui comptent pour le propriétaire : il a retiré le projet, puis révoqué la connexion, et le chat a continué à lire. Dans la conception « jeton seul », cela dure jusqu'à l'expiration du jeton, soit &lt;code&gt;expires_in: 3600&lt;/code&gt; dans cette reconstruction.&lt;/p&gt;

&lt;p&gt;Les refus se répartissent sur deux couches. Un scope manquant est un échec au niveau du protocole : 403 avec &lt;code&gt;error="insufficient_scope"&lt;/code&gt; et le scope requis dans &lt;code&gt;WWW-Authenticate&lt;/code&gt;, pour qu'un host puisse en demander davantage. Tout ce qui dépend de l'élément concerné est un résultat d'outil normal avec &lt;code&gt;isError: true&lt;/code&gt;. Une nouvelle autorisation n'y changerait rien, donc inutile de dire au host de réessayer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combien coûte la vérification à chaque appel ?
&lt;/h2&gt;

&lt;p&gt;Mesuré avec &lt;code&gt;time.perf_counter_ns()&lt;/code&gt; autour de &lt;code&gt;access()&lt;/code&gt; dans le serveur, 1,000 appels par exécution (500 autorisés, 500 refusés), sur un Mac qui faisait aussi tourner d'autres tâches :&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;access() médiane&lt;/th&gt;
&lt;th&gt;access() p99&lt;/th&gt;
&lt;th&gt;appel d'outil en loopback, médiane&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vérification à chaque appel, 6 exécutions&lt;/td&gt;
&lt;td&gt;1.3–1.6 µs&lt;/td&gt;
&lt;td&gt;6.1–7.0 µs&lt;/td&gt;
&lt;td&gt;0.43–0.78 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jeton seul, 3 exécutions&lt;/td&gt;
&lt;td&gt;0.7–1.6 µs&lt;/td&gt;
&lt;td&gt;3.5–7.7 µs&lt;/td&gt;
&lt;td&gt;0.35–1.36 ms (2 exécutions)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;En mémoire, la revérification se noie dans le bruit d'un appel HTTP en loopback. Un vrai serveur fait des requêtes en base de données au lieu de tests d'appartenance à un ensemble, donc son coût est plus élevé. Cette reconstruction ne peut pas le mesurer, donc aucun chiffre n'est donné. Pour l'ordre de grandeur, le trajet réseau vers l'endpoint en production depuis Tokyo est d'environ 500 ms à 800 ms par requête avec urllib et une nouvelle connexion TLS à chaque fois (n=10 pour chacun : &lt;code&gt;initialize&lt;/code&gt; médiane 790 ms, &lt;code&gt;tools/list&lt;/code&gt; 816 ms, le &lt;code&gt;tools/call&lt;/code&gt; en 401 565 ms, chaque document de métadonnées environ 520–530 ms). Ce n'est pas dans la vérification d'accès qu'un connecteur passe son temps.&lt;/p&gt;

&lt;p&gt;La découverte elle-même représentait 5 allers-retours HTTP en local : 401, métadonnées de la ressource, métadonnées du serveur, authorize, token. Cela a pris 7.5–13.4 ms sur sept exécutions, et 34 ms une fois avec le Mac chargé. Face au serveur en production, les trois trajets GET/POST faisables sans identifiants totalisent environ 1.6 s à froid. L'étape authorize attend de toute façon un humain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pourquoi Claude peut-il appeler osFoundry, alors qu'osFoundry ne peut pas ouvrir vos chats Claude ?
&lt;/h2&gt;

&lt;p&gt;Dans ce montage, c'est le host qui appelle. Il ouvre la connexion, liste les outils et décide quand en appeler un. Le serveur répond. Rien dans ce montage ne donne au serveur une prise sur les conversations du host. Le plan d'osFoundry l'indique dans son tableau des capacités : ouvrir ou énumérer des chats personnels ChatGPT/Claude arbitraires, c'est "No", avec "No general host API is provided for this".&lt;/p&gt;

&lt;p&gt;Cela façonne les outils. Avec &lt;code&gt;link_current_chat&lt;/code&gt;, c'est le chat lui-même qui crée un lien depuis l'intérieur. Son libellé est déclaré par le chat, et osFoundry s'en sert pour le routage, pas comme identité. Le travail assigné à un chat Claude lié attend que l'utilisateur ouvre ce chat et lui demande de vérifier. ChatGPT dispose de MCP Events pour les chats abonnés, et le plan d'osFoundry traite cela comme propre à ce host.&lt;/p&gt;

&lt;p&gt;Le côté serveur détient toutes les décisions sur les données, parce que c'est le seul à connaître l'état actuel d'un projet. Le côté host détient toutes les décisions sur le moment d'agir et sur l'opportunité de demander à l'utilisateur. La vérification à chaque appel est ce qui permet au serveur de rester correct quand les deux côtés se désynchronisent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Si vous câblez cela vous-même
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Mettez &lt;code&gt;resource_metadata&lt;/code&gt; dans le &lt;code&gt;WWW-Authenticate&lt;/code&gt; du 401 et servez la RFC 9728 à &lt;code&gt;/.well-known/oauth-protected-resource/&amp;lt;resource path&amp;gt;&lt;/code&gt;. Un client qui construit l'URL à la main peut se tromper. L'en-tête évite ça à tous les clients.&lt;/li&gt;
&lt;li&gt;Décidez de ce qui est public avant la connexion. osFoundry expose &lt;code&gt;initialize&lt;/code&gt; et &lt;code&gt;tools/list&lt;/code&gt; et protège &lt;code&gt;tools/call&lt;/code&gt;. Si les descriptions de vos outils laissent fuiter quoi que ce soit sur les tenants, protégez aussi &lt;code&gt;tools/list&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Déclarez un scope et &lt;code&gt;readOnlyHint&lt;/code&gt; par outil. Les hosts peuvent en tirer leurs demandes de confirmation, et un 403 &lt;code&gt;insufficient_scope&lt;/code&gt; donne au host quelque chose de concret à redemander.&lt;/li&gt;
&lt;li&gt;Gardez le jeton petit : qui, quelle connexion, quels scopes. Relisez à chaque appel le workspace, l'appartenance au projet, le projet de la ressource et le rôle actuel de la personne qui a approuvé, pour que la révocation et « déplacer vers un autre projet » s'appliquent dès l'appel suivant.&lt;/li&gt;
&lt;li&gt;Renvoyez les refus liés aux ressources comme résultats d'outil, pas en 401/403. Un autre jeton ne réglera pas « cette Note est dans le projet B ».&lt;/li&gt;
&lt;li&gt;Faites porter aux écritures un &lt;code&gt;expected_version&lt;/code&gt; et une clé d'idempotence. Les hosts réessaient, et les modèles relancent des appels.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Le côté MCP de la spécification se trouve sur la &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization" rel="noopener noreferrer"&gt;page d'autorisation MCP&lt;/a&gt;. La définition courte d'osFoundry se trouve dans son &lt;a href="https://osfoundry.io/fr/glossary/mcp" rel="noopener noreferrer"&gt;entrée de glossaire sur MCP&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>claude</category>
      <category>securite</category>
    </item>
    <item>
      <title>Wie findet ein Claude Custom Connector den OAuth-Server von osFoundry, und warum wird der Zugriff bei jedem Aufruf geprüft?</title>
      <dc:creator>BBT</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:38:30 +0000</pubDate>
      <link>https://dev.to/bbt-de/wie-findet-ein-claude-custom-connector-den-oauth-server-von-osfoundry-und-warum-wird-der-zugriff-3904</link>
      <guid>https://dev.to/bbt-de/wie-findet-ein-claude-custom-connector-den-oauth-server-von-osfoundry-und-warum-wird-der-zugriff-3904</guid>
      <description>&lt;p&gt;Das bekommt ein Remote-MCP-Client zurück, wenn er ein Tool von &lt;a href="https://osfoundry.io/de" rel="noopener noreferrer"&gt;osFoundry&lt;/a&gt; ohne Token aufruft. Die Anfrage wurde am 2026-11-06 mit curl von einem Mac in Tokio geschickt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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\", ..."]}}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dieser eine Header reicht dem Custom Connector von Claude, oder der MCP-Verbindung im Developer Mode von ChatGPT, um die Anmeldeseite zu finden. Der Nutzer fügt nur die URL ein. Alles Folgende stammt aus nicht authentifizierten Anfragen an den Live-Endpoint, plus einem kleinen Nachbau in Python, lokal ausgeführt, der misst, was ein curl nicht zeigen kann: was nach der Anmeldung mit dem Zugriff passiert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was sieht der Client, bevor er ein Token hat?
&lt;/h2&gt;

&lt;p&gt;Drei Aufrufe, drei verschiedene Antworten:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Anfrage&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Was zurückkommt&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initialize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Protokoll &lt;code&gt;2025-06-18&lt;/code&gt;, Server &lt;code&gt;osfoundry 0.1.0&lt;/code&gt;, dazu Instructions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;59 Tools, 45,368 Bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WWW-Authenticate&lt;/code&gt; mit Verweis auf die Resource Metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET&lt;/code&gt; auf den Endpoint&lt;/td&gt;
&lt;td&gt;405&lt;/td&gt;
&lt;td&gt;kein SSE-Stream per GET&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Handshake und Tool-Katalog sind öffentlich. Nur der Tool-Aufruf braucht ein Token. Dadurch kann der Host dem Nutzer zeigen, welche Tools es gibt, bevor sich überhaupt jemand anmeldet. Im Katalog stehen keine Nutzerdaten, und der &lt;code&gt;instructions&lt;/code&gt;-String in &lt;code&gt;initialize&lt;/code&gt; ist generisch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use list_granted_resources first. Every Note edit must use the exact tab ID.
Local-only data is unavailable to cloud clients.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Jedes Tool in &lt;code&gt;tools/list&lt;/code&gt; gibt an, welchen OAuth-Scope es braucht und ob es nur liest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"edit_record_item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"readOnlyHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destructiveHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"openWorldHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"securitySchemes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oauth2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;]}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Über alle 59 Tools: 30 brauchen &lt;code&gt;osfoundry:read&lt;/code&gt; und sind mit &lt;code&gt;readOnlyHint: true&lt;/code&gt; markiert, 26 brauchen &lt;code&gt;osfoundry:write&lt;/code&gt;, und 3 brauchen &lt;code&gt;osfoundry:run&lt;/code&gt; (&lt;code&gt;request_session_run&lt;/code&gt;, &lt;code&gt;run_graph_session&lt;/code&gt;, &lt;code&gt;cancel_graph_run&lt;/code&gt;). Ein Host kann anhand von &lt;code&gt;readOnlyHint&lt;/code&gt; entscheiden, welche Aufrufe eine Bestätigung brauchen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wohin schickt ihn der 401?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;resource_metadata&lt;/code&gt; folgt RFC 9728 (OAuth Protected Resource Metadata). Das Well-known-Suffix kommt direkt hinter den Host, der Ressourcenpfad wird hinten angehängt. Es wird nicht an die Ressourcen-URL angehängt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:models"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wenn du einen Client von Hand baust, ist dieser 404 der erste Fehler, den du machst. Lies die URL aus dem Header, statt sie selbst zusammenzubauen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was sagt der Authorization Server über sich selbst?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt; (RFC 8414):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"issuer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/authorize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"registration_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/register"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"grant_types_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"authorization_code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint_auth_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"code_challenge_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"S256"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_response_iss_parameter_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lies das als Kette von Entscheidungen. Es gibt einen &lt;code&gt;registration_endpoint&lt;/code&gt;, also kann sich der Host selbst registrieren (Dynamic Client Registration), und der Nutzer hat nie mit einer Client-ID zu tun. Die Token-Authentifizierung ist &lt;code&gt;none&lt;/code&gt;, also ist der Host ein Public Client ohne Client Secret, und PKCE mit S256 bindet den Code an den Client, der ihn angefordert hat. &lt;code&gt;iss&lt;/code&gt; in der Authorization Response schützt vor Verwechslungen, wenn ein Host mit mehreren Authorization Servern spricht.&lt;/p&gt;

&lt;p&gt;Der Authorize-Schritt schickt den Browser auf die Consent-Seite von osFoundry, &lt;code&gt;https://osfoundry.io/external-assistant-authorize&lt;/code&gt;. Dort wählt der Nutzer genau einen Workspace und hakt "I approve connecting this assistant to …" an (ich erlaube, diesen Assistenten zu verbinden mit …). Die Freigabe nennt einen Workspace und sonst nichts. Eine neue Verbindung hat keine Projekte. Die fügt der Owner danach in der App hinzu (key icon → Connected → Add to project).&lt;/p&gt;

&lt;h2&gt;
  
  
  Warum reicht der Scope nicht?
&lt;/h2&gt;

&lt;p&gt;Ein Scope steht fest, sobald das Token ausgestellt wird. &lt;code&gt;osfoundry:write&lt;/code&gt; sagt, dass die Verbindung schreibende Aktionen ausführen darf. Es sagt nicht, welche Note, und es kann nicht wissen, dass die Note vor zehn Minuten in ein anderes Projekt verschoben wurde. Die Design-Notizen von osFoundry listen auf, was bei jedem Aufruf neu gelesen wird: Verbindungsstatus, Workspace, Projektmitgliedschaft, das aktuelle Projekt und die ACL der Ressource, Channel-Mitgliedschaft oder Session-Rolle, das Tool und der OAuth-Scope. Außerdem muss die Person, die die Verbindung freigegeben hat, noch Bearbeitungsrechte auf das Element haben. Entfernen oder Neuzuweisen "wirkt sofort".&lt;/p&gt;

&lt;p&gt;Die Alternative: die Projektliste ins Token packen, oder in einen Cache mit dem Token als Schlüssel, und ihr bis zum Ablauf vertrauen. Das ist billiger zu schreiben. Der Nachbau unten implementiert beides, damit sich der Unterschied im Verhalten messen lässt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Der Nachbau
&lt;/h2&gt;

&lt;p&gt;Es ist kein Claude-Binary beteiligt. &lt;code&gt;client.py&lt;/code&gt; spielt den Host und &lt;code&gt;server.py&lt;/code&gt; den MCP-Server. Beide nutzen nur die Standardbibliothek, zusammen etwa 280 Zeilen, und liefen mit Python 3.9.6 auf einem Apple M1. Der Server implementiert &lt;code&gt;initialize&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt; und &lt;code&gt;tools/call&lt;/code&gt; über einfaches HTTP-JSON-RPC, die beiden Well-known-Dokumente, &lt;code&gt;/authorize&lt;/code&gt; (die Consent-Seite wird für einen Workspace automatisch bestätigt) und &lt;code&gt;/token&lt;/code&gt; mit S256-PKCE. Ein paar &lt;code&gt;/admin/*&lt;/code&gt;-Routen ersetzen die Klicks des Owners im Tab Connected.&lt;/p&gt;

&lt;p&gt;Die Prüfung, die vor jedem Tool läuft:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;TOOL_SCOPE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scopes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insufficient_scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;CHECK&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                     &lt;span class="c1"&gt;# design B: trust issue-time state
&lt;/span&gt;        &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;projects_at_issue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CONNECTIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_revoked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROJECT_MEMBERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Der Client startet ohne Token. Er ruft ein Tool auf, bekommt den 401, parst &lt;code&gt;resource_metadata&lt;/code&gt; aus &lt;code&gt;WWW-Authenticate&lt;/code&gt;, holt beide Metadaten-Dokumente, macht Authorize und Token mit einem frischen PKCE-Verifier und arbeitet dann das Szenario ab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ CHECK=per_call python3 server.py &amp;amp;  python3 client.py 8767
discovery: first call -&amp;gt; 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Derselbe Client gegen &lt;code&gt;CHECK=token_only&lt;/code&gt;. Hier trägt das Token die Projektliste vom letzten Refresh:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Die Schritte 1–7 sind identisch. Schritt 7 wird auch im Token-only-Design abgelehnt, weil das Projekt der Notiz live nachgeschlagen wird. Das Token-only-Design veraltet nur bei Fakten, die im Token stecken. Die Schritte 8 und 9 sind die, auf die es dem Owner ankommt: Er hat das Projekt entfernt, dann die Verbindung widerrufen, und der Chat hat weitergelesen. Im Token-only-Design hält das an, bis das Token abläuft, in diesem Nachbau &lt;code&gt;expires_in: 3600&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Ablehnungen verteilen sich auf zwei Ebenen. Ein fehlender Scope ist ein Fehler auf Protokollebene: 403 mit &lt;code&gt;error="insufficient_scope"&lt;/code&gt; und dem benötigten Scope in &lt;code&gt;WWW-Authenticate&lt;/code&gt;, damit ein Host mehr anfordern kann. Alles, was davon abhängt, um welches Element es geht, ist ein normales Tool-Ergebnis mit &lt;code&gt;isError: true&lt;/code&gt;. Eine erneute Autorisierung würde das nicht beheben, also hat es keinen Sinn, den Host dazu aufzufordern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was kostet die Prüfung pro Aufruf?
&lt;/h2&gt;

&lt;p&gt;Gemessen mit &lt;code&gt;time.perf_counter_ns()&lt;/code&gt; um &lt;code&gt;access()&lt;/code&gt; im Server, 1,000 Aufrufe pro Lauf (500 erlaubt, 500 abgelehnt), auf einem Mac, der nebenbei andere Jobs laufen hatte:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;access() Median&lt;/th&gt;
&lt;th&gt;access() p99&lt;/th&gt;
&lt;th&gt;Loopback-Tool-Aufruf, Median&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prüfung pro Aufruf, 6 Läufe&lt;/td&gt;
&lt;td&gt;1.3–1.6 µs&lt;/td&gt;
&lt;td&gt;6.1–7.0 µs&lt;/td&gt;
&lt;td&gt;0.43–0.78 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nur Token, 3 Läufe&lt;/td&gt;
&lt;td&gt;0.7–1.6 µs&lt;/td&gt;
&lt;td&gt;3.5–7.7 µs&lt;/td&gt;
&lt;td&gt;0.35–1.36 ms (2 Läufe)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Im Speicher geht die erneute Prüfung im Rauschen eines Loopback-HTTP-Aufrufs unter. Ein echter Server macht Datenbankabfragen statt Set-Lookups, die Kosten sind also höher. Das kann dieser Nachbau nicht messen, deshalb gibt es dafür keine Zahl. Zur Einordnung: Die Netzwerkstrecke zum Live-Endpoint aus Tokio liegt bei etwa 500 ms bis 800 ms pro Anfrage mit urllib und jedes Mal neuer TLS-Verbindung (je n=10: &lt;code&gt;initialize&lt;/code&gt; Median 790 ms, &lt;code&gt;tools/list&lt;/code&gt; 816 ms, der 401-&lt;code&gt;tools/call&lt;/code&gt; 565 ms, jedes Metadaten-Dokument etwa 520–530 ms). Die Zugriffsprüfung ist nicht der Punkt, an dem ein Connector seine Zeit verbringt.&lt;/p&gt;

&lt;p&gt;Die Discovery selbst waren lokal 5 HTTP-Roundtrips: 401, Resource Metadata, Server Metadata, Authorize, Token. Das dauerte 7.5–13.4 ms in sieben Läufen und einmal 34 ms, als der Mac ausgelastet war. Gegen den Live-Server summieren sich die drei GET/POST-Strecken, die ohne Credentials möglich sind, kalt auf ungefähr 1.6 s. Die Authorize-Strecke wartet sowieso auf einen Menschen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Warum kann Claude osFoundry aufrufen, aber osFoundry deine Claude-Chats nicht öffnen?
&lt;/h2&gt;

&lt;p&gt;In diesem Aufbau ist der Host der Aufrufer. Er öffnet die Verbindung, listet die Tools und entscheidet, wann er eines aufruft. Der Server antwortet. Nichts in diesem Aufbau gibt dem Server einen Zugriff auf die Unterhaltungen des Hosts. Der Plan von osFoundry sagt das in seiner Capability-Tabelle: Beliebige persönliche ChatGPT/Claude-Chats öffnen oder auflisten ist "No", mit "No general host API is provided for this".&lt;/p&gt;

&lt;p&gt;Das prägt die Tools. Bei &lt;code&gt;link_current_chat&lt;/code&gt; legt der Chat den Link von innen selbst an. Sein Label gibt der Chat selbst an, und osFoundry nutzt es zum Routing, nicht als Identität. Arbeit, die einem verknüpften Claude-Chat zugewiesen ist, wartet, bis der Nutzer diesen Chat öffnet und ihn bittet nachzusehen. ChatGPT hat MCP Events für abonnierte Chats, und der Plan von osFoundry behandelt das als hostspezifisch.&lt;/p&gt;

&lt;p&gt;Die Serverseite trifft alle Entscheidungen über Daten, weil nur sie den aktuellen Stand eines Projekts kennt. Die Hostseite trifft alle Entscheidungen darüber, wann gehandelt wird und ob der Nutzer gefragt wird. Die Prüfung pro Aufruf sorgt dafür, dass der Server korrekt bleibt, wenn die beiden Seiten auseinanderlaufen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wenn du das selbst verdrahtest
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pack &lt;code&gt;resource_metadata&lt;/code&gt; in den &lt;code&gt;WWW-Authenticate&lt;/code&gt; des 401 und liefere RFC 9728 unter &lt;code&gt;/.well-known/oauth-protected-resource/&amp;lt;resource path&amp;gt;&lt;/code&gt; aus. Ein Client, der die URL selbst baut, kann sie falsch bauen. Der Header erspart das jedem Client.&lt;/li&gt;
&lt;li&gt;Leg fest, was vor der Anmeldung öffentlich ist. osFoundry gibt &lt;code&gt;initialize&lt;/code&gt; und &lt;code&gt;tools/list&lt;/code&gt; frei und schützt &lt;code&gt;tools/call&lt;/code&gt;. Wenn deine Tool-Beschreibungen etwas über Tenants verraten, schütz auch &lt;code&gt;tools/list&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Deklarier pro Tool einen Scope und &lt;code&gt;readOnlyHint&lt;/code&gt;. Hosts können daraus Bestätigungsdialoge bauen, und ein 403 &lt;code&gt;insufficient_scope&lt;/code&gt; gibt dem Host etwas Konkretes, das er neu anfordern kann.&lt;/li&gt;
&lt;li&gt;Halt das Token klein: wer, welche Verbindung, welche Scopes. Lies Workspace, Projektmitgliedschaft, das Projekt der Ressource und die aktuelle Rolle der freigebenden Person bei jedem Aufruf neu, damit Widerruf und "in ein anderes Projekt verschieben" beim nächsten Aufruf greifen.&lt;/li&gt;
&lt;li&gt;Gib Ablehnungen auf Ressourcenebene als Tool-Ergebnisse zurück, nicht als 401/403. Ein anderes Token behebt "diese Note liegt in Projekt B" nicht.&lt;/li&gt;
&lt;li&gt;Lass Schreibvorgänge &lt;code&gt;expected_version&lt;/code&gt; und einen Idempotency Key mitschicken. Hosts wiederholen Anfragen, und Modelle setzen Aufrufe erneut ab.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Die MCP-Seite der Spezifikation steht auf der &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization" rel="noopener noreferrer"&gt;Autorisierungsseite von MCP&lt;/a&gt;. Die kurze Definition von osFoundry steht im &lt;a href="https://osfoundry.io/de/glossary/mcp" rel="noopener noreferrer"&gt;Glossareintrag zu MCP&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>claude</category>
      <category>sicherheit</category>
    </item>
    <item>
      <title>¿Cómo encuentra un conector personalizado de Claude el servidor OAuth de osFoundry y por qué se comprueba el acceso en cada llamada?</title>
      <dc:creator>BBT</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:30:52 +0000</pubDate>
      <link>https://dev.to/bbt-es/como-encuentra-un-conector-personalizado-de-claude-el-servidor-oauth-de-osfoundry-y-por-que-se-5ae7</link>
      <guid>https://dev.to/bbt-es/como-encuentra-un-conector-personalizado-de-claude-el-servidor-oauth-de-osfoundry-y-por-que-se-5ae7</guid>
      <description>&lt;p&gt;Esto es lo que recibe un cliente MCP remoto cuando llama a una herramienta de &lt;a href="https://osfoundry.io/es" rel="noopener noreferrer"&gt;osFoundry&lt;/a&gt; sin token. La petición se hizo con curl desde un Mac en Tokio el 2026-11-06:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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\", ..."]}}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Con esa única cabecera, el conector personalizado de Claude, o la conexión MCP del modo desarrollador de ChatGPT, encuentra la página de inicio de sesión. El usuario solo pega la URL. Todo lo que sigue sale de peticiones sin autenticar al endpoint en producción, más una pequeña reconstrucción en Python, ejecutada en local, que mide lo que un curl no puede enseñar: qué pasa con el acceso después de iniciar sesión.&lt;/p&gt;

&lt;h2&gt;
  
  
  ¿Qué ve el cliente antes de tener un token?
&lt;/h2&gt;

&lt;p&gt;Tres llamadas, tres respuestas distintas:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Petición&lt;/th&gt;
&lt;th&gt;Estado&lt;/th&gt;
&lt;th&gt;Qué devuelve&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initialize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;protocolo &lt;code&gt;2025-06-18&lt;/code&gt;, servidor &lt;code&gt;osfoundry 0.1.0&lt;/code&gt;, más instrucciones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;59 herramientas, 45,368 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WWW-Authenticate&lt;/code&gt; apuntando a los metadatos del recurso&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET&lt;/code&gt; sobre el endpoint&lt;/td&gt;
&lt;td&gt;405&lt;/td&gt;
&lt;td&gt;no hay stream SSE por GET&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;El handshake y el catálogo de herramientas son públicos. Solo llamar a una herramienta exige token. Así el host puede enseñar al usuario qué herramientas existen antes de que nadie inicie sesión. Nada del catálogo son datos de usuario, y el texto de &lt;code&gt;instructions&lt;/code&gt; en &lt;code&gt;initialize&lt;/code&gt; es genérico:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use list_granted_resources first. Every Note edit must use the exact tab ID.
Local-only data is unavailable to cloud clients.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cada herramienta de &lt;code&gt;tools/list&lt;/code&gt; declara el scope OAuth que necesita y si solo lee:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"edit_record_item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"readOnlyHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destructiveHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"openWorldHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"securitySchemes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oauth2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;]}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;De las 59 herramientas, 30 necesitan &lt;code&gt;osfoundry:read&lt;/code&gt; y llevan &lt;code&gt;readOnlyHint: true&lt;/code&gt;, 26 necesitan &lt;code&gt;osfoundry:write&lt;/code&gt; y 3 necesitan &lt;code&gt;osfoundry:run&lt;/code&gt; (&lt;code&gt;request_session_run&lt;/code&gt;, &lt;code&gt;run_graph_session&lt;/code&gt;, &lt;code&gt;cancel_graph_run&lt;/code&gt;). Un host puede usar &lt;code&gt;readOnlyHint&lt;/code&gt; para decidir qué llamadas piden confirmación al usuario.&lt;/p&gt;

&lt;h2&gt;
  
  
  ¿Adónde le manda el 401?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;resource_metadata&lt;/code&gt; sigue la RFC 9728 (OAuth Protected Resource Metadata). El sufijo well-known va justo después del host, con la ruta del recurso añadida detrás. No se añade al final de la URL del recurso:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:models"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Si escribes un cliente a mano, ese 404 es el primer error que vas a cometer. Lee la URL de la cabecera en lugar de construirla tú.&lt;/p&gt;

&lt;h2&gt;
  
  
  ¿Qué dice de sí mismo el servidor de autorización?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt; (RFC 8414):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"issuer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/authorize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"registration_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/register"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"grant_types_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"authorization_code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint_auth_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"code_challenge_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"S256"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_response_iss_parameter_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Léelo como una cadena de decisiones. Hay un &lt;code&gt;registration_endpoint&lt;/code&gt;, así que el host puede registrarse solo (registro dinámico de clientes) y el usuario nunca toca un client ID. La autenticación en el token es &lt;code&gt;none&lt;/code&gt;, así que el host es un cliente público sin client secret, y lo que ata el código al cliente que lo pidió es PKCE con S256. &lt;code&gt;iss&lt;/code&gt; en la respuesta de autorización evita confusiones cuando un host habla con varios servidores de autorización.&lt;/p&gt;

&lt;p&gt;El paso de autorización lleva el navegador a la página de consentimiento de osFoundry, &lt;code&gt;https://osfoundry.io/external-assistant-authorize&lt;/code&gt;. Ahí el usuario elige exactamente un workspace y marca "I approve connecting this assistant to …" (apruebo conectar este asistente a …). La aprobación nombra un workspace y nada más. Una conexión nueva no tiene proyectos. El propietario los añade después en la app (key icon → Connected → Add to project).&lt;/p&gt;

&lt;h2&gt;
  
  
  ¿Por qué no basta con el scope?
&lt;/h2&gt;

&lt;p&gt;Un scope se fija cuando se emite el token. &lt;code&gt;osfoundry:write&lt;/code&gt; dice que la conexión puede hacer acciones de escritura. No dice qué Note, y no puede saber que esa Note se movió a otro proyecto hace diez minutos. Las notas de diseño de osFoundry enumeran lo que se vuelve a leer en cada llamada: estado de la conexión, workspace, pertenencia al proyecto, el proyecto actual y la ACL del recurso, pertenencia al canal o rol en la sesión, la herramienta y el scope OAuth. Además, la persona que aprobó la conexión tiene que seguir teniendo permisos de edición sobre el elemento. Quitar o reasignar algo "tiene efecto inmediato".&lt;/p&gt;

&lt;p&gt;La alternativa es meter la lista de proyectos en el token, o en una caché indexada por el token, y fiarse de ella hasta que caduque. Es más barato de escribir. La reconstrucción de abajo implementa las dos cosas para poder medir la diferencia de comportamiento.&lt;/p&gt;

&lt;h2&gt;
  
  
  La reconstrucción
&lt;/h2&gt;

&lt;p&gt;No hay ningún binario de Claude de por medio. &lt;code&gt;client.py&lt;/code&gt; hace de host y &lt;code&gt;server.py&lt;/code&gt; hace de servidor MCP. Los dos usan solo la biblioteca estándar, unas 280 líneas entre ambos, y se ejecutaron con Python 3.9.6 en un Apple M1. El servidor implementa &lt;code&gt;initialize&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt; y &lt;code&gt;tools/call&lt;/code&gt; sobre JSON-RPC en HTTP plano, los dos documentos well-known, &lt;code&gt;/authorize&lt;/code&gt; (la página de consentimiento se aprueba sola para un workspace) y &lt;code&gt;/token&lt;/code&gt; con PKCE S256. Unas cuantas rutas &lt;code&gt;/admin/*&lt;/code&gt; sustituyen a los clics del propietario en la pestaña Connected.&lt;/p&gt;

&lt;p&gt;La comprobación que se ejecuta antes de cada herramienta:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;TOOL_SCOPE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scopes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insufficient_scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;CHECK&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                     &lt;span class="c1"&gt;# design B: trust issue-time state
&lt;/span&gt;        &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;projects_at_issue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CONNECTIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_revoked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROJECT_MEMBERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El cliente arranca sin token. Llama a una herramienta, recibe el 401, saca &lt;code&gt;resource_metadata&lt;/code&gt; de &lt;code&gt;WWW-Authenticate&lt;/code&gt;, descarga los dos documentos de metadatos, hace authorize y token con un verificador PKCE nuevo y luego recorre el escenario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ CHECK=per_call python3 server.py &amp;amp;  python3 client.py 8767
discovery: first call -&amp;gt; 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El mismo cliente contra &lt;code&gt;CHECK=token_only&lt;/code&gt;. Aquí el token lleva la lista de proyectos de su último refresco:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Los pasos 1–7 son idénticos. El paso 7 sigue denegado en el diseño solo-token porque el proyecto de la nota se consulta en vivo. El diseño solo-token solo se queda desfasado en los datos que viven dentro del token. Los pasos 8 y 9 son los que le importan al propietario: quitó el proyecto, luego revocó la conexión, y el chat siguió leyendo. En el diseño solo-token eso dura hasta que caduca el token, que en esta reconstrucción es &lt;code&gt;expires_in: 3600&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Las denegaciones se reparten en dos capas. Un scope que falta es un fallo a nivel de protocolo: 403 con &lt;code&gt;error="insufficient_scope"&lt;/code&gt; y el scope necesario en &lt;code&gt;WWW-Authenticate&lt;/code&gt;, para que el host pueda pedir más. Todo lo que tiene que ver con qué elemento está implicado es un resultado normal de herramienta con &lt;code&gt;isError: true&lt;/code&gt;. Volver a autorizar no lo arreglaría, así que no tiene sentido decirle al host que lo intente.&lt;/p&gt;

&lt;h2&gt;
  
  
  ¿Cuánto cuesta la comprobación en cada llamada?
&lt;/h2&gt;

&lt;p&gt;Medido con &lt;code&gt;time.perf_counter_ns()&lt;/code&gt; alrededor de &lt;code&gt;access()&lt;/code&gt; dentro del servidor, 1,000 llamadas por ejecución (500 permitidas, 500 denegadas), en un Mac que además estaba ejecutando otros trabajos:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;access() mediana&lt;/th&gt;
&lt;th&gt;access() p99&lt;/th&gt;
&lt;th&gt;llamada a herramienta en loopback, mediana&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;comprobación por llamada, 6 ejecuciones&lt;/td&gt;
&lt;td&gt;1.3–1.6 µs&lt;/td&gt;
&lt;td&gt;6.1–7.0 µs&lt;/td&gt;
&lt;td&gt;0.43–0.78 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;solo token, 3 ejecuciones&lt;/td&gt;
&lt;td&gt;0.7–1.6 µs&lt;/td&gt;
&lt;td&gt;3.5–7.7 µs&lt;/td&gt;
&lt;td&gt;0.35–1.36 ms (2 ejecuciones)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;En memoria, la recomprobación se pierde en el ruido de una llamada HTTP por loopback. Un servidor real hace consultas a base de datos en lugar de comprobar pertenencia a un conjunto, así que su coste es mayor. Esta reconstrucción no puede medirlo, así que no se da ninguna cifra. Para hacerse una idea, el tramo de red hasta el endpoint en producción desde Tokio ronda los 500 ms a 800 ms por petición con urllib y una conexión TLS nueva cada vez (n=10 cada una: &lt;code&gt;initialize&lt;/code&gt; mediana 790 ms, &lt;code&gt;tools/list&lt;/code&gt; 816 ms, el &lt;code&gt;tools/call&lt;/code&gt; con 401 565 ms, cada documento de metadatos unos 520–530 ms). Un conector no pierde el tiempo en la comprobación de acceso.&lt;/p&gt;

&lt;p&gt;El descubrimiento en sí fueron 5 idas y vueltas HTTP en local: 401, metadatos del recurso, metadatos del servidor, authorize, token. Tardó 7.5–13.4 ms en siete ejecuciones y 34 ms una vez con el Mac ocupado. Contra el servidor en producción, los tres tramos GET/POST que puedes hacer sin credenciales suman aproximadamente 1.6 s en frío. El tramo de authorize espera a una persona de todos modos.&lt;/p&gt;

&lt;h2&gt;
  
  
  ¿Por qué Claude puede llamar a osFoundry, pero osFoundry no puede abrir tus chats de Claude?
&lt;/h2&gt;

&lt;p&gt;En este montaje quien llama es el host. Abre la conexión, lista las herramientas y decide cuándo llamar a una. El servidor responde. Nada en este montaje le da al servidor un acceso a las conversaciones del host. El plan de osFoundry lo dice en su tabla de capacidades: abrir o enumerar chats personales arbitrarios de ChatGPT/Claude es "No", con "No general host API is provided for this".&lt;/p&gt;

&lt;p&gt;Eso condiciona las herramientas. Con &lt;code&gt;link_current_chat&lt;/code&gt;, es el propio chat el que crea un enlace desde dentro. Su etiqueta la declara el propio chat, y osFoundry la usa para enrutar, no como identidad. El trabajo asignado a un chat de Claude enlazado espera hasta que el usuario abre ese chat y le pide que lo revise. ChatGPT tiene MCP Events para chats suscritos, y el plan de osFoundry lo trata como algo específico de ese host.&lt;/p&gt;

&lt;p&gt;El lado del servidor es dueño de todas las decisiones sobre datos, porque es el único que conoce el estado actual de un proyecto. El lado del host es dueño de todas las decisiones sobre cuándo actuar y si preguntar al usuario. La comprobación en cada llamada es lo que permite que el servidor siga siendo correcto cuando esos dos lados se desincronizan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Si lo montas tú
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pon &lt;code&gt;resource_metadata&lt;/code&gt; en el &lt;code&gt;WWW-Authenticate&lt;/code&gt; del 401 y sirve la RFC 9728 en &lt;code&gt;/.well-known/oauth-protected-resource/&amp;lt;resource path&amp;gt;&lt;/code&gt;. Un cliente que construye la URL a mano puede equivocarse. La cabecera se lo ahorra a todos los clientes.&lt;/li&gt;
&lt;li&gt;Decide qué es público antes del inicio de sesión. osFoundry expone &lt;code&gt;initialize&lt;/code&gt; y &lt;code&gt;tools/list&lt;/code&gt; y protege &lt;code&gt;tools/call&lt;/code&gt;. Si las descripciones de tus herramientas filtran algo sobre los tenants, protege también &lt;code&gt;tools/list&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Declara un scope y &lt;code&gt;readOnlyHint&lt;/code&gt; por herramienta. Los hosts pueden construir con ellos las peticiones de confirmación, y un 403 &lt;code&gt;insufficient_scope&lt;/code&gt; le da al host algo concreto que volver a pedir.&lt;/li&gt;
&lt;li&gt;Mantén el token pequeño: quién, qué conexión, qué scopes. Vuelve a leer en cada llamada el workspace, la pertenencia al proyecto, el proyecto del recurso y el rol actual de quien aprobó, para que revocar y "mover a otro proyecto" funcionen en la siguiente llamada.&lt;/li&gt;
&lt;li&gt;Devuelve las denegaciones sobre recursos como resultados de herramienta, no como 401/403. Otro token no va a arreglar "esta Note está en el proyecto B".&lt;/li&gt;
&lt;li&gt;Haz que las escrituras lleven &lt;code&gt;expected_version&lt;/code&gt; y una clave de idempotencia. Los hosts reintentan, y los modelos repiten llamadas.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La parte MCP de la especificación está en la &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization" rel="noopener noreferrer"&gt;página de autorización de MCP&lt;/a&gt;. La definición breve de osFoundry está en su &lt;a href="https://osfoundry.io/es/glossary/mcp" rel="noopener noreferrer"&gt;entrada del glosario sobre MCP&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>claude</category>
      <category>seguridad</category>
    </item>
    <item>
      <title>How does a Claude custom connector find osFoundry's OAuth server, and why is access checked on every call?</title>
      <dc:creator>BBT</dc:creator>
      <pubDate>Fri, 06 Nov 2026 10:29:55 +0000</pubDate>
      <link>https://dev.to/bbt_c84feb0f535/how-does-a-claude-custom-connector-find-osfoundrys-oauth-server-and-why-is-access-checked-on-fc</link>
      <guid>https://dev.to/bbt_c84feb0f535/how-does-a-claude-custom-connector-find-osfoundrys-oauth-server-and-why-is-access-checked-on-fc</guid>
      <description>&lt;p&gt;This is what a remote MCP client gets when it calls an &lt;a href="https://osfoundry.io" rel="noopener noreferrer"&gt;osFoundry&lt;/a&gt; tool without a token. The request was sent with curl from a Mac in Tokyo on 2026-11-06:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ 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\", ..."]}}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one header is enough for Claude's custom connector, or ChatGPT's developer-mode MCP connection, to find the sign-in page. The user only ever pastes the URL. Everything below is from unauthenticated requests to the live endpoint, plus a small Python rebuild, run locally, that measures the part a curl can't show: what happens to access after sign-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the client see before it has a token?
&lt;/h2&gt;

&lt;p&gt;Three calls, three different answers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;What comes back&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initialize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;protocol &lt;code&gt;2025-06-18&lt;/code&gt;, server &lt;code&gt;osfoundry 0.1.0&lt;/code&gt;, plus instructions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;59 tools, 45,368 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WWW-Authenticate&lt;/code&gt; pointing at the resource metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;GET&lt;/code&gt; on the endpoint&lt;/td&gt;
&lt;td&gt;405&lt;/td&gt;
&lt;td&gt;no SSE stream on GET&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The handshake and the tool catalogue are public. Only calling a tool needs a token. That lets the host show the user which tools exist before anyone signs in. Nothing in the catalogue is user data, and the &lt;code&gt;instructions&lt;/code&gt; string in &lt;code&gt;initialize&lt;/code&gt; is generic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use list_granted_resources first. Every Note edit must use the exact tab ID.
Local-only data is unavailable to cloud clients.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every tool in &lt;code&gt;tools/list&lt;/code&gt; declares the OAuth scope it needs and whether it only reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"edit_record_item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"readOnlyHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"destructiveHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"openWorldHint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"securitySchemes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oauth2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scopes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;]}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across the 59 tools: 30 need &lt;code&gt;osfoundry:read&lt;/code&gt; and are marked &lt;code&gt;readOnlyHint: true&lt;/code&gt;, 26 need &lt;code&gt;osfoundry:write&lt;/code&gt;, and 3 need &lt;code&gt;osfoundry:run&lt;/code&gt; (&lt;code&gt;request_session_run&lt;/code&gt;, &lt;code&gt;run_graph_session&lt;/code&gt;, &lt;code&gt;cancel_graph_run&lt;/code&gt;). A host can use &lt;code&gt;readOnlyHint&lt;/code&gt; to decide which calls need an approval prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the 401 send it?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;resource_metadata&lt;/code&gt; follows RFC 9728 (OAuth Protected Resource Metadata). The well-known suffix goes after the host, with the resource path appended. It is not appended to the resource URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"scopes_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osfoundry:models"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you build a client by hand, that 404 is the mistake you'll make first. Read the URL from the header instead of building it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the authorization server say about itself?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt; (RFC 8414):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"issuer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/authorize"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"registration_endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.osfoundry.io/api/v1/external-oauth/register"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"grant_types_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"authorization_code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint_auth_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"code_challenge_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"S256"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"authorization_response_iss_parameter_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as a chain of decisions. There's a &lt;code&gt;registration_endpoint&lt;/code&gt;, so the host can register itself (dynamic client registration) and the user never handles a client ID. Token auth is &lt;code&gt;none&lt;/code&gt;, so the host is a public client with no client secret, and PKCE with S256 is what ties the code to the client that asked for it. &lt;code&gt;iss&lt;/code&gt; in the authorization response guards against mix-ups when a host talks to several authorization servers.&lt;/p&gt;

&lt;p&gt;The authorize step sends the browser to osFoundry's consent page, &lt;code&gt;https://osfoundry.io/external-assistant-authorize&lt;/code&gt;. There the user picks exactly one workspace and ticks "I approve connecting this assistant to …". The approval names a workspace and nothing else. A new connection has no projects. The owner adds them afterwards in the app (key icon → Connected → Add to project).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why isn't the scope enough?
&lt;/h2&gt;

&lt;p&gt;A scope is fixed when the token is minted. &lt;code&gt;osfoundry:write&lt;/code&gt; says the connection may perform write-type actions. It doesn't say which Note, and it can't know that the Note moved to another project ten minutes ago. osFoundry's design notes list what gets re-read on each call: connection status, workspace, project membership, the resource's current project and ACL, channel membership or session role, the tool, and the OAuth scope. The person who approved the connection also has to still hold edit rights on the item. Removal or reassignment "takes effect immediately".&lt;/p&gt;

&lt;p&gt;The alternative is to put the project list into the token, or into a cache keyed by the token, and trust it until expiry. That is cheaper to write. The rebuild below implements both so the difference in behaviour can be measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rebuild
&lt;/h2&gt;

&lt;p&gt;No Claude binary is involved. &lt;code&gt;client.py&lt;/code&gt; plays the host and &lt;code&gt;server.py&lt;/code&gt; plays the MCP server. Both use only the standard library, about 280 lines together, and ran on Python 3.9.6 on an Apple M1. The server implements &lt;code&gt;initialize&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt; and &lt;code&gt;tools/call&lt;/code&gt; over plain HTTP JSON-RPC, the two well-known documents, &lt;code&gt;/authorize&lt;/code&gt; (the consent page is auto-approved for one workspace), and &lt;code&gt;/token&lt;/code&gt; with S256 PKCE. A few &lt;code&gt;/admin/*&lt;/code&gt; routes stand in for the owner's clicks in the Connected tab.&lt;/p&gt;

&lt;p&gt;The check that runs before every tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;TOOL_SCOPE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scopes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insufficient_scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;CHECK&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                     &lt;span class="c1"&gt;# design B: trust issue-time state
&lt;/span&gt;        &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;projects_at_issue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CONNECTIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_revoked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;note_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROJECT_MEMBERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_in_project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client starts with no token. It calls a tool, takes the 401, parses &lt;code&gt;resource_metadata&lt;/code&gt; out of &lt;code&gt;WWW-Authenticate&lt;/code&gt;, fetches both metadata documents, runs authorize and token with a fresh PKCE verifier, and then works through the scenario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ CHECK=per_call python3 server.py &amp;amp;  python3 client.py 8767
discovery: first call -&amp;gt; 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same client against &lt;code&gt;CHECK=token_only&lt;/code&gt;. Here the token carries the project list from its last refresh:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Steps 1–7 are identical. Step 7 is still denied in the token-only design because the note's project is looked up live. The token-only design only goes stale on facts that live in the token. Steps 8 and 9 are the ones the owner cares about: they removed the project, then they revoked the connection, and the chat kept reading. In the token-only design that lasts until the token expires, which is &lt;code&gt;expires_in: 3600&lt;/code&gt; in this rebuild.&lt;/p&gt;

&lt;p&gt;Denials split across two layers. A missing scope is a protocol-level failure: 403 with &lt;code&gt;error="insufficient_scope"&lt;/code&gt; and the needed scope in &lt;code&gt;WWW-Authenticate&lt;/code&gt;, so a host could ask for more. Anything about which item is involved is a normal tool result with &lt;code&gt;isError: true&lt;/code&gt;. Re-authorising wouldn't fix those, so there's no point telling the host to try.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the per-call check cost?
&lt;/h2&gt;

&lt;p&gt;Measured with &lt;code&gt;time.perf_counter_ns()&lt;/code&gt; around &lt;code&gt;access()&lt;/code&gt; inside the server, 1,000 calls per run (500 allowed, 500 denied), on a Mac that was also running other jobs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;access() median&lt;/th&gt;
&lt;th&gt;access() p99&lt;/th&gt;
&lt;th&gt;loopback tool call, median&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;per-call check, 6 runs&lt;/td&gt;
&lt;td&gt;1.3–1.6 µs&lt;/td&gt;
&lt;td&gt;6.1–7.0 µs&lt;/td&gt;
&lt;td&gt;0.43–0.78 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;token-only, 3 runs&lt;/td&gt;
&lt;td&gt;0.7–1.6 µs&lt;/td&gt;
&lt;td&gt;3.5–7.7 µs&lt;/td&gt;
&lt;td&gt;0.35–1.36 ms (2 runs)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In memory, the recheck is lost in the noise of a loopback HTTP call. A real server does database lookups instead of set membership, so its cost is higher. This rebuild can't measure that, so no number is given for it. For scale, the network leg to the live endpoint from Tokyo is about 500 ms to 800 ms per request in urllib with a new TLS connection each time (n=10 each: &lt;code&gt;initialize&lt;/code&gt; median 790 ms, &lt;code&gt;tools/list&lt;/code&gt; 816 ms, the 401 &lt;code&gt;tools/call&lt;/code&gt; 565 ms, each metadata document about 520–530 ms). The access check isn't where a connector spends its time.&lt;/p&gt;

&lt;p&gt;The discovery itself was 5 HTTP round trips locally: 401, resource metadata, server metadata, authorize, token. That took 7.5–13.4 ms in seven runs and 34 ms once while the Mac was busy. Against the live server, the three GET/POST legs you can make without credentials add up to roughly 1.6 s from a cold start. The authorize leg waits on a human anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can Claude call osFoundry, but osFoundry can't open your Claude chats?
&lt;/h2&gt;

&lt;p&gt;In this setup the host is the caller. It opens the connection, lists tools and decides when to call one. The server answers. Nothing in this setup gives the server a handle on the host's conversations. osFoundry's plan states it in its capability table: opening or enumerating arbitrary personal ChatGPT/Claude chats is "No", with "No general host API is provided for this".&lt;/p&gt;

&lt;p&gt;That shapes the tools. &lt;code&gt;link_current_chat&lt;/code&gt; has the chat create a link from inside the chat. Its label is self-reported, and osFoundry uses it as routing, not as identity. Work assigned to a linked Claude chat waits until the user opens that chat and asks it to check. ChatGPT has MCP Events for subscribed chats, and osFoundry's plan treats that as host-specific.&lt;/p&gt;

&lt;p&gt;The server side owns every decision about data, because it's the only side that knows the current state of a project. The host side owns every decision about when to act and whether to ask the user. The per-call check is what lets the server stay correct when those two sides drift apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you wire this up yourself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Put &lt;code&gt;resource_metadata&lt;/code&gt; in the 401's &lt;code&gt;WWW-Authenticate&lt;/code&gt; and serve RFC 9728 at &lt;code&gt;/.well-known/oauth-protected-resource/&amp;lt;resource path&amp;gt;&lt;/code&gt;. A client that builds the URL by hand can get it wrong. The header saves every client from that.&lt;/li&gt;
&lt;li&gt;Decide what's public before sign-in. osFoundry exposes &lt;code&gt;initialize&lt;/code&gt; and &lt;code&gt;tools/list&lt;/code&gt; and guards &lt;code&gt;tools/call&lt;/code&gt;. If your tool descriptions leak anything about tenants, guard &lt;code&gt;tools/list&lt;/code&gt; too.&lt;/li&gt;
&lt;li&gt;Declare a scope and &lt;code&gt;readOnlyHint&lt;/code&gt; per tool. Hosts can build approval prompts from them, and a 403 &lt;code&gt;insufficient_scope&lt;/code&gt; gives the host something concrete to re-request.&lt;/li&gt;
&lt;li&gt;Keep the token small: who, which connection, which scopes. Re-read workspace, project membership, the resource's project and the approver's current role on every call, so that revoke and "move to another project" work on the next call.&lt;/li&gt;
&lt;li&gt;Return resource denials as tool results, not as 401/403. A different token won't fix "this Note is in project B".&lt;/li&gt;
&lt;li&gt;Make writes carry &lt;code&gt;expected_version&lt;/code&gt; and an idempotency key. Hosts retry, and models re-issue calls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The MCP side of the spec is on the &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization" rel="noopener noreferrer"&gt;MCP authorization page&lt;/a&gt;. osFoundry's own short definition is in its &lt;a href="https://osfoundry.io/glossary/mcp" rel="noopener noreferrer"&gt;glossary entry for MCP&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>claude</category>
      <category>security</category>
    </item>
  </channel>
</rss>
