Problem
RFC 0005 put the platform on the Model Context Protocol: the gateway serves /mcp
(streamable HTTP, stateless) and offers an allowlist of public calls as tools. It works
for one kind of caller, the one who already holds a full API token and pastes it into
the assistant's configuration by hand. Everyone else is shut out, and the spec says why:
- An assistant cannot find where to sign in. MCP 2025-11-25 makes the server an
OAuth resource server. A client that gets
401readsWWW-Authenticatefor the address of the server's protected resource metadata (RFC 9728), and from it learns the authorization server. Our/mcpanswered401with nothing in that header and served no metadata, so claude.ai, ChatGPT and Cursor had nowhere to start. - The only token that worked could do everything.
/mcptook a token for the API audience holdingiohr.api, the scope that acts as the person on every route. There was no way to give an assistant the models and nothing else, or read tools and no writes. API tokens with catalogue scopes (RFC 0016) could not reach/mcpat all. - Tokens were not bound to the server. The spec requires a server to accept only tokens issued for it (RFC 8707 audience) and never to pass a client's token upstream. Our edge also left the issuer unpinned.
- Nothing told a client which tools change things. MCP tools carry hints
(
readOnlyHint,destructiveHint,idempotentHint,openWorldHint); a client treats a tool withoutreadOnlyHintas a write and asks the person every time, or refuses it. - Anyone could list the tools.
tools/listanswered without a caller. Harmless for today's list, wrong once the RFCs product's tools join it.
Our sign-in ( ) has dynamic client registration switched off, its consent page
grants a person iohr.api and the sign-in scopes only, and it implements neither RFC
8707 resource indicators nor Client ID Metadata Documents. Each of those decides how far
the work reaches in one step.
Proposal
The MCP server becomes a protected resource that any MCP client can connect to for any signed-in person, under scopes that the person grants and the gateway enforces per tool. Nine parts, built in phases.
A1. Discovery. The gateway serves the protected resource metadata without a token at
both addresses RFC 9728 and the MCP spec name, /.well-known/oauth-protected-resource
and /.well-known/oauth-protected-resource/mcp, on the API host:
{
"resource": "https://api.<domain>/mcp",
"authorization_servers": ["https://auth.<domain>"],
"scopes_supported": ["mcp:read", "mcp:write", "mcp:generate", "connections:use"],
"bearer_methods_supported": ["header"],
"resource_name": "InOrbit",
"resource_documentation": "https://docs.<domain>/docs/transports/mcp"
}
The resource is the server's canonical URL, and the authorization server is 's issuer. Both come from the gateway's configuration, the same way the API's public address does.
A2. Challenges. A request to /mcp without a valid token is 401 with
WWW-Authenticate: Bearer scope="mcp:read", resource_metadata="<the document>". The
edge answers it first, building the address from the request; the gateway answers the
same for a caller that reaches it without one. A call to a tool whose scopes the token
lacks is 403 with error="insufficient_scope", the scopes the tool needs and the
metadata address, so a client can ask the person for more (the spec's step-up flow).
The edge pins the issuer on the API's token providers.
A3. Audience binding. /mcp takes a token whose audience is the API's (iohr-api)
or its own canonical URL. Every other route keeps the API audience alone, so a token
minted for MCP opens /mcp and nothing else. The gateway checks the audience again,
exactly, behind the edge. A client's token goes no further than the gateway: the
services behind it receive the claims the edge verified, never the token.
A diagram is drawn here in the RFCs product; this page does not show diagrams yet.
A4. Registration. 's own dynamic registration stays off. The accounts service
offers a small registration endpoint (RFC 7591), published as the authorization server's
registration_endpoint in metadata the auth host serves ('s plus that endpoint,
code_challenge_methods_supported: ["S256"] and none among the token endpoint's
authentication methods). It accepts redirect URIs that are https or loopback, compared
exactly; grant types authorization_code and refresh_token only; no client secret;
scopes clamped to the MCP set; the MCP resource as the client's audience; access tokens
of 15 minutes and rotating refresh tokens. It creates the client through 's admin
API, marks it third-party, limits registrations per address and removes clients never
used within 30 days. Client ID Metadata Documents come later behind the same endpoint,
with a fetch that cannot reach private addresses, before the spec removes dynamic
registration.
A5. Consent. For a third-party client the consent page names the app, the host it redirects to (with a warning when that is only a loopback address), the account it will act for and the scopes in plain words. It grants the MCP resource as the audience, remembers the choice for 30 days and cannot be framed.
A6. Scopes. Three MCP scopes: mcp:read for tools that only read, mcp:write for
tools that change something, mcp:generate for tools that run a model. Product scopes
(connections:use, lab:read, lab:write) are checked per tool beside them. They are
in the accounts catalogue, so API tokens and keys may hold them, and consent may grant
them to a person. A full API token (iohr.api) and the site's workbench session meet
every scope, as before.
A7. The server. Every tool in the gateway's allowlist declares its MCP scope, the
product scopes it needs, and what it does: read-only, destructive, idempotent, open
world. The configuration is refused when a tool that is not read-only does not say
whether it destroys and whether a retry is safe, or when a read scope sits on a write.
The hints are sent as MCP tool annotations. tools/list needs a caller and lists only
what that caller may call. Later in the same part: outputSchema and
structuredContent where the call has a response schema, writes confirmed by
elicitation where the client supports it, the RFC documents as resource templates, and
protocol 2026-07-28 (server/discover, the Mcp-Method headers) once the Rust SDK
supports it. No sampling: 2026-07-28 deprecates it. CORS on /mcp allows MCP's headers
for the pages the API host already admits. The audit line gains the OAuth client id,
never content.
A8. RFC tools. The RFCs product's reads (spaces, documents, versions, timeline,
search, comments) and writes (save a draft, comment, review) join the allowlist under
lab:read and lab:write. Publishing stays a person's act in the console. Opening the
RFC API to every signed-in person is part of this; the RFCs product lifts its own gates
in its own change.
A9. Connected apps. Settings, under sign-in and security, lists the apps a person authorised: name, scopes, last use, and revoke, which ends the consent and the tokens.
A diagram is drawn here in the RFCs product; this page does not show diagrams yet.
Phases
- A1, A2, A3, A6 and the core of A7 (per-tool scopes, annotations,
401and403, listing only to a caller, CORS, the client id in the audit line). API tokens with MCP scopes work in any client that sends a header, Claude Code among them. - A4 and A5, then A9. Tested live with Claude Code, claude.ai's custom connectors, ChatGPT's developer mode and Cursor, each through our consent page.
- A8, resources and elicitation for writes.
What the spec says, and where
- MCP 2025-11-25, Authorization:
the server is an OAuth 2.1 resource server and MUST serve RFC 9728 metadata;
401withresource_metadatainWWW-Authenticate, ascopethere SHOULD guide the client;403witherror="insufficient_scope", the needed scopes andresource_metadatafor step-up; the server MUST accept only tokens issued for it and MUST NOT pass a client's token upstream; clients send RFC 8707'sresourceand use PKCE withS256. - MCP 2025-11-25, Tools: tool annotations, which are hints a client must not trust from an untrusted server.
- MCP 2026-07-28, Key changes:
removes sessions and the
initializehandshake, addsserver/discoverand theMcp-Method/Mcp-Nameheaders, deprecates dynamic client registration in favour of Client ID Metadata Documents, and deprecates sampling. - RFC 9728 (protected resource metadata), RFC 8707 (resource indicators), RFC 6750 (bearer challenges), RFC 7591 (dynamic registration), OAuth 2.1 and RFC 9700 (OAuth security best practice).
Alternatives considered
- 's own dynamic registration. It is built in and needs no code. It also takes whatever the client asks for: any grant type, any scope knows, any redirect URI, a client secret, and no audience of our choosing. Clamping all of that after the fact means a sweeper racing every registration. A small registration endpoint of our own refuses what we do not want before sees it.
- Client ID Metadata Documents first. The spec now prefers them and dynamic registration is deprecated. does not support them, so we would fetch and validate each client's document ourselves, a server-side fetch of a URL a stranger chose, with the SSRF care that needs. claude.ai and ChatGPT support dynamic registration today; it stays available for at least a year after deprecation. We build registration first and CIMD behind the same endpoint after.
- Only MCP-audience tokens on
/mcp. The strict reading of the spec. It would break every token in use today (Claude Code with an API token, the workbench) for no gain:/mcpis part of the API, the API audience is ours, and a token for it already acts for the same person under the same rights. The reverse is what matters, and is enforced: an MCP-audience token opens nothing but/mcp. - Scopes per tool instead of three MCP scopes. One scope per tool would make the consent page a list of fifty checkboxes and every new tool a new grant. Three scopes match what a person decides (may it read, may it change things, may it spend my model budget), and product scopes cover what a product must decide on its own.
- Sampling, to let the server ask the client's model. Deprecated in 2026-07-28 and unnecessary: the platform runs its own models. Letting an account run them on its own provider is a separate proposal.
- The audience as a fixed string instead of the URL. Simpler to check at the edge
(one exact value), but clients send the canonical URL as
resourceand ChatGPT expects it inaud. The edge matches the URL's shape and the gateway compares it exactly.
Decision
/mcp is an OAuth protected resource with per-tool scopes; tokens are audience-bound;
anonymous tool listing is removed. Phase 1 builds discovery, the challenges, the
audience binding, the three scopes on API tokens and at consent, and per-tool scopes and
annotations; registration, consent for third-party clients, the RFC tools and connected
apps follow as phases 2 and 3. This changes access control on a public surface and is
recorded with the platform's security controls.
Publication
The gateway's documentation describes the metadata, the challenges, the scopes and the annotations; the developer documentation's MCP page shows how to connect Claude Code with an API token and what an assistant discovers on its own; the changelog says when anonymous listing ended. Each phase adds its status line here.
Status log
- 2026-10-04: opened. Measured before:
/mcpanswered an unauthenticatedtools/listwith the tool list, answered other unauthenticated requests401with noresource_metadata, served no protected resource metadata, admitted only tokens withiohr.api, and its tools carried no annotations. - 2026-10-04: phase 1 built. The gateway serves the metadata at both addresses; any
request without a caller is
401with the challenge; a token for another audience is401 invalid_token; a tool whose scopes the token lacks is403 insufficient_scope;tools/listlists only what the caller may call; every tool carries its four hints. The edge pins the issuer, admits a token for the MCP resource on/mcponly, and answers401and403there with the challenge.mcp:read,mcp:writeandmcp:generateare in the accounts catalogue and grantable at consent. Tested against a real gateway and real backends, and the edge against tokens signed for the test. Not deployed yet. Known gap: the edge answers a token our sign-in minted for a third audience403(its RBAC refuses it) where the spec asks for401; the gateway's own answer is401. - 2026-10-04: phase 2 built. The accounts service registers an app's own client at the
sign-in's registration endpoint (RFC 7591): https or loopback redirects compared
exactly,
authorization_codeandrefresh_tokenonly, no secret, scopes from the MCP and product set, the MCP resource as the only audience, 15-minute access tokens and 30-day refresh tokens rotated on use, at most per address and replica behind 's . The auth host serves 's metadata at/.well-known/oauth-authorization-serverwith the registration endpoint, S256 andnoneadded; the issuer is 's. The consent page shows such an app on its own card (name, that nobody reviewed it, where it returns, a warning for loopback-only, the account, the scopes in English and Croatian), grants the MCP resource as audience from the request'sresourceor the client's registration, remembers 30 days and cannot be framed. The console lists the apps a person allowed under Sign-in and security, with last use from the token hook, and Revoke ends the consent and its refresh tokens. Clients never used within 30 days are removed. Tested against a fake (registration rules, limits, metadata, list and revoke, the sweep) and the consent step's audience rule; not deployed yet, and not yet tried live with each assistant. Known limits: ignores the port of a loopback redirect only for an IP, so a client onlocalhostmust register the port it uses; custom-scheme redirects (cursor://,vscode://) are refused; an access token issued before a revoke lives out its 15 minutes. - 2026-10-04: phase 3 built, the RFC tools (A8) and resources (A7). Eleven reads under
mcp:readandlab:read(spaces, documents, versions, timeline, comments, reviews, the review queue, diagrams) and eight writes undermcp:writeandlab:write(start a document, save a draft, comment, resolve a thread, ask for a review, ask for changes, start and save a diagram), each annotated, none destructive, titled and described as spaces. A save on a stale version is the tool's error with the current version's number, never an overwrite and never retried. Asking for changes is a tool and approving is not: the gateway holds the review'sdecisionto"changes"before the call is forwarded (allow_values, a new part of a tool's policy, also written into its input schema). Deciding, publishing, the spaces and their settings, deleting or renaming a diagram and the export stay off. Documents are one resource template,inorbit://spaces/{space}/documents/{document}, read as the caller under the scopes oflabs_get_document; nothing is listed as a resource. The labs service's own rule holds through MCP: a caller without the platform's admin role lists no internal space and gets "not found" for one. Tested against a real gateway and a real labs service on a fresh database; the chaos check now also fails a write that does not say it writes, or one offered to a token with read scopes only. Not deployed yet. Not done: elicitation before a write, andoutputSchema. - 2026-10-05: phases 2 and 3 live. Measured after the roll:
/mcpwithout a token is401withresource_metadata; the protected resource metadata names the sign-in as its authorization server; the sign-in's metadata carriesregistration_endpoint, S256 andnone; registration refuses a redirect that is neither https nor loopback (400 invalid_redirect_uri). Not yet tried end to end from claude.ai, ChatGPT, Cursor or VS Code, which is the next step. The RFCs product's API is being renamed (RFC 0054): the tools keep theirlabs_*names until a follow-up moves them, with the old names answering for one release. - 2026-10-05: tools moved to
rfcs_*andrfc:*(RFC 0054), built, not deployed (the cluster is frozen). The nineteen RFC tools are nowrfcs_<method>fromiohr.rfcs.v1.RfcsService, withspace_id(rfcs_list_spacesandrfcs_get_spacefor the oldlabs_list_labsandlabs_get_lab), underrfc:readandrfc:writebesidemcp:readandmcp:write, with the same hints and the same hold ondecision; the document resource reads throughrfcs_get_document, and the protected resource metadata namesrfc:readandrfc:write. A token with the oldlab:readorlab:writestill works. The oldlabs_*names are deprecated for one release: they still answer, keep their successor's policy (the gateway refuses any other) and are listed as deprecated with the name to call instead; they go with the old proto service. Tested against a real gateway and labs service, with a token holding onlyrfc:readand one holding onlylab:read. - 2026-10-07: client ID metadata documents started at the owner's request, by another session: a client named by the URL of its metadata document, fetched with the same guards as any outbound request, shown on the same consent card and audited. A spike against a throwaway copy of the sign-in proved a URL client id, the authorization code flow with S256 and a token without a secret. Not built yet. The Claude Code plugin that signs in through this server is tracked in RFC 0040.14. The end-to-end run from each assistant still waits on the owner's browser.
- 2026-10-07: Checked: phases 1 to 3 and the
rfcs_*names (#247, #264, #315) run in protocol, accounts and the sign-in pages. Not done: the live tests with claude.ai, ChatGPT, Cursor and VS Code that phase 2 names, and elicitation before writes. Open.