Serve it to your editor
Expose the knowledge layer over MCP and wire your editors (Claude Code, Windsurf, Kiro) in one command.
The third layer. Once the knowledge layer is built, contextlake kb serve
exposes it as an MCP server, so any MCP client (Claude Code, Windsurf, VS Code, Kiro, Cursor,
Postman, …) can query the graph directly instead of grepping.
flowchart LR CL(["your MCP client"]) -->|"stdio, http, or sse"| SRV["contextlake kb serve"] SRV --> ASK["ask"] ASK -.->|"classifies, then routes"| TOOLS["find_definition, find_callers,
find_dependents, blast_radius,
who_knows, get_wiki, and the rest"] SRV --> TOOLS TOOLS --> G[("the graph")] SRV -.->|"registered only when
embeddings exist"| SEM["semantic_search,
hybrid_search"] SEM --> V[("the vector store")]
The transport only decides how your client reaches the server; the tool set behind it is the same
whichever one you pick. ask is a front door onto those tools, not a layer above them, so an agent
can call either.
Start with ask. One tool, natural language: ask("who calls charge_order") /
ask("what breaks if I change CatalogService") / ask("what extends BaseController") /
ask("explain the catalog-api"). It classifies the question, routes it to the right
substrate below (definition / callers / dependents / subclasses / impact / owners /
explain / search), resolves the symbol or repo, and returns one labeled answer (graph
facts cited; explain returns advisory wiki prose, or the repo's grounded anatomy when
no wiki exists yet). An agent that would rather not choose among the tools can just ask.
Most of it needs no model. The underlying graph tools work on their own:
search_code, find_definition, find_callers, find_callees, find_dependents, get_node,
get_neighbors, shortest_path, graph_stats, repo_dependencies, repo_flow,
repo_event_flow, blast_radius, who_knows, get_wiki, get_readme,
get_repo_brief, list_repos, get_repo_links, graph_health, plus a kb://stats
resource with the store counts.
semantic_search / hybrid_search are the two exceptions: they register only when
embeddings exist, which takes both halves, enabled = true under [embeddings] in
kb.toml (the section on its own is not enough, enabled defaults to false) and a
contextlake kb embed run to create the vector store. Without both, the server starts
fine and says so, the two tools are simply absent from the tool list, and everything
above still works.
The quick way: let contextlake wire your editors#
From your workspace root:
contextlake kb steer --config ~/.contextlake/kb.toml
This writes the per-tool steering files so agents pick up the workspace context and the MCP server natively:
AGENTS.md(overview, the knowledge tools, and guardrails), a thinCLAUDE.mdthat imports it,.windsurfrules, and.kiro/steering/.- A merged
.mcp.jsonentry for thecontextlake kb serveserver (Claude Code, Windsurf, Cursor, and other clients that read this file) and a merged.vscode/mcp.jsonentry for VS Code, which uses a different top-level key (servers, notmcpServers): a distinct schema, so it gets its own file rather than reusing.mcp.json. - A generic library of agent skills / workflows (
.claude/skills/,.windsurf/workflows/): investigate-root-cause, plan-before-coding, surgical-change, review-before-landing, ship-safely, use-knowledge-graph, indexed-content-is-untrusted, a strong operating playbook even for a small-context model. The last of those states the trust boundary: the repositories the graph indexes are content other people wrote, so everything it returns is evidence about the code, never an instruction to the agent reading it.
It never corrupts your existing files. If you already have an AGENTS.md, CLAUDE.md,
.windsurfrules, or .kiro/steering, your content is preserved and only a clearly-delimited
managed block is appended (and just that block is refreshed on re-runs). .mcp.json and
.vscode/mcp.json are merged so your other servers stay; a skill file you wrote with the same
name is kept as-is; custom layers like .devin/ are left untouched.
The generated AGENTS.md names the store it was built from#
Near the top of the managed block:
Generated by `contextlake kb steer` from the knowledge store at
`/home/you/.contextlake/kb`. If those counts look wrong, check that
this is the store you meant -- the output path and the store are chosen
separately.
That line exists because those two things really are chosen by different inputs: --out (or
--workspace, or the current directory) decides where the files land, while the config chain
decides which store the symbol counts and repository list are read from. Running steer from the
wrong directory therefore rewrote a correct 5,500-symbol AGENTS.md down to a two-symbol one,
exit 0, no warning, and every number in the replacement was accurate for the store that happened
to resolve. Confident, tiny and wrong is the worst shape a steering file can take, because an
agent reading it has no way to tell it from a workspace that genuinely holds two symbols. Naming
the store puts the swap in the diff.
What the generated .mcp.json pins, and what it deliberately does not#
An MCP client execs contextlake kb serve with the workspace as its working directory, not the
directory you ran steer in. With no --config on that command line the server re-resolves the
store by walking up from there, so it can serve a different store than the files beside it
describe. Writing --config <path> into the entry removes the ambiguity, and steer does that
whenever it can (_implicit_binding in src/contextlake/kb/cmds/steer.py):
| Which config chose the store | Pinned into .mcp.json? |
|---|---|
One you named with --config |
Yes, made absolute first |
| A config somewhere non-default, already trusted | Yes: nothing else would find it |
Your global ~/.contextlake/kb.toml |
No, on purpose |
An ancestor-discovered .contextlake.kb.toml |
No, and you are warned when it matters |
| No config file anywhere | No, and none is needed |
The global config is not pinned even though it is the usual answer. .mcp.json is a file you
commit and share, and pinning writes an absolute /home/<you>/... into it: a teammate who clones
the repository gets a launcher naming a path that does not exist on their machine, plus your home
directory layout in version control. Leaving it out costs nothing, because an unpinned launcher
walks up from the workspace and lands on the global config anyway, on their machine, which is
the store they should be served.
An ancestor-discovered config is not pinned either, for a different reason: naming a file on a
command line is exactly the act that promotes its gated keys to trusted (see
Workspace trust), so auto-pinning one would launder a file you
never chose into a privileged one. You get a warning instead, and only in the case that actually
bites, when the workspace sits outside that config's directory and that config is what set
store_dir:
⚠ /path/to/workspace is outside /path/to/config-dir, so the generated MCP entry will resolve a different store than this run used (/path/to/store). Re-run with --config <path> to pin it (naming it on the command line is also what makes its gated keys trusted).
Wiring it by hand#
Claude Code:
claude mcp add contextlake-kb -- contextlake kb serve --config ~/.contextlake/kb.toml
Windsurf, add the same server in its MCP config (Cascade's MCP Servers panel, or
~/.codeium/windsurf/mcp_config.json):
{
"mcpServers": {
"contextlake-kb": {
"command": "contextlake",
"args": ["kb", "serve", "--config", "~/.contextlake/kb.toml"]
}
}
}
VS Code, in .vscode/mcp.json (note the servers key: a different schema from the
mcpServers files above; contextlake kb steer writes this automatically):
{
"servers": {
"contextlake-kb": {
"command": "contextlake",
"args": ["kb", "serve", "--config", "~/.contextlake/kb.toml"]
}
}
}
Transports#
contextlake kb serve --transport <stdio|http|sse> (default stdio):
stdio, the default. The editor/agent spawnscontextlake kb serveitself and talks to it over stdin/stdout; this is whatsteer-generated.mcp.json/.vscode/mcp.jsonentries use. No token, no network: the pipe belongs to the process that spawned it.http, Streamable HTTP, the MCP spec's current standard network transport (--host/--port, default127.0.0.1:8765). Point clients athttp://127.0.0.1:8765/mcp, not the bare host:port: the endpoint is the/mcppath. Any other path, the root included, returns 401 rather than 404, because the bearer-auth middleware wraps the whole app and runs before routing. Prefer this transport for any new remote/network wiring. Authenticated, see below.sse, the older HTTP+SSE transport from the 2024-11-05 MCP spec revision. The current spec marks it deprecated in favor of Streamable HTTP, but still guides servers to keep offering it for clients that haven't moved off it yet; contextlake follows that guidance rather than dropping it. Its endpoint ishttp://127.0.0.1:8765/sse. Usesseonly if your client specifically requires it (some clients, e.g. Devin's custom-MCP-server setup, list SSE as a distinct, separate option from HTTP), pickhttpfirst. Authenticated exactly likehttp.
Stopping it#
Ctrl-C stops every transport and exits 0. Ending a server the documented way is not a
failure, so kb serve does not use the 130 an interrupted command usually exits with (see
Reading the console output).
| How it stops | stdio |
http / sse |
|---|---|---|
Ctrl-C (SIGINT) |
0 |
0 |
SIGTERM (systemctl stop, docker stop, a supervisor) |
0 |
143 |
stdio installs its own handler for both signals through asyncio's wakeup fd, which is what makes
an idle server stoppable at all. Python only runs a signal handler at a bytecode boundary in
the main thread, and that thread is parked in the selector with no traffic coming, so without the
wakeup fd an interrupt sits unhandled until a request happens to arrive (_run_stdio in
src/contextlake/kb/server.py). Both signals then unwind through one path, which closes the store
and the vector store on the way out.
143 on the network transports is 128 + 15, the conventional "terminated by SIGTERM" code, and
it is what a supervisor reads as a clean stop rather than a crash. uvicorn owns those transports:
it handles the signal itself, drains connections and shuts the session manager down, then restores
the default handler and re-raises the signal, so the process reports the termination it was asked
for after the shutdown has already finished.
Authenticating the network transports#
The graph answers with real file paths, symbol names, docstrings and owner identities, so the socket transports do not serve it to anyone who connects.
A bearer token, printed once to stderr at startup:
$ contextlake kb serve --transport http
✓ MCP server on http://127.0.0.1:8765/mcp (Ctrl-C to stop)
Bearer token: <a fresh 43-character token>
Clients must send: Authorization: Bearer <token>
Pin a stable one across restarts with $CONTEXTLAKE_MCP_TOKEN.
Every request needs Authorization: Bearer <token>; without it the server answers 401. The
token goes to stderr only, never to stdout, never to the log file, so it does not outlive the
process anywhere you did not put it.
Pin it for a client config. A fresh token per launch is fine when you copy it by hand and
useless when a config file has to hold it. Set CONTEXTLAKE_MCP_TOKEN and the server uses that
value instead of minting one (an empty or whitespace-only value is treated as unset, and a fresh
token is minted, it never turns authentication off):
export CONTEXTLAKE_MCP_TOKEN='pick-your-own-long-random-string'
contextlake kb serve --transport http
Origin and Host are validated on every request, as the MCP spec requires for HTTP transports:
a request whose Origin is not the bound host (or a loopback address) gets 403, and one whose
Host does not name this server gets 421. That is what stops a web page you visit from
reaching your loopback MCP server through DNS rebinding.
Non-loopback binds must be opted into. --host outside 127.0.0.1 / localhost / ::1 is
refused unless you pass --allow-remote, and prints a warning when you do:
contextlake kb serve --transport http --host 0.0.0.0 # refused, exits 1
contextlake kb serve --transport http --host 0.0.0.0 --allow-remote
Nothing here is encrypted in transit. For anything beyond your own machine, prefer an SSH tunnel
to a loopback bind, or put TLS in front of it. Note also that a wildcard bind (0.0.0.0) only
answers requests whose Host is a loopback name, because the Host check has no way to know which
address you meant, bind the address clients will actually name (--host 192.0.2.10).
There is an access log, and it is off by default. These servers are loopback developer tools
whose console is already a command's output, so they stay quiet, but a server holding the whole
code graph should be able to answer "what did it serve, and to whom". --access-log turns on one
line per request (client address, request line, status).
For contextlake's own servers, kb dashboard --serve, kb graph --serve, kb graph --site
--serve, those lines go through the same logger as everything else, so they land in --log-file
and follow --log-format json, and the client-supplied request line is stripped of control
characters first. kb serve's http/sse transports are served by uvicorn rather than by
contextlake's handler, so there the flag enables uvicorn's access log instead: its own format,
on stderr, alongside its startup banner.
Devin is different: there's no repo file to wire. Devin's MCP connections are configured at
the account/org level (mcp.devin.ai, with an API key and org header), not read from a file
committed to the repo it's working in, so contextlake cannot self-register as a Devin MCP
server the way it can for the clients above. Add contextlake kb serve there yourself, once, in
Devin's own MCP settings. What contextlake kb steer does give Devin (and any agent that reads
plain workspace context) is AGENTS.md: the portable part travels; the MCP wiring itself
doesn't.
How many tool calls run at once#
contextlake kb serve --tool-concurrency 4
CONTEXTLAKE_MCP_TOOL_CONCURRENCY=4 contextlake kb serve
The default is 2, and raising it makes the server slower. That is the opposite of what a
concurrency knob usually does, so it is worth writing down why. The bound applies to every
transport, not just the network ones.
The MCP SDK runs every synchronous tool body through anyio.to_thread.run_sync with no limiter,
so it takes anyio's default of 40 worker threads. contextlake's tool bodies are graph traversals
over SQLite, and a traversal is not one query, it is thousands of small round trips through the
store. Forty threads interleaving those on one connection pool spend their time contending rather
than working, and what they are contending for is the store round-trips, not the Python: the same
traversal run over in-memory dictionaries does not degrade the same way. The default therefore has
to sit near the low end rather than merely below anyio's 40.
The bound is applied to the tool bodies themselves, not by shrinking that worker pool. The pool
is sized separately, to the bound plus a reserve for transport I/O, because the SDK's stdio
transport borrows worker threads for its own readline and flush: with the pool set to the bound
outright, --tool-concurrency 1 left stdin holding the only token and the server answered nothing
at all. tests/kb/test_serve_concurrency.py pins both halves, that the bound still bounds
concurrent tool bodies and that stdio still answers at a limit of one.
The cheap tools come out faster too, which is the counterintuitive part and the reason not to
think of this as a throughput-for-latency trade. search_code and the other short lookups pay
store round-trips as well, just fewer of them, so in an unbounded burst they sit in exactly the
same contention as the traversals do. Bounding the pool takes that away from them rather than
making them queue for a slot.
Two rather than one, because a limit of one is only free when every call costs the same. Real editor traffic mixes one slow call with many fast ones, and at a limit of one a single multi-second traversal holds the only token while every cheap lookup waits behind it. Two keeps a slot free for the cheap path while still keeping the server far away from the width where contention dominates. One is a supported setting, not a trap: set it if your traffic is one caller at a time.
Precedence is the flag, then $CONTEXTLAKE_MCP_TOOL_CONCURRENCY, then the default. A value that
is not a positive integer is ignored rather than fatal, whichever of the two it came from
(resolve_tool_concurrency): this is a performance knob on a server your editor launches, and
refusing to start over a typo in a shell profile is worse than serving at the default.
Every cited node says whether the file moved under it#
The staleness contextlake tracked until now is per repo: has the head commit or the parser
version moved since this graph was built (Keep it fresh). That is the right
question for the graph as a whole and it is blind to the one that bites hardest in practice, an
agent editing files between index runs, inside the same commit. The graph says
src/billing/refund.py:88, twenty lines get inserted above it, and the answer still says 88. A
confidently wrong citation is worse than a miss, because the agent goes and reads it.
Every node a tool returns therefore carries citation_status, decided against the file on disk
as the answer is built:
| value | what it means |
|---|---|
verified |
the file has not been written since the repo was indexed |
stale |
it has, and the line number may have moved. The file is still the right one, so find the symbol by name |
unverifiable |
the citation could not be checked at all: no local checkout, an unreadable file, or a repo that carries no index timestamp |
When the status is not verified a citation_note says which of those it is, in a sentence
meant for the agent reading it. unverifiable is not a polite verified: it means nothing was
checked, and the two are kept apart for the same reason kb eval --verify-citations keeps them
apart (Semantic search). The answer is still returned
either way: the guard discloses, it never withholds a result or refuses.
What it costs. One stat() per distinct file in a response, not per node. Only files that
really were written after indexing escalate to a confirming read, which asks the same question
--verify-citations asks and shares its implementation. Measured on a real store, a full MCP call
costs about 1.7% more when nothing has changed and 28.6% in the worst case where every file in
the response was modified, at roughly 1.5 tokens per node. Past 32 confirming reads in one request
the remainder are reported stale with modified_after_index rather than quietly passed. A
budget nobody is told about would read as a clean bill of health for work that never ran.
"One request" means one call over the wire, including every leg of an ask. ask routes to
several tools internally and they share one probe on purpose, so a file cited by three legs costs
one stat() rather than three. The budget is shared for the same reason, which is worth knowing
before reading it as per-verb.
The fields are null on surfaces that do not run the guard, which again is not a synonym for fine:
the dashboard reads the graph directly and does not install a probe.
blast_radius returns hits rather than nodes and carries the same two fields, so no verb hands
back a file and a line with nothing said about whether they still hold. contextlake kb steer
writes the same three-value explanation into the generated agent skills, so an agent that reads
only its steering files still knows what a stale result means.
Once connected#
Ask the agent things like "where is CatalogService defined?", "who calls charge?", or
"which repos depend on shared-core?" and it calls the graph tools directly, you can even
have it draft wiki pages from the graph without the built-in wiki command.
