Build a Coding Assistant with Weaviate MCP: RAG over Code & Docs
Last week I asked Claude Code to implement something relatively trivial in my codebase. Three turns in, the conversation used up >80K tokens and Claude was still missing some crucial information I'd forgotten to include. That's the loop you fall into without retrieval: paste too little and the...
Last week I asked Claude Code to implement something relatively trivial in my codebase. Three turns in, the conversation used up >80K tokens and Claude was still missing some crucial information I'd forgotten to include. That's the loop you fall into without retrieval: paste too little and the agent guesses, paste too much and pay for context the agent isn't using. Most teams solve this with RAG over the codebase. The typical setup is a vector database plus a custom MCP server process bridging the two. Weaviate simplifies this: the MCP server is built into the database, at /v1/mcp on the same port as the REST API. One env var enables it. The same hybrid search you'd use for any other Weaviate workload powers code retrieval, with the BM25 half keeping function identifiers like connect_to_local matchable and the vector half finding semantic intent like "how do I init a client." This post walks through building a coding assistant on top of that built-in MCP server: ingest a codebase, ingest its docs, connect Claude Code, Cursor, and VS Code, and run real queries. Topics covered: Why your coding assistant needs more than its training data Why Weaviate MCP fits this job Step 1: Run Weaviate with MCP enabled Step 2: Design the schema Step 3: Chunk and ingest the codebase Step 4: Chunk and ingest documentation Step 5: Connect Claude Code, Cursor, and VS Code Try it out Agent runbook: autonomous setup Why your coding assistant needs more than its training data LLMs ship with a fixed cutoff and zero knowledge of your private code. The naive workaround is to dump files into the prompt. That has three problems. Cost: Tokens in context are billed. Every turn. A 200-file Python project doesn't fit, and even the parts that do are billed continuously while the agent reasons. Stale context: Once a file is in the prompt, it's frozen. If the agent changes a function and then needs to read it again, it has to reload the whole file. There's no live link between the model's view and the on-disk truth. Wrong granularity: Even when files fit, the model spends attention on the wrong parts. Imports. Module-level boilerplate. The function the agent is actually editing competes for context with a hundred lines of from x import y. Retrieval solves all three. Index the codebase once, store the chunks in a vector database, and let the LLM client pull only what it needs per query. That's RAG. Coding assistants haven't had a clean way to talk to such a database without a custom shim. That's where MCP comes in. Why Weaviate MCP fits this job The Model Context Protocol (MCP) is the standardized way for LLM clients like Claude Code, Cursor, and VS Code to call out to external tools. Weaviate v1.37.1 exposes its core operations as MCP tools directly, on a Streamable HTTP endpoint at /v1/mcp. Four tools are surfaced: weaviate-collections-get-config — let the LLM inspect what collections exist and what properties they have weaviate-tenants-list — list tenants when you're using multi-tenancy weaviate-query-hybrid — run hybrid (BM25 + vector) search weaviate-objects-upsert — write objects back, only when write access is enabled Hybrid search is the most concrete reason this stack works for a coding assistant. Code is a mix of identifiers and intent. BM25 nails the identifiers. Vectors nail the intent. A query like "where do we handle retry on 429?" wants both at once: vectors find the semantically related retry code, BM25 anchors on 429 as an exact token. Pure-vector retrieval drops the integer match. Pure-BM25 misses any wording the user didn't already know. Hybrid wins on this kind of mixed-intent query. Operational simplicity is the second reason. Competing stacks run an MCP server alongside the vector database. That's a second service to watch in production. Weaviate ships the MCP server inside the database, on the same port, with the same auth. The thing you have to monitor is just Weaviate. The third reason is multi-tenancy. One Weaviate instance can hold many codebases, each isolated as a tenant. For an organization with multiple repos, that's one cluster instead of one-per-team. Weaviate MCP vs function calling comes up as a natural question. Function calling is per-LLM-API. MCP is transport-level: any client that speaks MCP can call any server that speaks MCP, no rewrites. Build the retrieval once, use it from Claude Code, Cursor, and VS Code without translating. Step 1: Run Weaviate with MCP enabled The MCP server is disabled by default. Two environment variables turn it on: # docker-compose.ymlservices: weaviate: image: cr.weaviate.io/semitechnologies/weaviate:1.37.1 ports: - '8080:8080' - '50051:50051' environment: MCP_SERVER_ENABLED: 'true' MCP_SERVER_WRITE_ACCESS_ENABLED: 'true' AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true' DEFAULT_VECTORIZER_MODULE: 'text2vec-openai' ENABLE_MODULES: 'text2vec-openai' OPENAI_APIKEY: ${OPENAI_APIKEY} MCP_SERVER_ENABLED exposes the read tools (config, tenants, hybrid query). MCP_SERVER_WRITE_ACCESS_ENABLED adds the upsert tool, which lets the agent write findings back. Skip it if you only want retrieval. Honestly, I'd skip it for a while even if you think you want write-back. Read-only MCP covers most of what a coding agent actually does, and you sidestep a class of failure modes where the agent writes nonsense back into your knowledge base. Turn write access on once you have a specific use case that justifies the risk. Auth for productionThis example uses AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true' so the post stays focused on the MCP wiring. For any networked deployment, enable an API key and add Authorization: Bearer to your client config — Weaviate's MCP server respects standard auth and RBAC. See §"Going further" for the RBAC permissions involved. Bring it up and confirm the endpoint is alive. Streamable HTTP requires an initialize handshake before any other call, so the liveness probe sends one: docker compose up -dcurl -sf -X POST http://localhost:8080/v1/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{ "jsonrpc":"2.0","id":0,"method":"initialize", "params":{ "protocolVersion":"2025-03-26", "capabilities":{}, "clientInfo":{"name":"curl","version":"1"} } }' A live MCP server returns a JSON-RPC envelope describing the server's capabilities and sets an Mcp-Session-Id response header that subsequent calls must echo back. You don't need to parse the body for a sanity check — a non-empty response is enough. If you get a connection refused or a 404, MCP isn't enabled. Step 2: Design the schema Two collections, one for code chunks and one for documentation chunks, sharing the same Weaviate instance: import weaviatefrom weaviate.classes.config import Property, DataType, Tokenization, Configureclient = weaviate.connect_to_local()client.collections.create( name="CodeChunks", properties=[ Property(name="content", data_type=DataType.TEXT, tokenization=Tokenization.WORD), Property(name="symbol", data_type=DataType.TEXT, tokenization=Tokenization.LOWERCASE), Property(name="file_path", data_type=DataType.TEXT, tokenization=Tokenization.FIELD), Property(name="language", data_type=DataType.TEXT, tokenization=Tokenization.FIELD), Property(name="repo", data_type=DataType.TEXT, tokenization=Tokenization.FIELD), ], vector_config=Configure.Vectors.text2vec_openai(),)client.collections.create( name="DocChunks", properties=[ Property(name="content", data_type=DataType.TEXT, tokenization=Tokenization.WORD), Property(name="title", data_type=DataType.TEXT, tokenization=Tokenization.WORD), Property(name="source_url", data_type=DataType.TEXT, tokenization=Tokenization.FIELD), ], vector_config=Configure.Vectors.text2vec_openai(),) The tokenization choices matter. symbol uses lowercase so connect_to_local is one token instead of three. file_path, language, and repo use field so they match exactly. Prose properties (content, title) use word. If this section feels familiar, the tokenization post explains why each method belongs where. Step 3: Chunk and ingest the codebase Naive line-based chunking destroys code. A function split across two chunks loses its signature on one side and its body on the other. The fix is to chunk along syntactic boundaries: one chunk per function, one per class, one per top-level statement. Python's standard library is enough for Python code (no extra dependency). For other languages, tree-sitter is the standard choice (10+ languages, AST-aware, fast). import astfrom pathlib import Pathdef chunk_python_file(path: Path) -> list[dict]: """Emit one chunk per top-level function or class.""" source = path.read_text() tree = ast.parse(source) lines = source.splitlines() chunks = [] for node in ast.iter_child_nodes(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): start = node.lineno - 1 end = node.end_lineno chunks.append({ "content": "\n".join(lines[start:end]), "symbol": node.name, "file_path": str(path), "language": "python", "repo": "my-service", }) return chunks Batch-ingest with the standard Weaviate batch pattern: code_chunks = client.collections.get("CodeChunks")with code_chunks.batch.dynamic() as batch: for py_file in Path("./src").rglob("*.py"): for chunk in chunk_python_file(py_file): batch.add_object(properties=chunk) Weaviate vectorizes each chunk on insert via the configured text2vec-openai module, so there's no separate embedding step. Replace the vectorizer with text2vec-voyageai (Voyage's voyage-code-2 is purpose-built for code) or text2vec-cohere if you want a different embedding family. Step 4: Chunk and ingest documentation Prose chunks differently. Headings are real boundaries. A useful default is "split on H2, then on H3 if a section is too long": import redef chunk_markdown(text: str, max_chars: int = 1500) -> list[str]: sections = re.split(r"(?m)^## ", text) chunks = [] for section in sections: if len(section) docker-compose.yml 1500: for sub in re.split(r"(?m)^### ", sec): sub = sub.strip() if sub: batch.add_object(properties={ "content": sub, "title": md.stem, "source_url": f"file://{md.resolve()}", }) doc_count += 1 else: batch.add_object(properties={ "content": sec, "title": md.stem, "source_url": f"file://{md.resolve()}", }) doc_count += 1 print(f"STEP_2_DOC_CHUNKS={doc_count}") if code_count + doc_count == 0: print("STEP_2_FAILED: no chunks ingested. Check CODE_DIR / DOCS_DIR paths.", file=sys.stderr) sys.exit(2) print("STEP_2_INGEST_OK")finally: client.close()Success criteria: stdout contains STEP_2_INGEST_OK and the chunk counts are non-zero. If both counts are zero, the input paths point to empty or unsupported content.Note on idempotency: re-running this script appends objects rather than upserting. For a clean re-ingest, delete the collections first:curl -X DELETE http://localhost:8080/v1/schema/CodeChunkscurl -X DELETE http://localhost:8080/v1/schema/DocChunksStep 3: Wire up the LLM clientPick the block matching the agent's host client. All three point at the same Weaviate endpoint.Claude Code (preferred, single command):claude mcp add weaviate http://localhost:8080/v1/mcp --transport httpIf the claude CLI is not on PATH, write the config directly:python3 -c 'import json, os, pathlibp = pathlib.Path(os.path.expanduser("~/.claude.json"))data = json.loads(p.read_text()) if p.exists() else {}data.setdefault("mcpServers", {})["weaviate"] = { "type": "http", "url": "http://localhost:8080/v1/mcp"}p.write_text(json.dumps(data, indent=2))print("STEP_3_CLAUDE_OK")'Cursor (~/.cursor/mcp.json):python3 -c 'import json, os, pathlibp = pathlib.Path(os.path.expanduser("~/.cursor/mcp.json"))p.parent.mkdir(parents=True, exist_ok=True)data = json.loads(p.read_text()) if p.exists() else {}data.setdefault("mcpServers", {})["weaviate"] = {"url": "http://localhost:8080/v1/mcp"}p.write_text(json.dumps(data, indent=2))print("STEP_3_CURSOR_OK")'VS Code with Copilot (project-local .vscode/mcp.json):mkdir -p .vscodecat > .vscode/mcp.json