Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 0 ๐Ÿ“– 9 min read

How does a Claude custom connector find osFoundry's OAuth server, and why is access checked on every call?

This is what a remote MCP client gets when it calls an osFoundry tool without a token. The request was sent with curl from a Mac in Tokyo on 2026-11-06: $ curl -s -D - -X POST https://api.osfoundry.io/api/v1/external-

This is what a remote MCP client gets when it calls an osFoundry tool without a token. The request was sent with curl from a Mac in Tokyo on 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\", ..."]}}}

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.

What does the client see before it has a token?

Three calls, three different answers:

Request Status What comes back
initialize 200 protocol 2025-06-18, server osfoundry 0.1.0, plus instructions
tools/list 200 59 tools, 45,368 bytes
tools/call 401 WWW-Authenticate pointing at the resource metadata
GET on the endpoint 405 no SSE stream on GET

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 instructions string in initialize is generic:

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

Every tool in tools/list declares the OAuth scope it needs and whether it only reads:

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

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

Where does the 401 send it?

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

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"]}

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.

What does the authorization server say about itself?

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

Read it as a chain of decisions. There's a registration_endpoint, so the host can register itself (dynamic client registration) and the user never handles a client ID. Token auth is none, 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. iss in the authorization response guards against mix-ups when a host talks to several authorization servers.

The authorize step sends the browser to osFoundry's consent page, https://osfoundry.io/external-assistant-authorize. 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).

Why isn't the scope enough?

A scope is fixed when the token is minted. osfoundry:write 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".

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.

The rebuild

No Claude binary is involved. client.py plays the host and server.py 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 initialize, tools/list and tools/call over plain HTTP JSON-RPC, the two well-known documents, /authorize (the consent page is auto-approved for one workspace), and /token with S256 PKCE. A few /admin/* routes stand in for the owner's clicks in the Connected tab.

The check that runs before every 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

The client starts with no token. It calls a tool, takes the 401, parses resource_metadata out of WWW-Authenticate, fetches both metadata documents, runs authorize and token with a fresh PKCE verifier, and then works through the 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

The same client against CHECK=token_only. Here the token carries the project list from its last 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"

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

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

What does the per-call check cost?

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

access() median access() p99 loopback tool call, median
per-call check, 6 runs 1.3โ€“1.6 ยตs 6.1โ€“7.0 ยตs 0.43โ€“0.78 ms
token-only, 3 runs 0.7โ€“1.6 ยตs 3.5โ€“7.7 ยตs 0.35โ€“1.36 ms (2 runs)

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: initialize median 790 ms, tools/list 816 ms, the 401 tools/call 565 ms, each metadata document about 520โ€“530 ms). The access check isn't where a connector spends its time.

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.

Why can Claude call osFoundry, but osFoundry can't open your Claude chats?

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

That shapes the tools. link_current_chat 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.

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.

If you wire this up yourself

  • Put resource_metadata in the 401's WWW-Authenticate and serve RFC 9728 at /.well-known/oauth-protected-resource/<resource path>. A client that builds the URL by hand can get it wrong. The header saves every client from that.
  • Decide what's public before sign-in. osFoundry exposes initialize and tools/list and guards tools/call. If your tool descriptions leak anything about tenants, guard tools/list too.
  • Declare a scope and readOnlyHint per tool. Hosts can build approval prompts from them, and a 403 insufficient_scope gives the host something concrete to re-request.
  • 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.
  • Return resource denials as tool results, not as 401/403. A different token won't fix "this Note is in project B".
  • Make writes carry expected_version and an idempotency key. Hosts retry, and models re-issue calls.

The MCP side of the spec is on the MCP authorization page. osFoundry's own short definition is in its glossary entry for MCP.

๐Ÿ“ฐ Read the original article on Dev.to Security

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