Signed-in MCP and OAuth
Every docs site serves an MCP endpoint for signed-in readers. An agent that connects to it reads the docs as the reader who approved it. A member reads what their role allows, and a guest reads only the published pages in the guest's scope. The endpoint uses OAuth 2.1, as the MCP authorization spec describes, so the agent needs no agent key.
https://{your-docs-host}/mcp/signed-in
Use this endpoint when a person connects their own agent to docs that are not public. Use https://{your-docs-host}/mcp for public pages, and an agent key for changes and analytics.
Connect a client
claude mcp add --transport http acme-docs https://docs.acme.com/mcp/signed-in
The first call returns 401. The client then runs the OAuth flow. It opens a ContextOwl page in the browser, the reader signs in and allows the client, and the client gets its tokens. Claude Code, Cursor, VS Code, Claude, and ChatGPT do these steps without more setup.
Find the authorization server
A call without a token returns 401 with a challenge that names the resource metadata:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://docs.acme.com/.well-known/oauth-protected-resource/mcp/signed-in", scope="docs:read"
The Protected Resource Metadata of RFC 9728 names the authorization server. It is the docs host itself:
curl https://docs.acme.com/.well-known/oauth-protected-resource/mcp/signed-in
{
"resource": "https://docs.acme.com/mcp/signed-in",
"authorization_servers": ["https://docs.acme.com"],
"scopes_supported": ["docs:read"],
"bearer_methods_supported": ["header"],
"resource_name": "Acme docs"
}
The authorization server metadata of RFC 8414 lists the endpoints:
curl https://docs.acme.com/.well-known/oauth-authorization-server
| Field | Value |
|---|---|
| issuer | https://docs.acme.com |
| authorization_endpoint | https://docs.acme.com/oauth/authorize |
| token_endpoint | https://docs.acme.com/oauth/token |
| registration_endpoint | https://docs.acme.com/oauth/register |
| revocation_endpoint | https://docs.acme.com/oauth/revoke |
| code_challenge_methods_supported | S256 |
| client_id_metadata_document_supported | true |
| authorization_response_iss_parameter_supported | true |
Identify the client
A client identifies itself in one of two ways:
- Client ID Metadata Document. The client_id is an https URL with a path. ContextOwl fetches the JSON document at that URL. The document must name the same client_id, a client_name, and the redirect_uris. Its token_endpoint_auth_method must be none, or the document must leave it out.
- Dynamic Client Registration. The client posts its metadata to the registration endpoint, as RFC 7591 describes. A registration with the same name and redirect URIs as an earlier one gets the same client_id.
curl -X POST https://docs.acme.com/oauth/register \
-H 'Content-Type: application/json' \
-d '{"client_name":"My agent","redirect_uris":["http://127.0.0.1:8976/callback"]}'
Every client is a public client. The token endpoint takes no client secret, and PKCE proves the client. A redirect URI must use https, http on a loopback address, or the URI scheme of a native app. A loopback redirect URI matches with any port.
Get a code
Send the reader to the authorization endpoint with a PKCE challenge of the S256 method, and with the endpoint as the resource:
https://docs.acme.com/oauth/authorize?response_type=code
&client_id=CLIENT_ID
&redirect_uri=http%3A%2F%2F127.0.0.1%3A8976%2Fcallback
&code_challenge=CHALLENGE
&code_challenge_method=S256
&state=STATE
&resource=https%3A%2F%2Fdocs.acme.com%2Fmcp%2Fsigned-in
The reader signs in, checks the client and the host of its redirect URI, and selects Allow or Deny. The redirect carries code, state, and iss. Compare iss with the issuer before you use the code, as RFC 9207 describes. A code expires after 2 minutes and works once.
Exchange the code
curl -X POST https://docs.acme.com/oauth/token \
-d grant_type=authorization_code \
-d code=CODE \
-d redirect_uri=http://127.0.0.1:8976/callback \
-d client_id=CLIENT_ID \
-d code_verifier=VERIFIER \
-d resource=https://docs.acme.com/mcp/signed-in
{
"access_token": "cowl_oat_...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "cowl_ort_...",
"scope": "docs:read"
}
Send the access token on every MCP request:
curl -X POST https://docs.acme.com/mcp/signed-in \
-H 'Authorization: Bearer cowl_oat_...' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Tokens
- An access token is valid for 1 hour, and only at the signed-in endpoint of the host that issued it.
- A refresh token is valid for 30 days and rotates on every use. Store the new refresh token each time. A second use of an old refresh token revokes the grant, and the reader must allow the client again.
- To end access, post the access token or the refresh token to the revocation endpoint, as RFC 7009 describes. The reader can also disconnect the client in Account > Connected agents.
- Access ends when the reader leaves the organization. A change to the role or the scope of the reader applies to the next request.
What the agent can do
The token has the docs:read scope. The agent gets the read tools of the public endpoint: search_docs, semantic_search, list_articles, get_article, list_changelog, get_changelog, list_workspaces, whoami, and report_content_gap. It reaches only the workspaces that allow AI agents. A guest reads only the published pages in the guest's scope, and whoami reports the role guest.
Errors
| Response | What to do |
|---|---|
| 401 with error="invalid_token" | The token expired, was revoked, or came from another host. Refresh it, or run the flow again. |
| 400 invalid_grant | The code or refresh token is not valid, expired, or was used. Run the flow again. |
| 400 invalid_target | The resource is not the signed-in endpoint of this host. Send the URL from the resource metadata. |
| 429 | The client sent too many requests. Wait for the Retry-After seconds, then retry. |