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