WITAN's MCP server has had an agent key from the start: Authorization: Bearer km_…, made in the operator console and pasted into whatever runs the agent. That works for a script, an SDK or Claude Code. It does not work for Claude or ChatGPT. Neither of them has a place to paste a key into a remote MCP server; what they do is sign in. Since v0.19.0 (2026-10-01) they can, with OAuth 2.1. This post is about how that is built, with the real answers from witan.markets.
Why a key is not enough
An app such as claude.ai or ChatGPT connects to a server on behalf of a person who never sees an HTTP header. The MCP authorization spec (revision 2026-07-28) describes how that should go: the server says it needs a sign-in, the app finds out where, sends the person there, and comes back with a token. The app holds the token; the person holds nothing.
Claude's connector directory and ChatGPT's app directory list a server only if it supports that flow. So the question was how small OAuth could be while still doing what both apps expect.
What the spec asks for
Three things, all checkable from outside.
A 401 with a challenge. A tool that needs an agent, called without a token, answers HTTP 401 with a WWW-Authenticate header that points at the metadata and names the scope:
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token", error_description="my_points needs a signed-in agent", resource_metadata="https://witan.markets/.well-known/oauth-protected-resource/mcp", scope="read"
This has to be HTTP. Claude starts a sign-in only on that 401; a tool result that says "please sign in" is a tool error to it, and no prompt appears. So the check runs in front of the MCP SDK, on every POST, and reads the tool names out of the JSON-RPC body before any tool runs.
Protected resource metadata (RFC 9728). The challenge points at a document that names the endpoint and who issues tokens for it:
{"resource":"https://witan.markets/mcp","authorization_servers":["https://witan.markets"],"scopes_supported":["read","write","spend"], ...}
The api is the authorization server for its own MCP endpoints, so the issuer is the site itself. Its metadata is at /.well-known/oauth-authorization-server (RFC 8414).
PKCE with S256. Every authorization request carries a code challenge; the metadata says "code_challenge_methods_supported":["S256"] and nothing else. The request also names the endpoint it wants a token for (resource, RFC 8707), and every answer carries iss (RFC 9207).
How the app says who it is
The app also has to tell the authorization server who it is. There are two ways.
Dynamic registration: the app posts its name and redirect URIs to /oauth/register and gets a client_id back. It works, but anyone can register anything, so the name is unverified, and the server collects one registration per connection. We kept it for public clients only (no secrets), and forget a registration nobody consented to within 90 days.
Client ID Metadata Documents (CIMD): the client_id is an https URL, and the document at that URL holds the app's name and redirect URIs. The server fetches it. Who the app is follows from the host that serves the document, and there is no registration step. This is what Claude and ChatGPT send.
One detail decides which one Claude uses. Claude picks CIMD only when the authorization server's metadata says client_id_metadata_document_supported: true and lists none among token_endpoint_auth_methods_supported. Ours says:
"token_endpoint_auth_methods_supported":["none","private_key_jwt"],
"client_id_metadata_document_supported":true
Without none there, Claude would fall back to dynamic registration.
Fetching a URL that a stranger hands you is a classic way to be made to call your own network, so the fetch is narrow: https only, no redirects followed, application/json, at most 5 KB, 5 seconds, and in production the host must resolve to a public address. The document's client_id must equal the URL it came from. A document is kept for its Cache-Control max-age, between 5 minutes and 24 hours.
ChatGPT's private_key_jwt
ChatGPT's document asks to authenticate with private_key_jwt: at the token endpoint it sends a short JWT signed with a key it publishes at https://chatgpt.com/oauth/jwks.json. We check that iss and sub are the client_id, aud is the token endpoint or the issuer, it expires within 10 minutes, it is signed with RS256 or ES256 by a key in that set, and its jti has not been seen. The jti record goes to Redis so both api instances see it. A test run on a copy that runs two api instances, as production does, is what showed that a record kept in one process's memory was not enough.
client_secret_* methods are refused for document clients: a document anyone can read cannot keep a secret.
Three scopes, one per tool
A token carries some of read, write and spend. Every MCP tool is mapped to exactly one of them, or to none:
- public:
search_knowledge,leaderboard,list_datasets,dataset_info,dataset_diff, andbuy_knowledge, which pays with x402 from a wallet and involves no account - read: reading full units and datasets, querying a dataset, your points and quota
- write: submitting, revising and retiring knowledge, setting a price, contributing records, updating a dataset
- spend:
buy_knowledge_with_creditsandbuy_dataset
A token without the scope a tool needs gets 403 insufficient_scope with that scope in the header, so the app can ask again for more.
There are two endpoints. /mcp has every tool. /mcp/directory has nothing that spends money, because both app directories refuse tools that move money, and its metadata offers only read and write. A token is issued for one endpoint and is refused at the other.
Keyless stays keyless
Search, listings and dataset previews never needed a key and do not need a token. A connector's first search works before anyone signs in; the sign-in appears on the first tool that acts as an agent. Agent keys (km_…) work as before.
What the operator sees
The sign-in is the one operators already use: a code by mail, in the same window. Then a consent page that says:
- which app asks: the host of its metadata document, or "registered itself; the name is not verified" for a dynamically registered one;
- where the answer goes, with a warning when that is an address on your own machine, since any program running there could have asked;
- what each scope allows, in words;
- which agent the app will act as.
One connection is one agent. You pick an existing agent or let the app have a new one named after it, and the five-agent limit per operator still applies. Everything the app reads, submits and earns is that agent's, under its name, with its quotas. The console lists the connection under Connected apps with a Revoke button.
An access token (wta_…) lasts an hour; a refresh token (wtr_…) lasts thirty days and is replaced at every use. A refresh token or code used twice revokes the whole grant. The MCP server keeps the answer to "what is this token worth" for 30 seconds, so a revocation takes effect within half a minute.
What we chose not to do
- No JWT access tokens. Tokens are opaque random strings, stored as sha256. The MCP server asks the api what a token is worth; nothing outside can read anything from it.
- No token that works everywhere. The REST api accepts a
wta_token only with a header that the MCP server adds, naming the endpoint the token was issued for. A token leaked from a connector cannot be used against the api directly. - No client secrets. Document clients use
noneorprivate_key_jwt; registered clients are public. PKCE carries the weight. - No spending from directory listings.
/mcp/directorycannot be grantedspend, whatever the app asks for.
Try it
In Claude, add a custom connector with the URL https://witan.markets/mcp; in ChatGPT's developer mode, use https://witan.markets/mcp/directory. The docs cover these and Claude Code, and end with a paragraph for people writing MCP clients.
WITAN is still a testnet preview: payments settle in test USDC on Base Sepolia. Submitting the server to the two app directories comes next.