DEV Community

Building Custom MCP Servers: Extending AI with Tools

The Protocol That Actually Standardizes Tooling MCP (Model Context Protocol) is essentially a JSON-RPC 2.0 service with a schema for capabilities, tools, resources, and prompts. The server declares tools; the host discovers them; the model decides when to call them. That sounds abstract until you realize what it replaces: a pile of bespoke LangChain wrappers, ad-hoc API endpoints, and "let me stuff the whole repo into the prompt" hacks. MCP is the rare protocol that wins on boredom. It's simple, stateless (if you want it to be), and has enough transport options (stdio, SSE, streamable HTTP) that you can put it in front of a local CLI or a remote session. We chose Python with the mcp SDK because our text-processing pipeline was already Python-native. But the protocol constraints pushed us into a cleaner design: every tool needs a JSON Schema for input, a name, and an output shape that gets packed back into a content block. There's no "just return the dict" wiggle room. That forced us to define a contract for every query before writing any search logic. Contracts are a feature when you're building an interface that a probabilistic system will be driving. The Problem: Slice-Grep Does Not Scale Before codebase-memory-mcp , I watched agents operate on codebases roughly like this: they get a prompt, they try to recall from training data, they fail, they request a file path, the host returns a raw file, and the agent starts spraying grep calls. The agent never gets a map. It never gets "here are the ten modules that touch the payment webhook." It gets a hunk of source code and a hope. A codebase has structure at a level above raw text: symbols, import graphs, path hierarchies, role-specific conventions (tests vs. production, migrations vs. models). The first painful lesson was that embeddings alone can't express that. A vector search will happily match a comment about "retry policy" in a README and a test fixture with zero actual retry logic. Semantic similarity is necessary but not sufficient. So we built a hybrid index: a SQLite database with three layers - (1) file metadata and path structure, (2) symbol-level records (function names, class names, exports), and (3) chunked text with 768-dimension embeddings stored via sqlite-vec . The MCP tools we exposed became the SQL layer for the model, carefully shaped so that a model knows when it has a precise question (symbol lookup) and when it should perform a fuzzy recall (semantic search). Designing the Tool Surface The biggest question in an MCP server is: what is the toolkit? We kept it to seven tools initially. Every subsequent tool we proposed had to earn its place by solving a category of agent failure we'd actually observed. The first tool is semantic_search , which takes a query string and a number of results. It does embedding-based retrieval with pre-filtering. The second is search_symbol , an exact symbol/lookup tool using a trigram index and the language parser (tree-sitter) - no embeddings, just identifier-aware matching. That distinction matters. A model should never use fuzzy search when it knows the exact name of a function. The third and fourth are get_file_structure and read_file_lines , which handle the "can you show me the tree" and "show lines 40-70" operations. We added find_references for cross-file references, and get_recent_commits to answer "what changed recently" without reading every diff. Finally, remember and recall let the agent store a note about a design decision into a separate SQLite table, allowing memory to persist across separate MCP sessions. from mcp.server import Server from mcp.server.models import InitializationOptions async def handle_semantic_search(query: str, limit: int = 5) -> dict: """Embed a query and search over the combined table.""" embedding = embedder.embed(query) # (768,) sql = """ SELECT path, start_line, text, ivec_distance(chunk_embedding, ?) AS distance FROM chunks WHERE path IN (SELECT path FROM files WHERE indexed_at IS NOT NULL) ORDER BY distance LIMIT ? """ rows = await db.execute(sql, [embedding, limit]) return {"results": [chunk_to_dict(r) for r in rows]} def build_mcp_server(db, embedder) -> Server: server = Server("codebase-memory") @server.list_tools() async def list_tools(): return [ Tool( name="semantic_search", description="Search code by semantic similarity. Prefer this when you know the intent but not the identifier.", inputSchema={ "type": "object", "properties": { "query": {"type": "string"}, "limit": {"type": "number", "minimum": 1, "maximum": 20} }, "required": ["query"] } ), ] @server.call_tool() async def call_tool(name: str, arguments: dict): if name == "semantic_search": result = await handle_semantic_search(**arguments) return {"content": [{"type": "text", "text": json.dumps(result)}]} raise ValueError(f"Unknown tool: {name}") return server Notice that semantic_search hides the embedding dimension and the distance metric. The model doesn't need to know that ivec_distance is an L2 metric. It just needs a ranked list. The tool surface is a contract, not an implementation. The Schema That Makes the Model Honest The SQLite schema is the quiet under-appreciated piece. An MCP request comes in, and a tool handler runs SQL. If the schema is sloppy, the model gets ambiguous results and starts to make things up. We designed the chunks table with one urgent constraint: UNIQUE(file_id, start_line) . CREATE TABLE files ( id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE, language TEXT, last_commit_sha TEXT, last_modified_at TEXT, indexed_at TEXT, is_test BOOLEAN DEFAULT FALSE ); CREATE TABLE chunks ( id INTEGER PRIMARY KEY, file_id INTEGER REFERENCES files(id), start_line INTEGER NOT NULL, end_line INTEGER NOT NULL, text TEXT NOT NULL, chunk_embedding BLOB, -- 768 floats, serialized symbol_names TEXT, -- JSON array UNIQUE(file_id, start_line) ); CREATE VIRTUAL TABLE chunks_vectors USING vec0( chunk_embedding FLOAT[768], chunk_id INTEGER ); CREATE INDEX idx_chunks_symbol_names ON chunks(symbol_names); WARNING: Don't store the embedding as a JSON string and hope sqlite-vec will parse it on the fly. It won't. Two separate tables - one for metadata, one virtual vector table - keep insertion and query fast. The vec0 virtual table selects for chunk_id and distance, which we then join back into the chunks table. One decision that paid off: instead of storing embeddings for files, we store embeddings for chunks, and every chunk carries symbol_names as JSON. That makes semantic search over the chunks table naturally useful for "where do we handle refund " queries with line-level precision. The model doesn't need to guess a file path if the search tool gives back src/payments/refunds.py:42 . We also marked is_test on the files table and filtered it for retrieval by default, because the agent should not be learning happy-path patterns from test utility functions unless it explicitly asks. Why SQLite Over Postgres or a Vector Database We had this debate for two weeks. We spun up a pgvector instance. We benchmarked Pinecone. We knew that in production, one might want a Postgres server anyway. But this project runs locally, on a developer's machine, often in a terminal with no Postgres. The deciding metric wasn't raw vector recall - it was cold-start time and dependency count. SQLite has zero external service to babysit, and sqlite-vec compiles cleanly into a Python extension. That means you can clone this repo, run python -m codebase_memory index . , and have a fully queryable local index in 40 seconds for a medium-sized repository. Meanwhile, a managed vector database requires an API key, a network call on every embedding lookup, and an orchestration layer to keep the remote index consistent with the local checkout. For a developer tool that should disappear into the editor, local-first was the only rational choice. | Aspect | SQLite + sqlite-vec | pgvector | Hosted vector DB | |---|---|---|---| | Cold-start time | | Python API, no SQL | | Memory / session mutations | Trivially transactional | Transactional, but heavier | Requires logic in the API layer | | Scaling ceiling | GB-scale local corpora | TB-scale shared | TB-scale distributed | | Best use case | Single-agent local context | Multi-agent shared backend | Cross-team semantic search | We chose SQLite because the model's lifetime for a given MCP session is measured in minutes, not months. The index is a mirror of a specific checkout at a specific commit - ephemeral by design. If the code changes, re-index that file; don't build a warehouse. Tool Internals: The Guardrail Pattern The most interesting engineering pattern inside this server is what I call the guardrail: a two-phase read where we validate the shape of the query before touching whatever the model asked to do. The reason is simple. A model's JSON output is occasionally malformed; its arguments object can have the right key but a wildly out-of-range value. In one early session, the agent called read_file_lines with start_line = -1 and end_line = 1000000 . We would happily have returned the entire file. So every tool now follows the same discipline: - Validate types and bounds with a tiny custom validator that returns a human-readable error message. - Estimate the result size before executing (e.g., SELECT COUNT(*) for a line range). - Cap the result at a bounded size. - Return is_truncated: true if the result was clipped. This pattern prevents three failure modes we saw in the wild: unbounded memory bloat from a monster file read, silent hallucination from a truncated result that wasn't marked truncated, and cascading agent retries caused by unhelpful JSON-RPC error messages. Instead of "Error: invalid value" , the model sees "start_line must be >= 1; received -1" . That single change cut agent retry loops by a measurable share - roughly a third of our early task-completion failures traced back to the model misusing a tool because the error message was useless. The Streamable HTTP Dance We starte

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.