CoDNA Request access
Contents

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 become https://claude.com/api/mcp/auth_callback, so allow both. Claude Code comes back to a loopback address on any port, or to claude://claude.ai/... from the desktop app. It registers with Dynamic Client Registration, or uses its published Client ID Metadata Document when the provider advertises client_id_metadata_document_supported and none among 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, plus offline_access when 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_redirect when the provider returns iss in its authorization responses (RFC 9207), and otherwise to https://chatgpt.com/connector/oauth/{callback_id}. It registers with DCR, or with CIMD where offered, and sends resource on 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, localhost and 127.0.0.1, with client URIs required to match them;
  • openid among 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, email and offline_access: every other OIDC client scope gets include.in.openid.provider.metadata false (Keycloak 26.8). Codex asks for everything listed, and anonymous registration refuses service_account, so a full list stops it before sign-in; Claude still finds offline_access there.

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_document takes 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. With expected_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 with section). 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_run shows it and saves nothing. revert_document brings 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_document and revert_document answer 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 " knowledge base" and its instructions open with whose knowledge base it is, from 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:

  • raw is evidence and is never edited; wiki is searched first; output holds what a task made.
  • Extend the wiki page on a subject rather than start a second one.
  • Material you were given goes into raw with ingest_text, as it is, and what you conclude goes in wiki, 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 as get_document showed it; update_document replaces a whole page, and revert_document undoes an edit. Read a long page's outline first.
  • Link to the address create_document returned, not one you predicted.
  • A diagram goes in as a mermaid fenced 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_version says 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_document reports 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.