# 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](https://codna.me/docs/adr/0012-mcp-oauth-resource-server.md)).
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](https://codna.me/docs/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](https://codna.me/docs/permissions.md#service-tokens)). `/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](https://codna.me/docs/connect.md) 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](https://claude.com/docs/connectors/building/authentication))
- **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](https://developers.openai.com/codex/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](https://developers.openai.com/apps-sdk/build/auth))

**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:

```bash
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:

```bash
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](https://codna.me/docs/reference/tools.md).

| 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](https://codna.me/docs/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 "*<org>* 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](https://codna.me/docs/files.md#diagrams)). 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](https://codna.me/docs/skills.md) 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](https://codna.me/docs/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](https://codna.me/docs/permissions.md#how-the-instance-is-used). 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.
