DEV Community

Cover image for Wie findet ein Claude Custom Connector den OAuth-Server von osFoundry, und warum wird der Zugriff bei jedem Aufruf geprüft?
BBT for BBT Deutsch

Posted on

Wie findet ein Claude Custom Connector den OAuth-Server von osFoundry, und warum wird der Zugriff bei jedem Aufruf geprüft?

Das bekommt ein Remote-MCP-Client zurück, wenn er ein Tool von osFoundry ohne Token aufruft. Die Anfrage wurde am 2026-11-06 mit curl von einem Mac in Tokio geschickt:

$ 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

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.

Was sieht der Client, bevor er ein Token hat?

Drei Aufrufe, drei verschiedene Antworten:

Anfrage Status Was zurückkommt
initialize 200 Protokoll 2025-06-18, Server osfoundry 0.1.0, dazu Instructions
tools/list 200 59 Tools, 45,368 Bytes
tools/call 401 WWW-Authenticate mit Verweis auf die Resource Metadata
GET auf den Endpoint 405 kein SSE-Stream per GET

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 instructions-String in initialize ist generisch:

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

Jedes Tool in tools/list gibt an, welchen OAuth-Scope es braucht und ob es nur liest:

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

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

Wohin schickt ihn der 401?

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

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

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.

Was sagt der Authorization Server über sich selbst?

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

Lies das als Kette von Entscheidungen. Es gibt einen registration_endpoint, 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 none, also ist der Host ein Public Client ohne Client Secret, und PKCE mit S256 bindet den Code an den Client, der ihn angefordert hat. iss in der Authorization Response schützt vor Verwechslungen, wenn ein Host mit mehreren Authorization Servern spricht.

Der Authorize-Schritt schickt den Browser auf die Consent-Seite von osFoundry, https://osfoundry.io/external-assistant-authorize. 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).

Warum reicht der Scope nicht?

Ein Scope steht fest, sobald das Token ausgestellt wird. osfoundry:write 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".

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.

Der Nachbau

Es ist kein Claude-Binary beteiligt. client.py spielt den Host und server.py 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 initialize, tools/list und tools/call über einfaches HTTP-JSON-RPC, die beiden Well-known-Dokumente, /authorize (die Consent-Seite wird für einen Workspace automatisch bestätigt) und /token mit S256-PKCE. Ein paar /admin/*-Routen ersetzen die Klicks des Owners im Tab Connected.

Die Prüfung, die vor jedem Tool läuft:

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

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

$ 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

Derselbe Client gegen CHECK=token_only. Hier trägt das Token die Projektliste vom letzten 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

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 expires_in: 3600.

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

Was kostet die Prüfung pro Aufruf?

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

access() Median access() p99 Loopback-Tool-Aufruf, Median
Prüfung pro Aufruf, 6 Läufe 1.3–1.6 µs 6.1–7.0 µs 0.43–0.78 ms
nur Token, 3 Läufe 0.7–1.6 µs 3.5–7.7 µs 0.35–1.36 ms (2 Läufe)

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: initialize Median 790 ms, tools/list 816 ms, der 401-tools/call 565 ms, jedes Metadaten-Dokument etwa 520–530 ms). Die Zugriffsprüfung ist nicht der Punkt, an dem ein Connector seine Zeit verbringt.

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.

Warum kann Claude osFoundry aufrufen, aber osFoundry deine Claude-Chats nicht öffnen?

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

Das prägt die Tools. Bei link_current_chat 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.

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.

Wenn du das selbst verdrahtest

  • Pack resource_metadata in den WWW-Authenticate des 401 und liefere RFC 9728 unter /.well-known/oauth-protected-resource/<resource path> aus. Ein Client, der die URL selbst baut, kann sie falsch bauen. Der Header erspart das jedem Client.
  • Leg fest, was vor der Anmeldung öffentlich ist. osFoundry gibt initialize und tools/list frei und schützt tools/call. Wenn deine Tool-Beschreibungen etwas über Tenants verraten, schütz auch tools/list.
  • Deklarier pro Tool einen Scope und readOnlyHint. Hosts können daraus Bestätigungsdialoge bauen, und ein 403 insufficient_scope gibt dem Host etwas Konkretes, das er neu anfordern kann.
  • 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.
  • Gib Ablehnungen auf Ressourcenebene als Tool-Ergebnisse zurück, nicht als 401/403. Ein anderes Token behebt "diese Note liegt in Projekt B" nicht.
  • Lass Schreibvorgänge expected_version und einen Idempotency Key mitschicken. Hosts wiederholen Anfragen, und Modelle setzen Aufrufe erneut ab.

Die MCP-Seite der Spezifikation steht auf der Autorisierungsseite von MCP. Die kurze Definition von osFoundry steht im Glossareintrag zu MCP.

Top comments (0)