Building an MCP Server for Code Intelligence: How I Built Scry
The protocol layer of an MCP server is trivial. The hard part is designing tool interfaces that agents actually use well: narrow tools, precise descriptions, structured returns, and sub-10ms latency that changes agent behavior.
The protocol layer of an MCP server is about 200 lines of code. You pick an SDK, register some tool handlers, and wire up stdio or HTTP transport. That part is solved. The hard part, the one that determines whether an agent actually uses your server or falls back to Grep, is designing the right tool interface for your domain.
For code intelligence, that means deciding what questions agents actually ask about code and how to answer them in a format agents can consume. I spent more time on tool naming, descriptions, and return formats for scry than I did on the entire MCP transport layer. This post is about those decisions.
If you want the architecture of scry itself (the SCIP indexing pipeline, the BadgerDB storage layer, the daemon design), read Building Scry. If you want an overview of the protocol, read the MCP servers guide. If you want to build your own MCP server in Go, read Building MCP Servers in Go. This post is specifically about the interface design layer: what tools to expose, how to describe them, and what to return.
What agents actually ask about code
I watched Claude Code sessions for weeks before building scry’s tool interface, not to study the model but to study the patterns. Every code-related query an agent makes falls into a small set of structural questions:
- Where is X defined? The agent has a symbol name and needs its declaration site.
- Where is X used? Every call site, every reference, every import.
- What calls X? The incoming call graph. Who depends on this function.
- What does X call? The outgoing call graph. What does this function touch.
- What implements interface X? Concrete types satisfying a contract.
- Is X tested? Coverage status before deciding whether to write new tests.
Six questions, and they map directly to six tools. There’s no “search code” query, no natural language interpretation, no fuzzy matching. Agents ask structural questions about symbols, and structural questions have precise answers.
The seventh tool is scry_status, a health check that tells the agent which repos are indexed and how fresh the index is. This sounds trivial but it’s load-bearing: the agent needs to know whether scry can answer before it asks, otherwise it wastes a tool call on an unindexed repo and then falls back to Grep anyway.
Designing the tool interface
The most important design decision was exposing seven narrow tools instead of one broad tool. I could have built a single query_code tool with a mode parameter:
// Don't do this
tools.Register("query_code", QueryCodeHandler, mcp.WithDescription(
"Query code intelligence. Modes: defs, refs, callers, callees, impls, tests, status",
))
This is worse in every way. The agent has to read one long description, understand the mode parameter, and construct a more complex call. More importantly, the model’s tool-selection logic works better with many specific tools than one polymorphic tool. When Claude sees scry_callers in its tool list, it knows instantly that this is the tool for “who calls X.” No parsing, no ambiguity. The tool name itself is documentation.
Each of scry’s tools takes one required parameter, symbol, with no file paths to narrow scope, no regex patterns, no pagination parameters. The agent says “find references to processOrder” and gets back every reference in the repo. If there are 200 results, the agent gets 200 results. I experimented with pagination and file-scoping and they both made the agent worse: it would over-filter and miss relevant results, or it would paginate through results one page at a time burning tool calls.
The optional repo parameter defaults to cwd, which is almost always correct. In the rare case where the agent needs to query a different repo, it’s there. But 95% of calls omit it entirely.
Tool descriptions matter
The description string on an MCP tool is not documentation for humans. It’s the system prompt for tool selection. The agent reads your description to decide whether to call your tool or use something else. A vague description means the agent reaches for Grep. A precise description means it reaches for scry.
Here’s the real description for scry_refs:
PREFERRED over Grep for any identifier lookup. Finds every call site, usage, and reference of a symbol across a scry-indexed repo in under 10ms. Returns file:line:col with a context snippet for each occurrence. Accepts plain names (
processOrder,UserController) AND method-call notation (DB::table,auth->user,service.run), where the leftmost token narrows to a specific class.
Notice what’s happening here. The description explicitly says “PREFERRED over Grep,” which tells the agent to use scry instead of its default behavior. It states the latency (“under 10ms”) so the agent knows this is cheap to call. It gives concrete input examples so the agent knows what format to pass. And it explains the compound notation so the agent can construct precise queries like DB::table instead of just table.
Compare that to a description like “Search for symbol references in code.” That description is technically accurate but it doesn’t tell the agent why to prefer this tool, how fast it is, what input format it accepts, or how it differs from Grep. The agent would use it sometimes, maybe, if it happened to remember it existed.
I also include negative guidance for when to fall back:
Only fall back to Grep when the repo isn’t indexed, when scry returns empty, or when searching for text inside strings/comments/docstrings.
This is critical. The agent needs to know the boundaries of the tool. Scry doesn’t index string literals or comments. If the agent asks “where does this error message appear” it needs to know that’s a Grep job, not a scry job. Without that negative guidance, the agent would try scry, get nothing, and then try Grep, wasting a tool call and adding latency to the session.
Return format design
What you return from a tool shapes how the agent reasons about the results. I experimented with three formats before settling on the current one.
Plain text (file paths only):
src/handlers/order.go:47
src/handlers/order.go:112
src/services/payment.go:23
This was fast to implement but terrible for the agent. It had to follow up with Read calls to see what was actually at those lines. Every scry call generated 3-5 follow-up Read calls.
Full source context (10 lines around each hit):
src/handlers/order.go:47
func processOrder(ctx context.Context, order *Order) error {
if err := validateOrder(order); err != nil {
return fmt.Errorf("validation: %w", err)
}
...
This gave the agent too much context. Token-expensive, and the agent would get confused trying to read 10 lines of code for 30 different results.
Structured JSON with one context line (what scry actually returns):
{
"symbol": "processOrder",
"refs": [
{
"file": "src/handlers/order.go",
"line": 47,
"col": 6,
"context": "func processOrder(ctx context.Context, order *Order) error {",
"role": "definition"
},
{
"file": "src/services/checkout.go",
"line": 89,
"col": 12,
"context": " if err := processOrder(ctx, cart.ToOrder()); err != nil {",
"role": "reference"
}
]
}
The single context line is the key insight. One line gives the agent enough to understand what’s happening at that location without needing a follow-up Read. It can see the function signature, the call pattern, the surrounding expression. For 80% of queries, the context line is all the agent needs to make its next decision.
The role field (definition, reference, implementation) lets the agent filter results mentally without needing separate tools for “find the definition” vs “find all references.” In practice, scry_defs and scry_refs are the same underlying query with different filters, but exposing them as separate tools is still the right call because it matches how agents think about the question.
The setup problem
An MCP server that’s hard to install doesn’t get used. Scry’s setup is one command:
scry setup
Under the hood, this shells out to claude mcp add with the right arguments: server name, transport type, command path. I considered writing directly to Claude Code’s config files but rejected it for two reasons. First, the config file location varies by platform and installation method, and Claude Code might change it. Second, claude mcp add is the documented interface and will handle any format changes.
The one sharp edge: Claude Code has both global and project-level MCP config. scry setup writes to the global config (~/.claude.json) because scry is useful across all repos. But if a user has a project-level .mcp.json that shadows the global config, scry silently doesn’t appear. I spent an embarrassing amount of time debugging this “silent shadow” failure mode before adding a check to scry setup that warns if a project config exists.
The broader lesson: MCP server registration is a UX problem, not a protocol problem. The protocol handles discovery perfectly once the server is registered. Getting it registered correctly, in the right scope, without conflicting with other configs: that’s where users get stuck.
Performance as a design constraint
Scry answers queries in 3-8 milliseconds. This isn’t an optimization flex: it’s a design constraint that changes agent behavior.
An agent that perceives a tool as fast uses it differently than an agent that perceives a tool as slow. When scry responds in under 10ms, the agent uses it speculatively. It’ll call scry_refs just to confirm a hypothesis, or check scry_callers before making a change even if it’s fairly sure of the call sites. The cost of being wrong is nearly zero: a wasted tool call that takes 7ms.
Compare this to a tool that takes 2-3 seconds. The agent will try to avoid calling it. It’ll attempt to reason from what it already knows, or use Grep as a faster (but less precise) alternative. The agent optimizes for fewer tool calls when tools are slow, and optimizes for more precise information when tools are fast.
This is why I put the latency in the tool description (“under 10ms”). It’s not marketing. It’s behavioral guidance. The agent needs to know that calling scry is essentially free so it doesn’t hesitate.
The performance comes from the architecture (pre-computed indexes in BadgerDB, Unix socket transport, no network round-trips), but those are implementation details covered in Building Scry. From the MCP interface design perspective, the takeaway is: if your tool is fast, say so in the description. If it’s slow, consider whether that slowness will cause agents to route around you entirely.
What scry became
Everything above describes scry as a code intelligence server, which is what it was when I wrote it. It has since absorbed the three siblings. Scry today is a single daemon spanning five domains: code symbols, git history, database schemas, HTTP traffic, and a cross-domain graph that connects them. That is 23 MCP tools where there used to be four separate binaries, and it replaces tome, lore, and flume outright.
The graph is the part that changed how I use it. Nodes come from every domain, so a path can run from a symbol through the commit that introduced it to the table it writes to. It builds communities with Louvain detection and finds paths with BFS, which means an agent can ask “how does this service reach that table” and get an actual answer instead of a guess assembled from four separate lookups. scry_graph_report leans on the same structure to summarize architecture: highest-coupling nodes, feature clusters, cross-domain edges.
The lesson from the tool interface work held up. Each new domain was mostly a question of what the agent would actually ask, not a protocol problem.
What I’d do differently
Two things.
First, I’d add a scry_batch tool that accepts multiple symbols in one call. Agents often need to look up 3-4 symbols in sequence: the function they’re modifying, its callers, and the interface it implements. Today that’s three tool calls. A batch endpoint would collapse it to one.
Second, I’d invest more in the scry_status response. Right now it returns index freshness timestamps, but it doesn’t tell the agent which languages are supported in each repo or whether the index covers the specific file the agent is looking at. That would let the agent make better decisions about when to fall back to Grep.
The tool interface is never done. Every week of watching agent sessions reveals another pattern that could be served better. The protocol makes iteration cheap: adding a tool is a few lines of handler code and a good description string. The expensive part is always the same: figuring out what question the agent is actually trying to answer.
This post is part of a series on building MCP infrastructure. See also: What Are MCP Servers? for protocol fundamentals, Building MCP Servers in Go for implementation patterns, Building Scry for the indexing architecture, and The Context Layer for the broader thesis on agent-first tooling.
Frequently Asked Questions
What is a code intelligence MCP server?
A code intelligence MCP server gives AI agents structured access to semantic code data (definitions, references, call graphs, and interface implementations) through the Model Context Protocol. Instead of the agent grep-searching source files, it queries pre-indexed symbol data and gets precise results in milliseconds.
How does semantic code search differ from grep in an MCP server?
Grep searches text patterns across files. Semantic code search understands symbols: it knows that processOrder on line 47 is a function definition, not a string or comment. An MCP server built on semantic indexing returns only true code references with file, line, and symbol metadata, eliminating false positives and reducing agent token waste.
What tools should an MCP server for code navigation expose?
The core tools map to the questions agents actually ask: find definitions (where is X defined), find references (where is X used), find callers (what calls X), find callees (what does X call), find implementations (what implements interface X), and find test coverage. Each should be a separate, single-purpose tool rather than one generic query tool with mode parameters.
Why does MCP server response time matter for AI agents?
Agents learn tool latency characteristics through their system prompts and descriptions. A tool that responds in under 10ms gets used speculatively: the agent will call it just to confirm a hypothesis. A tool perceived as slow gets skipped in favor of grep. Sub-10ms response time changes how often and how aggressively agents use your tool.