DodoRouter
Get Started
Docs / Agent Access (MCP)
Browse docs
View as Markdown

Agent access over MCP

The rest of these docs are about sending traffic through DodoRouter. This page is about the other direction: letting a coding agent read that traffic back, so it can answer questions you would otherwise answer by hand.

"This support-reply call costs $0.011 and uses Claude Opus. Would Haiku be good enough? Would Kimi?"

An agent connected here picks a request your product really made, replays it against whichever models you want to compare, has a judge model score every answer against criteria you wrote, and reports a score, a latency and a price per model. It never guesses — every number comes from a real call.

DodoRouter exposes this as an MCP server at {base_url}/mcp, authenticated with OAuth 2.1. There is no second API key to create, and no secret ever lands in a config file.

Connect an agent

One command:

claude mcp add --transport http dodorouter https://api.dodorouter.com/mcp

Then use it once (/mcp in Claude Code, or just ask it to list your routers). Claude Code opens your browser, you sign in with your usual magic link if you are not already, and a consent screen asks what this agent may do. Approve, and it is connected.

What happened under the hood, in case you are debugging it:

  1. The agent discovered the server from /.well-known/oauth-protected-resource/mcp and /.well-known/oauth-authorization-server.
  2. It registered itself (RFC 7591) — nobody can pre-register a desktop agent, because its loopback callback port is not known until it starts.
  3. It sent you to /oauth/authorize, which rendered the consent screen against the DodoRouter session you already had.
  4. Your approval minted a short-lived access token, audience-bound to this MCP endpoint.

Registering grants nothing on its own. A client gets no token until a signed-in user approves it, and the token it gets carries only the permissions ticked on that screen.

Any MCP client that speaks OAuth works — the endpoint is not Claude Code specific. Point it at the same URL.

What you are approving

The consent screen lists four permissions. They are ticked by default because the agent asked for them, but you can untick any of them before approving.

Permission Grants Sensitive
logs:read Models, token counts, cost, latency and status for your requests. No prompt or response text.
logs:read_bodies The stored request and response text itself — everything your product sent and received. ⚠️
evals:read Evaluation setups, scores, rankings and judge feedback.
evals:write Create evaluations and start benchmarks. Running a benchmark calls providers and spends money. ⚠️

logs:read_bodies is the one to think about. Everything else is metadata; that one is your product's actual traffic, including whatever your users typed. An agent can rank models on price and quality with only logs:read — it just cannot read the transcripts to write good criteria.

There is no hierarchy: granting logs:read_bodies does not imply logs:read. Each is granted explicitly.

Refusing a permission is not the same as an error. The agent still connects; the tools it cannot use say so, naming the permission that would have worked, so it can tell you what to grant.

Which routers

Below the permissions, the same screen decides which routers they apply to. The default is every router your account has — including ones you create later, which is why it is an explicit choice rather than a silent default. Pick "Only the routers ticked below" to limit the agent to specific routers: an agent working on one product then cannot read another product's traffic, however its permissions are set.

The limit travels with the connection — reconnecting or refreshing keeps it, and the consent screen shows the previous selection when an agent asks again. Tools answer honestly from inside the limit: list_routers shows only what the connection reaches, and a router granted to an agent but later deleted or transferred simply stops being reachable.

The tools

Tool What it does Needs
get_guide The full evaluation workflow, in prose. An agent should read this first.
list_routers Every router this connection reaches
list_logs Recent requests, with evaluable per row; filters by session, recording, model, provider and time window logs:read
list_sessions Sessions with per-session cost, tokens and latency logs:read
get_session One session's aggregates — "this question cost $1.40" logs:read
get_spend Spend grouped by served model over a window logs:read
get_cache_stats Prompt-cache hit rate and token volumes over a window logs:read
list_recordings Capture windows of real traffic; benchmark one via create_eval logs:read
get_recording One capture window's aggregates logs:read
get_log One request, with its stored bodies logs:read (+ logs:read_bodies for text)
list_eval_targets Your provider keys × the models each can serve, with list prices evals:read
list_evals Evaluations created against this router's logs evals:read
get_eval Status, per-model rankings, judge feedback, individual runs evals:read
create_eval Create an evaluation (optionally start it immediately) evals:write
run_eval Run or re-run the whole benchmark evals:write
retry_eval Re-run only the runs that failed evals:write
cancel_eval Stop a running benchmark; stored answers stay evals:write
send_feedback Send feedback about the agent surface to the DodoRouter team

create_eval accepts one request_log_id, a request_log_ids set, or a recording_id — with a set, every candidate answers every log and the ranking aggregates across them, so the score answers "on my traffic" rather than "on this one request". A recording is the strongest source: every replayable request captured during the window becomes a source log (evenly sampled across the capture when more than 20 are replayable), so the sample is production traffic nobody hand-picked. It also takes prompt_variants to hold the model constant and vary the request: a variant can replace the system prompt, or patch individual messages (message_patches) — swap a tool-result for a compressed version, say, and measure whether reasoning survives. Each variant patches the served request, and the ranking carries one row per model × variant under the same judge. For recorded agent traffic, comparison_mode: "next_action" turns the benchmark into a per-decision preference test: the judge compares each candidate's proposed next move against what production actually did over the same frozen history.

Most tools take an optional router slug. It is optional when your connection reaches exactly one router and required when it reaches several — rather than silently picking one.

get_guide exists because tool descriptions can say what an argument is but not what makes a result trustworthy. The guide covers the parts that decide whether the numbers mean anything: include the model you use today as a candidate or you have no baseline, read rubric_feedback before trusting a score, and cost_usd is $0 on subscription keys so list_cost_usd is the comparable figure.

Seeing what an agent did

Dashboard → Agent Activity.

Every call is recorded — including the refused ones. A 401 from a token that never verified is a row, and so is a tool call rejected for a missing permission; a log that only recorded successes could not tell "nothing went wrong" from "we have no way to tell".

The page shows:

  • Connected agents, with how many calls each made and when it was last seen
  • Which of them actually read prompt or response text, not merely which were granted permission to
  • Every call, filterable by allowed / denied / errored, with the tool name and which record it touched

This is not your router's API key

A router's API key sends traffic. It cannot read traffic back, and that is deliberate: if one credential did both, a leaked .env would stop being "someone is burning my tokens" and become "someone has every prompt my product ever sent".

So:

  • Your router key on /mcp401. Use the OAuth flow.
  • An agent connection on /r/{router}/v1/chat/completions401. Use the router key.

An agent connected here cannot send requests through your router. If you want it to test a prompt change, change the prompt in your product, let it make one real call, and evaluate that new log.

Self-hosting

The OAuth server needs HTTPS. Both the issuer and the audience must be https:// URLs — there is no localhost exemption.

ATTESTO_ISSUER and ATTESTO_AUDIENCE are read at boot, not baked into the release — set them wherever you set the app's other runtime environment variables (e.g. ~/dodorouter/.env on the server) and restart:

ATTESTO_ISSUER=https://router.example.com
ATTESTO_AUDIENCE=https://router.example.com/mcp

If unset, the issuer defaults to https:// plus the endpoint's own public host (PHX_HOST), and the audience defaults to <issuer>/mcp — so a correctly configured PHX_HOST alone is often enough, and no release needs to be rebuilt just to change these.

Behind a TLS-terminating reverse proxy (Caddy, nginx, Traefik), the app itself receives plain HTTP, and the proxy's X-Forwarded-Proto: https header is the only evidence a request arrived over TLS. That header is believed only when it comes from a trusted peer — otherwise any plain-HTTP client could claim it. A proxy on the same machine (loopback) is trusted out of the box. A proxy that reaches the app across a network — a Docker bridge, a separate box — must be listed explicitly, as comma-separated IPs or CIDRs:

ATTESTO_TRUSTED_PROXIES=172.16.0.0/12

Get this wrong and every OAuth endpoint answers 400 with "the request must be made over TLS", even though your clients are connecting over HTTPS — registration fails, so no agent can connect at all.

For local development, mix attesto_phoenix.gen.dev_https (with mkcert installed) generates a locally-trusted certificate and the app serves TLS on port 4443 alongside the plain HTTP dashboard on 4000.

Troubleshooting

Every tool refuses with "no active subscription". Hosted service only: the account that owns the connection has no active subscription, so tools list but will not run — the same paywall that makes the proxy answer 402. The account owner subscribes on their billing page; the connection itself is fine and starts working the moment they do. Not applicable to self-hosted instances unless billing was enabled there.

The agent connects but every tool says "UNAVAILABLE". It is missing permissions. Reconnect and tick the ones it needs on the consent screen — the tool description names them.

Every OAuth request fails with "the request must be made over TLS", but the URL is https. TLS terminates at your reverse proxy, and the app is not trusting that proxy's X-Forwarded-Proto header. A loopback proxy is trusted by default; anything else must be listed in ATTESTO_TRUSTED_PROXIES — see Self-hosting above. Some MCP SDKs raise the same error on their side when the WWW-Authenticate challenge on /mcp advertises an http:// metadata URL — check that header with curl -si -X POST <base>/mcp; a current DodoRouter pins it to the configured https audience origin.

POST /mcp returns 401 with a WWW-Authenticate challenge. That is the flow working: the challenge points the client at the discovery document so it can start the OAuth exchange. If the client stops there, check that ATTESTO_ISSUER matches the URL actually being served.

The consent screen never appears. The agent must be able to reach /oauth/authorize in a browser on the same host you are signed in to. A mismatch between the issuer and the address you visit means you are signed in to one origin and consenting on another.

Consent works, then the token exchange returns a 500. The server has no signing key. Everything up to the callback runs unsigned, so a missing key surfaces only at the final step — set ATTESTO_SIGNING_KEY_PATH (see Self-hosting environment variables) and restart.

Nothing to evaluate. list_logs only shows requests your router actually served, and only some are replayable — evaluable and not_evaluable_because say which and why. A request whose stored body was truncated cannot be replayed.