Ini yang diterima client MCP remote saat memanggil tool osFoundry tanpa token. Request-nya dikirim dengan curl dari sebuah Mac di Tokyo pada 2026-11-06:
$ 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\", ..."]}}}
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.
Tiga panggilan, tiga jawaban berbeda:
| Request | Status | Yang dikembalikan |
|---|---|---|
initialize |
200 | protokol 2025-06-18 , serverosfoundry 0.1.0 , plus instruksi |
tools/list |
200 | 59 tool, 45,368 byte |
tools/call |
401 | WWW-Authenticate yang menunjuk ke resource metadata |
GET ke endpoint |
405 | tidak ada stream SSE lewat GET |
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 instructions di initialize bersifat umum:
Use list_granted_resources first. Every Note edit must use the exact tab ID.
Local-only data is unavailable to cloud clients.
Setiap tool di tools/list mendeklarasikan scope OAuth yang dibutuhkannya dan apakah tool itu hanya membaca:
{"name": "edit_record_item",
"annotations": {"readOnlyHint": false, "destructiveHint": true, "openWorldHint": false},
"securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]}
Dari 59 tool: 30 butuh osfoundry:read dan ditandai readOnlyHint: true, 26 butuh osfoundry:write, dan 3 butuh osfoundry:run (request_session_run, run_graph_session, cancel_graph_run). Host bisa memakai readOnlyHint untuk menentukan panggilan mana yang perlu prompt persetujuan.
resource_metadata 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:
GET /.well-known/oauth-protected-resource/api/v1/external-mcp 200
GET /.well-known/oauth-protected-resource 200 (same document)
GET /api/v1/external-mcp/.well-known/oauth-protected-resource 404
{"resource": "https://api.osfoundry.io/api/v1/external-mcp",
"authorization_servers": ["https://api.osfoundry.io"],
"scopes_supported": ["osfoundry:read", "osfoundry:write", "osfoundry:run", "osfoundry:models"]}
Kalau kamu membuat client sendiri, 404 itu adalah kesalahan pertama yang akan kamu buat. Ambil URL dari header, jangan menyusunnya sendiri.
/.well-known/oauth-authorization-server (RFC 8414):
{"issuer": "https://api.osfoundry.io",
"authorization_endpoint": "https://api.osfoundry.io/api/v1/external-oauth/authorize",
"token_endpoint": "https://api.osfoundry.io/api/v1/external-oauth/token",
"registration_endpoint": "https://api.osfoundry.io/api/v1/external-oauth/register",
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["none"],
"code_challenge_methods_supported": ["S256"],
"authorization_response_iss_parameter_supported": true}
Baca ini sebagai rangkaian keputusan. Ada registration_endpoint, jadi host bisa mendaftarkan dirinya sendiri (dynamic client registration) dan pengguna tidak pernah berurusan dengan client ID. Autentikasi token-nya none, jadi host adalah public client tanpa client secret, dan PKCE dengan S256 yang mengikat code ke client yang memintanya. iss di authorization response mencegah salah tukar saat sebuah host berbicara dengan beberapa authorization server.
Langkah authorize mengarahkan browser ke halaman consent osFoundry, https://osfoundry.io/external-assistant-authorize. 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).
Scope ditetapkan saat token diterbitkan. osfoundry:write 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".
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.
Tidak ada binary Claude yang terlibat. client.py berperan sebagai host dan server.py 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 initialize, tools/list, dan tools/call lewat JSON-RPC di atas HTTP biasa, dua dokumen well-known, /authorize (halaman consent otomatis disetujui untuk satu workspace), dan /token dengan PKCE S256. Beberapa route /admin/* menggantikan klik owner di tab Connected.
Pengecekan yang berjalan sebelum setiap tool:
def access(token_info, tool, note_id):
if TOOL_SCOPE[tool] not in token_info["scopes"]:
return "insufficient_scope"
if CHECK == "token_only": # design B: trust issue-time state
ok = note_id is None or NOTES[note_id]["project"] in token_info["projects_at_issue"]
return None if ok else "not_in_project"
conn = CONNECTIONS.get(token_info["connection"])
if not conn or conn["status"] != "active":
return "connection_revoked"
if note_id is None:
return None
note = NOTES.get(note_id)
if not note or note["workspace"] != conn["workspace"]:
return "not_found"
if (token_info["connection"], note["project"]) not in PROJECT_MEMBERS:
return "not_in_project"
return None
Client mulai tanpa token. Ia memanggil sebuah tool, menerima 401, mem-parse resource_metadata dari WWW-Authenticate, mengambil kedua dokumen metadata, menjalankan authorize dan token dengan PKCE verifier baru, lalu menjalankan skenarionya:
$ CHECK=per_call python3 server.py & python3 client.py 8767
discovery: first call -> 401, metadata /.well-known/oauth-protected-resource/mcp, 5 round trips, 11.3 ms
1 fresh connection, no project yet 200 OK []
2 after owner adds project A 200 OK ["note-a1"]
3 read note in A 200 OK "Q3 launch checklist"
4 read note in B (never added) 200 DENY not_in_project
5 write with a read-only token 403 DENY insufficient_scope
6 write with read+write token 200 OK "saved"
7 same read after note moved to B 200 DENY not_in_project
8 same read after owner removes project A 200 DENY not_in_project
9 same read after revoke 200 DENY connection_revoked
Client yang sama terhadap CHECK=token_only. Di sini token membawa daftar project dari refresh terakhirnya:
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"
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 expires_in: 3600 di rebuild ini.
Penolakan terbagi di dua lapisan. Scope yang kurang adalah kegagalan di level protokol: 403 dengan error="insufficient_scope" dan scope yang dibutuhkan di WWW-Authenticate, sehingga host bisa meminta lebih. Semua yang berkaitan dengan item mana yang terlibat adalah hasil tool biasa dengan isError: true. Otorisasi ulang tidak akan memperbaikinya, jadi tidak ada gunanya menyuruh host mencoba lagi.
Diukur dengan time.perf_counter_ns() di sekitar access() di dalam server, 1,000 panggilan per run (500 diizinkan, 500 ditolak), di Mac yang juga sedang menjalankan job lain:
| median access() | p99 access() | median tool call loopback | |
|---|---|---|---|
| cek per panggilan, 6 run | 1.3–1.6 µs | 6.1–7.0 µs | 0.43–0.78 ms |
| token-only, 3 run | 0.7–1.6 µs | 3.5–7.7 µs | 0.35–1.36 ms (2 run) |
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 initialize 790 ms, tools/list 816 ms, tools/call yang 401 565 ms, tiap dokumen metadata sekitar 520–530 ms). Waktu sebuah connector tidak habis di pengecekan akses.
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.
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".
Hal itu membentuk tool-nya. Dengan link_current_chat, 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.
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.
resource_metadata di WWW-Authenticate milik respons 401 dan sajikan RFC 9728 di /.well-known/oauth-protected-resource/<resource path>. Client yang menyusun URL sendiri bisa salah. Header itu menyelamatkan setiap client dari kesalahan tersebut.initialize dan tools/list dan menjaga tools/call. Kalau deskripsi tool kamu membocorkan sesuatu tentang tenant, jaga juga readOnlyHint per tool. Host bisa membangun prompt persetujuan dari situ, dan 403 insufficient_scope memberi host sesuatu yang konkret untuk diminta ulang.expected_version dan idempotency key. Host melakukan retry, dan model mengulang panggilan.
Sisi MCP dari spesifikasinya ada di halaman otorisasi MCP. Definisi singkat versi osFoundry ada di entri glosariumnya tentang MCP.