DEV Community

Cover image for Come fa un connettore personalizzato di Claude a trovare il server OAuth di osFoundry, e perché l'accesso viene controllato a ogni chiamata?
BBT for BBT Italiano

Posted on

Come fa un connettore personalizzato di Claude a trovare il server OAuth di osFoundry, e perché l'accesso viene controllato a ogni chiamata?

Ecco cosa riceve un client MCP remoto quando chiama uno strumento di osFoundry senza token. La richiesta è stata inviata con curl da un Mac a Tokyo il 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\", ..."]}}}
Enter fullscreen mode Exit fullscreen mode

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.

Cosa vede il client prima di avere un token?

Tre chiamate, tre risposte diverse:

Richiesta Stato Cosa torna indietro
initialize 200 protocollo 2025-06-18, server osfoundry 0.1.0, più le istruzioni
tools/list 200 59 strumenti, 45,368 byte
tools/call 401 WWW-Authenticate che punta ai metadati della risorsa
GET sull'endpoint 405 nessuno stream SSE in GET

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 instructions in initialize è generica:

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

Ogni strumento in tools/list dichiara lo scope OAuth che gli serve e se fa solo letture:

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

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

Dove lo manda il 401?

resource_metadata 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:

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
Enter fullscreen mode Exit fullscreen mode
{"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"]}
Enter fullscreen mode Exit fullscreen mode

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

Cosa dice di sé il server di autorizzazione?

/.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}
Enter fullscreen mode Exit fullscreen mode

Leggilo come una catena di decisioni. C'è un registration_endpoint, 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 è none, quindi l'host è un client pubblico senza client secret, ed è PKCE con S256 a legare il codice al client che l'ha richiesto. iss nella risposta di autorizzazione evita scambi quando un host parla con più server di autorizzazione.

Il passo di authorize porta il browser sulla pagina di consenso di osFoundry, https://osfoundry.io/external-assistant-authorize. 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).

Perché lo scope non basta?

Uno scope viene fissato quando il token viene emesso. osfoundry:write 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".

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.

La ricostruzione

Non c'è nessun binario di Claude di mezzo. client.py fa da host e server.py 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 initialize, tools/list e tools/call su JSON-RPC via HTTP semplice, i due documenti well-known, /authorize (la pagina di consenso si approva da sola per un workspace) e /token con PKCE S256. Alcune route /admin/* sostituiscono i clic del proprietario nella scheda Connected.

Il controllo che gira prima di ogni strumento:

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
Enter fullscreen mode Exit fullscreen mode

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

$ 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
Enter fullscreen mode Exit fullscreen mode

Lo stesso client contro CHECK=token_only. Qui il token porta l'elenco dei progetti dell'ultimo refresh:

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

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è expires_in: 3600 in questa ricostruzione.

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

Quanto costa il controllo a ogni chiamata?

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

access() mediana access() p99 chiamata a strumento in loopback, mediana
controllo a ogni chiamata, 6 esecuzioni 1.3–1.6 µs 6.1–7.0 µs 0.43–0.78 ms
solo token, 3 esecuzioni 0.7–1.6 µs 3.5–7.7 µs 0.35–1.36 ms (2 esecuzioni)

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: initialize mediana 790 ms, tools/list 816 ms, il tools/call con 401 565 ms, ogni documento di metadati circa 520–530 ms). Non è nel controllo degli accessi che un connettore passa il suo tempo.

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.

Perché Claude può chiamare osFoundry, ma osFoundry non può aprire le tue chat di Claude?

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".

Questo dà forma agli strumenti. Con link_current_chat è 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.

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.

Se lo colleghi tu

  • Metti resource_metadata nel WWW-Authenticate del 401 e servi la RFC 9728 su /.well-known/oauth-protected-resource/<resource path>. Un client che costruisce l'URL a mano può sbagliarlo. L'header risparmia questo errore a ogni client.
  • Decidi cosa è pubblico prima del login. osFoundry espone initialize e tools/list e protegge tools/call. Se le descrizioni dei tuoi strumenti lasciano trapelare qualcosa sui tenant, proteggi anche tools/list.
  • Dichiara uno scope e readOnlyHint per ogni strumento. Gli host possono costruirci sopra le richieste di conferma, e un 403 insufficient_scope dà all'host qualcosa di concreto da richiedere di nuovo.
  • 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.
  • Restituisci i rifiuti sulle risorse come risultati dello strumento, non come 401/403. Un altro token non sistemerà "questa Note è nel progetto B".
  • Fai in modo che le scritture portino expected_version e una chiave di idempotenza. Gli host ritentano, e i modelli ripetono le chiamate.

La parte MCP della specifica è nella pagina sull'autorizzazione di MCP. La definizione breve di osFoundry è nella sua voce di glossario su MCP.

Top comments (0)