Docs How it works
MCP server
Status: implemented and working. Verified against the MCP Go SDK's client, by hand over HTTP, by an automated end-to-end OAuth test, and by connecting Claude Code to a local instance.
CoDNA's reason for existing. An agent connects on behalf of a person and gets exactly that person's visibility — no more, and the same rules the web UI enforces.
The endpoint adds no rules of its own. Every tool resolves an Actor from the
bearer token and calls the same services the REST API calls, so the two cannot
drift apart.
POST /mcp Streamable HTTP, bearer auth
GET /.well-known/oauth-protected-resource RFC 9728 discovery, public
Authentication
CoDNA is an OAuth resource server (adr/0012). It validates tokens from your identity provider and issues none itself.
The discovery flow, as a client sees it:
1. POST /mcp with no token
→ 401
WWW-Authenticate: Bearer resource_metadata="https://codna.example.com/.well-known/oauth-protected-resource"
2. GET that URL
→ { "resource": "https://codna.example.com/mcp",
"authorization_servers": ["https://idp.example.com"],
"scopes_supported": ["openid", "email", "profile"] }
3. Authenticate against the identity provider, then retry with the token.
Tokens must carry an email claim. CoDNA keys identities on
(issuer, subject), but the first time it sees a subject it needs an email to
attach it to an account. Request openid email profile; openid alone yields a
token CoDNA rejects, and says so in the log.
The exception is an agent connected in its own right (agents.md): a service
account whose subject was linked when management made the agent, so its client-credentials token
needs no email. It is resolved to the agent, not to a person, and is given a cut tool list and its
own instructions on connect: search and read what was shared with it, and write its own pages in
output. A tool outside that list is not listed, and refused if called.
Rejections are logged with their reason and never with the token:
level=WARN msg="mcp token rejected" reason="unknown subject and no email claim to link it by"
Service tokens are refused here. CoDNA's codna_… tokens are management's: a console or
other tooling uses them to run the instance through the REST API, and they cannot read a document
anywhere (permissions.md). /mcp rejects them by prefix with a
401 that says so. An agent must hold a token from the identity provider, so that it acts for a
person, and revoking that person there revokes their agent too.
Connecting a client
Each person finds the address and a command for their client on the web UI's
Connect an AI agent page; Connect your AI agent is the same for every
client, step by step. It is built from the mcp object on
GET /api/instance, which is present whenever the endpoint is on and names a
client to use when the provider cannot register one itself
(CODNA_MCP_CLIENT_ID, CODNA_MCP_CALLBACK_PORT,
CODNA_MCP_CLIENT_SECRET_REQUIRED; see
deploy/README.md). CoDNA never holds a client
secret or shows one.
Against a real identity provider. If it supports Dynamic Client
Registration — Okta, Auth0, Keycloak and Authentik do — a client can complete
the flow above unattended. Point it at https://codna.example.com/mcp.
Google Workspace does not support DCR. Register a client by hand there and configure it in the MCP client directly.
The page also says when the person's agent last called a tool, and so does
agent_last_call_at on GET /api/auth/me; the home page's Get started list
ticks "Connect your AI agent" from it. It is read from the usage counts, so
calls made before counting began do not show.
Claude and ChatGPT
Both find the identity provider from the 401, register themselves, and sign the person in with PKCE. What the provider has to allow:
- Claude — claude.ai, the desktop and phone apps, and Cowork — comes back to
https://claude.ai/api/mcp/auth_callback; Anthropic says it may becomehttps://claude.com/api/mcp/auth_callback, so allow both. Claude Code comes back to a loopback address on any port, or toclaude://claude.ai/...from the desktop app. It registers with Dynamic Client Registration, or uses its published Client ID Metadata Document when the provider advertisesclient_id_metadata_document_supportedandnoneamong its token endpoint auth methods. In Claude's Add custom connector dialog, choose Register automatically when the provider has no CIMD. It asks for the scopes in CoDNA's resource metadata, plusoffline_accesswhen the provider lists it, so its sign-in outlasts the browser session. (Anthropic: authentication for connectors) - Codex — the CLI, and the ChatGPT desktop app, which includes it — comes back to a loopback address. It registers asking for every scope the provider's metadata lists, so the provider must list only scopes a registering client may have. (OpenAI: Codex and MCP)
- ChatGPT, in developer mode, comes back to
https://chatgpt.com/connector_platform_oauth_redirectwhen the provider returnsissin its authorization responses (RFC 9207), and otherwise tohttps://chatgpt.com/connector/oauth/{callback_id}. It registers with DCR, or with CIMD where offered, and sendsresourceon its requests. It lets an app write only on Business, Enterprise and Edu plans. (OpenAI: authentication)
With Keycloak, one realm per instance, the anonymous client-registration policies need:
- trusted hosts
claude.ai,claude.com,chatgpt.com,localhostand127.0.0.1, with client URIs required to match them; openidamong the allowed client scopes, which every MCP client asks for and Keycloak refuses by default;- consent required, so the person sees what is asking;
- discovery listing only
openid,profile,emailandoffline_access: every other OIDC client scope getsinclude.in.openid.provider.metadatafalse (Keycloak 26.8). Codex asks for everything listed, and anonymous registration refusesservice_account, so a full list stops it before sign-in; Claude still findsoffline_accessthere.
A client that registered itself has its own id, so the token's audience comes
from a default client scope with an audience mapper naming <public URL>/mcp,
and CoDNA runs with CODNA_OIDC_AUDIENCE set to the same. DCR clients pile
up, one per connection, and Keycloak's anonymous policy caps them (200 by
default).
Claude Code, locally
Claude Code can be told a client id and a fixed callback port, which is exactly what Dex needs given it cannot register clients itself:
claude mcp add --transport http \
--client-id codna-mcp \
--client-secret \
--callback-port 33418 \
codna http://localhost:8080/mcp
Options come before the server's name and address, as claude mcp add --help
shows them; leave out --client-secret for a public client.
It prompts for the secret; locally that is codna-dev-mcp-secret, from
deploy/dev/dex.yaml. Then run /mcp inside Claude
Code to complete the browser sign-in as admin@codna.local (password
codna-dev-password). This works today.
Tokens last 24 hours, so a 401 appearing in the log after a while is usually a client reconnecting and re-running discovery, not an expiry.
Port 33418 matches the redirect URIs registered for the codna-mcp client.
Clients vary in the callback path they use and in whether they say localhost
or 127.0.0.1, so several combinations are registered — a mismatch surfaces
only as an opaque invalid redirect_uri, which is a miserable thing to debug.
Locally, against Dex — with a caveat. podman compose --profile dev up -d
starts Dex with static clients defined in
deploy/dev/dex.yaml, including redirect URIs for
local callbacks.
Dex does not support Dynamic Client Registration at all. Its discovery
document advertises no registration_endpoint, and POST /register returns
404 — confirmed, not assumed. A client that expects to register itself will
fail, so it has to be told the client id (codna-mcp) and secret
(codna-dev-mcp-secret) directly, as above. A client with no way to be told
those cannot connect to a local Dex at all.
This only affects local development. Okta, Auth0, Keycloak and Authentik all support DCR, so a real deployment behind any of them can be discovered automatically.
What is actually verified
The OAuth handshake is exercised end to end by a test, against a real Dex, not
assumed: TestOAuthAuthorizationCodeFlow in
internal/mcpserver walks resource-metadata discovery,
authorization-server metadata, PKCE with S256, the authorization request, the
connector login, the code exchange, and finally an MCP initialize with the
resulting token. It skips unless Dex and CoDNA are both running:
podman compose --profile dev up -d
make run
go test ./internal/mcpserver/ -run TestOAuthAuthorizationCodeFlow
Every other test signs its own tokens, which proves CoDNA verifies correctly but says nothing about whether a client can obtain one. That handshake involves three parties and is where MCP integrations usually break.
Claude Code connects successfully against a local Dex, using the command above. The server log shows the expected sequence: a 401 challenge, the OAuth round trip, then authenticated tool calls with no token rejections.
Against Keycloak 26.8, on a hosted instance with the realm settings above:
Claude Code and Claude's desktop app registered themselves and work (October
2026). On 2026-10-15 a claude.ai custom connector registered as "Claude",
signed in and connected, and Codex in the ChatGPT desktop app registered as
"Codex", signed in and called whoami, list_documents and get_document.
ChatGPT's web connector has not been tried yet.
Claude's apps have no equivalent of --client-id in the simple case, so
against an identity provider without registration they need Use your own
OAuth client in the connector dialog, with a client registered by hand for
https://claude.ai/api/mcp/auth_callback.
Tools
Split by what they do to the knowledge base. Each one, with its inputs and what it answers with, is in MCP tools.
| Tool | What it does | |
|---|---|---|
whoami |
read | Who this session acts for, and how many groups they are in |
search |
read | Full-text across everything visible, typo tolerant, optionally within a folder; pages with page, saying more |
list_documents |
read | Browse by space, folder (a folder includes its subfolders; eng never matches eng-old), or tag; up to 200 a page, with more and next_offset |
get_document |
read | Read a document as markdown, with its version, owner and files; or only its outline, or one section |
get_file |
read | Look at an image a page shows, given the page and its link or name; returned as an image, scaled to fit 1568 pixels. A PDF is described, not sent (files.md) |
document_history |
read | What changed, when, why, and who changed it |
backlinks |
read | What links here, filtered to what the caller can see |
preview_share |
read | What a document, several, or folders link to outside the set, so a recipient gets its dependencies; for a set, counts for all of it and short lists (the pages it would add, the most linked-to suggestions). Grants nothing. |
recent_access |
read | What was shared with this user recently |
list_access |
read | Who a document is shared with, how, and by whom; its owner and editors only |
shared_with |
read | What this person gave someone else: their own documents, and others' they shared as an editor, and through which shares, optionally within a folder |
list_ingestions |
read | What this user has fed into raw, failures included |
create_document |
write | New document in wiki or output |
update_document |
write | Replace a whole page, or its title or tags |
edit_document |
write | Change part of a page without sending it back: a list of text-anchored edits saved as one revision, answered with the diff; dry_run saves nothing |
revert_document |
write | Bring back an earlier version as a new revision |
move_document |
write | Change a document's address; its own relative links are rewritten to reach the same documents |
delete_document |
write | Tombstone a document |
share_document |
write | Grant access to a person, a group, or everyone |
share_folder |
write | Share folders of this person's with people or groups, standing: what is there now and what is put there later |
unshare_folder |
write | End a folder share; optionally take back what it shared |
list_folder_shares |
read | Which folders this person shares, with whom |
ingest_text |
write | File given material into raw, as supplied |
list_groups |
read | Teams that can be shared with, and which this person is in; everyone is the whole organisation |
list_automations |
read | Scripts running for this person, and the trigger vocabulary |
automation_runs |
read | What a script proposed, what was applied, and why the rest was refused |
create_automation |
write | Register a script, switched off by default; the description carries the full script contract |
get_automation |
read | One automation in full, its script included |
update_automation |
write | Change a script, its triggers or schedule, scope or name in place, keeping its id and runs |
run_automation |
write | Run one now: on a document, on everything already in its scope, or as a tick; dry_run reports what it would do and changes nothing |
set_automation_enabled |
write | Turn a script on once the person has read it |
delete_automation |
write | Remove a script and its history |
pending_decisions |
read | Everything waiting on this person about links: stale or broken ones, and proposed ones |
update_link, drop_link |
write | Resolve links left stale or broken by a move or delete |
keep_link |
write | Leave a moved link as written; it still reaches its document, and stops being asked about |
related_documents |
read | Candidates to link from a document, by shared tags and co-citation |
propose_link |
write | Suggest a link; nothing changes until someone who can edit the source decides |
decide_link_suggestion |
write | Record the person's yes or no; yes adds the link as an ordinary edit |
Read-only tools carry readOnlyHint, so a client can run them without
prompting and confirm the rest. Marking a writing tool read-only would let it
run unattended, which is why the annotations are asserted in tests rather than
merely set.
Editing
Measured on agents keeping a wiki over MCP before edit_document existed: 40 saves, every one
sending the whole page back (5–27 KB), changing a median of 3% of it in a dozen scattered places,
with five full reads per save. Nothing failed; it was slow, expensive, and one slip away from
dropping a paragraph while copying 27 KB. So:
- An edit is a list.
edit_documenttakes every change to a page at once —replace,insert_after,insert_before,append,replace_section,delete_section— applies them in order, each to the page as the ones before left it, and saves one revision, or nothing if any cannot apply. A new title or tags can go in the same revision. - Text, not line numbers. Each edit names the exact text it changes or sits beside, so it means
the same thing on a page somebody edited in between, and fails loudly when it no longer can. An
edit therefore needs no
expected_version; it is applied to the page as it is when saved, and again if somebody saved in the meantime. Withexpected_version, a stale version is refused with the diff of what changed since. - Misses explain themselves. Text that is not there names the closest line; text that occurs
more than once names the lines it is on (or
all: true, or scope it withsection). A trailing space,\r\n, or a straight quote where the page has a curly one is forgiven, and the answer says so. - The answer is the diff, so checking an edit needs no second read.
dry_runshows it and saves nothing.revert_documentbrings back an earlier version as a new revision. - Reading is markdown, not JSON. Clients show a model a tool's structured output when there is
one, and a page inside it is a JSON string with every newline and quote escaped — the opposite of
what quoting text exactly needs.
get_document,edit_documentandrevert_documentanswer in text: a short header, a blank line, then the page as stored. A long page can be read as its outline (headings with lines and sizes) and then one section.
The same edits are POST /api/documents/{id}/edit (edits, title, tags, dry_run), which
returns the document and the diff, and POST /api/documents/{id}/revert.
What the model is told
The server introduces itself as "CODNA_ORG_NAME. That is what lets a person connect two CoDNA instances to one
Claude — one per employer, say — and have the model know which company's documents it is reading.
Tool descriptions are the real interface to a model, so they carry the rules
rather than restating the schema. The server also sends instructions on
connect, because an agent that does not know raw is immutable will keep
trying to edit it, and one that does not know sharing is propose-then-confirm
will grant access nobody asked for:
rawis evidence and is never edited;wikiis searched first;outputholds what a task made.- Extend the wiki page on a subject rather than start a second one.
- Material you were given goes into
rawwithingest_text, as it is, and what you conclude goes inwiki, linked to it with an ordinary relative link. - You see only what your person sees. A page you cannot find may simply be private.
- Share only when asked, in two steps:
preview_share, then let the person choose. - Change part of a page with
edit_document, quoting the text asget_documentshowed it;update_documentreplaces a whole page, andrevert_documentundoes an edit. Read a long page's outline first. - Link to the address
create_documentreturned, not one you predicted. - A diagram goes in as a
mermaidfenced code block, which the web UI draws (files.md). Agents cannot add images. - A skill is a page directly in
wiki:skills, whose first paragraph says when to use it.
They are kept short on purpose. Claude Code keeps the first 2048 characters of a server's instructions and drops the rest, so the details of each tool are in its own description, and a test holds the instructions to about 1300 characters.
The instructions then end with the skills the caller can read, made on connect, by
title, 50 at most. One line names them all, so the names fall within what Claude Code keeps, and
then each gets its address, title and first paragraph. The model is told to read a skill with
get_document before a task it fits, and that a skill does not override the person it works
with. A caller with no skills gets no list. If the list cannot be made, the client connects
without it and the server logs mcp skills at warn.
Failures are tool results, not protocol errors
A missing document comes back as an error result with a sentence telling the model what to do, rather than a transport failure:
Could not read wiki:legal/acquisition: no such document, or it is private to
someone else. Try search to find what you can see.
A model recovers from that within the same turn. It also never confirms that a
document exists — the same not_found-never-forbidden rule the REST API
follows (permissions.md).
Two cases worth noting:
- A stale
expected_versionsays the document changed and to re-read it, which is actionable, rather than reporting a conflict and stopping. - A share where nothing was granted is an error, not a success with an
empty list.
share_documentreports per-document outcomes so one unshareable document does not fail a batch — but if nothing was shared, an agent reporting success would tell the person it did something it did not.
Operating
The endpoint is only mounted when CODNA_OIDC_ISSUER is set; without an
identity provider there is no way to authenticate anybody, and codna serve
says so on boot rather than failing later:
level=WARN msg="mcp endpoint disabled: no identity provider configured"
Each tool call is logged — msg="mcp call" with the tool, the person, how long
it took and how it ended (ok, refused with the start of its text, or
error) — and counted, by person, day, tool and outcome, for
the usage report. The count outlives
the log, which a restart loses; it never holds a call's arguments or answer.
Token verification hits the identity provider's JWKS, cached by the OIDC library. Each request is verified once, and the actor it resolves to is carried with it to the tool, which never looks at the token itself.
The endpoint is stateless: it hands out no Mcp-Session-Id, keeps nothing
between requests, and answers GET /mcp with 405, as the transport allows for
a server that sends no messages of its own. Nothing accumulates in memory, and
any replica can answer any request, so no sticky routing is needed.