A Tour of the Harness · v1 · M8

MCP and signing in

Other people's tools, on demand, without a browser opening in the middle of a turn. Four stops, about half an hour.

Built M8, shipped in 1.0
Kept in sync by a test

MCP is the one place where edgar runs somebody else's code and trusts somebody else's schema. Two rules shape everything on this page. A configured server costs nothing until one of its tools is actually called (ADR-0047), because a cold edgar -p must still answer inside the startup budget (NFR-1). And a turn never opens a browser: signing in is a command a human runs, not something the loop decides to do (ADR-0048).

flowchart TD
    cfg["[mcp.NAME] in config
read at startup: just a name"] --> names["tool names only,
from the on-disk digest"] names --> ask{"the model calls one"} ask -->|no| done["the server was never started"] ask -->|yes| start["start it now: spawn or connect,
initialize, list tools"] start --> perm["the same permission engine
as every other tool"] perm --> call["call it, translate the result"] start -.->|401 with a WWW-Authenticate| stop["stop and tell the human
to run edgar mcp login"]

Stage 1. Servers that are not running about 15 minutes

1Lazy start

tools/mcp/client.py tools/mcp/stdio.py tools/mcp/http.py

Look for: PROTOCOL McpError Server McpTool servers discover close Stdio Http

Start in client.py and find the moment a server actually starts. It's not in servers — that just reads config and builds handles. It's inside the tool's own call path, the first time the model actually calls one of that server's tools. Up to that point, nothing runs: no subprocess, no socket, no httpx import. That's the startup budget again (ADR-0012). It's also why the two transports live in their own small modules, each imported only once it's actually chosen.

discover is the handshake, run once per server per session:

discover(server):
    connect (spawn the process, or open the HTTP connection)
    send "initialize", check the reply's protocol version against PROTOCOL
    ask for the tool list
    cache it, keyed on a digest of this server's own config
    # a changed command or URL invalidates the cache; nothing else does
    if any step fails: mark this server "failed" for the rest of the session

That last line is worth noticing: a broken server is not retried on every call. Read it and decide for yourself whether you'd have written it the same way.

  • Stdio spawns a process, writes newline-delimited JSON to it, and drains its stderr on a separate thread — so a chatty server can't fill a pipe and freeze the harness.
  • Http speaks Streamable HTTP: one POST, answered either as plain JSON or as an SSE stream, so it has to parse both.
  • Both raise the same McpError. The caller above them never has to know which transport it got.
  • close shuts a server down explicitly rather than leaving it to the garbage collector — servers are child processes of edgar, and an unreaped stdio server is a hung terminal.

Take with you: "lazy" here isn't an optimisation bolted on afterwards. It's the reason the config format stores a command to run, not a connection to hold open.

2Deferred schemas

tools/mcp/schema.py tools/builtin/tool_search.py

Look for: to_schema to_result hints tool_name ToolSearch FOUND RESERVE

Picture thirty MCP servers with twenty tools each. That's six hundred JSON schemas — and most would never get called. Put them all in the prompt and you'd blow the cache budget before the model said a word. So the prompt only carries names. The model finds a schema the way a person would: it looks the tool up.

prompt holds: tool names only (cheap, always present)

when the model calls tool_search("something about spreadsheets"):
    find matching tool names, up to RESERVE tokens worth
    fetch their full schemas
    return them as ordinary tool output, with FOUND explaining what just happened
    # from this point in the session, those tools are directly callable
  • RESERVE caps how much of the context window a single search is allowed to spend.
  • FOUND is the reply text, worded so the model's next step is obvious without a second round trip.
  • The result is plain tool output — no special channel, nothing the rest of the loop needs to know about.

schema.py is the border guard between a server's schema and the rest of the harness:

  • tool_name — namespaces a server's tool, so two servers can't collide and neither can claim a built-in tool's name.
  • to_schema — accepts only what the harness can validate, and drops the rest.
  • to_result — turns whatever the server sent back into edgar's ordinary content blocks.

Everything here assumes the server isn't malicious, but is certainly careless.

Stage 2. Signing in about 15 minutes

3The loopback dance

auth/oauth.py auth/store.py

Look for: pkce Loopback listen visit post SERVICE available read write erase

Signing in uses OAuth 2.1 with PKCE, no client secret, on a loopback port the harness picks for itself. Two files split the job cleanly:

  • pkce — builds the verifier and its challenge, the pair that proves the code exchange at the end came from the same place that started it.
  • Loopback — a tiny HTTP server that answers exactly one request: catch the redirect, check the state value matches, close. Read listen for what it refuses: a mismatched state, a second request, a wait that timed out.
  • visit — the only place in edgar that opens a browser, reachable only from a command a human runs, never from a turn. No browser available (over SSH, say)? It prints the URL and waits instead.

store.py is thirty lines that decide where a token lives. keyring is an optional extra, imported only inside the function that needs it. available says honestly when it's missing, rather than silently falling back to writing a token in plain text. erase makes sure logging out really removes the token.

4Two kinds of sign-in

auth/keys.py auth/mcp.py

Look for: login logout account sign_in CLIENT SCOPES token _metadata _endpoints _register

keys.py is edgar login PROVIDER: it finds a key or token for a provider by checking, in this fixed order, (ADR-0044):

  • the environment, first — always wins, so a stored token can never quietly override what you exported in this shell;
  • then whatever a previous sign-in stored;
  • then api_key_command, a command the config runs to produce one.

mcp.py is edgar mcp login NAME, and it's longer because an MCP server has to be discovered before it can even be talked to. _metadata and _endpoints follow the protected-resource and authorization-server documents the server publishes about itself. _register does dynamic client registration when the server offers it — there's no secret to hand out in advance, so the client has to introduce itself fresh. token handles both the first exchange and later refreshes; a refresh that fails deletes the stored token rather than retrying forever.

sequenceDiagram
    participant H as a human at a terminal
    participant E as edgar mcp login
    participant S as the MCP server
    participant A as its authorization server
    H->>E: edgar mcp login notion
    E->>S: GET the protected-resource metadata
    S-->>E: which authorization server to use
    E->>A: register this client, if it is offered
    E->>H: open this URL (loopback redirect waiting)
    H->>A: sign in and approve
    A-->>E: code, on the loopback port
    E->>A: code + verifier
    A-->>E: token
    E->>E: store it in the keyring

Take with you: the harness holds no client secret anywhere, and the token never passes through the model's context.

Sizes

Lines of code: blank lines and comments do not count, docstrings do. just loc prints the current totals.

FileLines of codeWhat it is
tools/mcp/client.py~142Handles, the handshake, the digest cache, shutdown
tools/mcp/stdio.py~76A child process and newline-delimited JSON
tools/mcp/http.py~65Streamable HTTP: one POST, JSON or SSE back
tools/mcp/schema.py~47Namespacing, schema narrowing, result translation
tools/builtin/tool_search.py~74Deferred schemas, fetched by name
auth/oauth.py~93PKCE, the loopback listener, the code exchange
auth/mcp.py~131Discovery, registration, tokens and refresh
auth/keys.py~47Provider keys, resolved in a fixed order
auth/store.py~46The keyring, and honesty when it is absent

Source: just loc, which counts with tests/support/budget.py. Rounded; the tour's test fails if a figure drifts more than 25 lines of code from the code.