MCP Server
MCP Server
The MCP server is a ~290-line Python process that bridges Claude Code to the pgvector database. It runs locally on the workstation, supports hybrid search (vector + keyword via RRF), wikilink graph traversal, and neighbor chunk expansion.
Source: /home/wes/projects/c0smere_devops/obsidian-rag-mcp/server.py
Runtime: Python 3.14, FastMCP framework
Dependencies: fastmcp, psycopg[binary], httpx
What is MCP?
Model Context Protocol is a standard for giving AI assistants access to external tools. The server communicates over stdio — Claude Code launches it as a subprocess and sends/receives JSON-RPC messages over stdin/stdout.
Configuration
Registered in .mcp.json at the project root:
{
"mcpServers": {
"obsidian-rag": {
"type": "stdio",
"command": "/home/wes/projects/c0smere_devops/obsidian-rag-mcp/.venv/bin/python",
"args": ["server.py"],
"env": {
"DATABASE_URL": "postgresql://rag_indexer:<password>@phlegethon.c0smere.net:5432/obsidian_rag",
"OLLAMA_URL": "http://cyrion.c0smere.net:11434"
}
}
}
}
Tools
search_notes(query, limit=10, mode="hybrid")
Finds note chunks matching the query. Supports three search modes:
mode="vector" — Pure cosine similarity search via pgvector. Best for conceptual/natural language queries (“how do I back up my server”).
mode="keyword" — PostgreSQL full-text search via websearch_to_tsquery + ts_rank_cd. Falls back to plainto_tsquery on parse errors. Best for exact term matching (“docker compose”).
mode="hybrid" (default) — Reciprocal Rank Fusion (RRF) of vector + keyword results:
- Fetch 3x
limitcandidates from each engine - Score each result as
1/(k + rank)from each list (k=60) - Results in both lists get summed scores — boosted to the top
- Take top
limitresults - Expand top 5 with neighboring chunks (chunk_index ± 1)
Neighbors are appended after their parent result, tagged with *(neighbor)* and a score of 0.0.
Reading scores:
- Vector mode:
> 0.7strong,0.5–0.7related,< 0.5weak - Keyword mode:
ts_rank_cdvalues (relative, not absolute) - Hybrid mode: RRF scores (small numbers, relative ranking matters)
graph_search(file_path, depth=1, include_content=False)
BFS traversal of the wikilink graph around a note. Follows both outgoing ([[links]] in the note) and incoming (other notes that link to it) edges, up to depth hops (max 3).
> graph_search("Digital_Garden/Obsidian-RAG/index.md", depth=2)
# Wikilink Graph: Digital_Garden/Obsidian-RAG/index.md
## Hop 1 (3 links)
- → Database Schema.md (via [[Database Schema]])
- → Indexer.md (via [[Indexer]])
- → MCP Server.md (via [[MCP Server]])
## Hop 2 (1 links)
- → Infra_RELOADED/Node-RED.md (via [[Node-RED]])
Total: 4 links across 2 hops
With include_content=True, each linked note includes a 500-character preview from its first chunk.
Useful for discovering related notes and understanding knowledge structure — follows explicit relationships rather than semantic similarity.
list_topics()
Returns a structured overview of everything in the index, grouped by top-level folder. Shows chunk counts per file as a rough proxy for document size.
get_note(file_path)
Retrieves the full content of a specific note by reassembling all chunks in order via chunk_index. Use list_topics() or search_notes() to find file paths first.
Internal Architecture
Search Pipeline
search_notes(query, mode="hybrid")
│
├─→ _vector_search(query, limit*3)
│ └─ get_embedding(query) via Ollama → pgvector cosine search
│
├─→ _keyword_search(query, limit*3)
│ └─ websearch_to_tsquery → tsvector @@ match → ts_rank_cd
│
└─→ _rrf_fuse(vector_results, keyword_results, limit)
└─ Score by 1/(k+rank), sum for results in both lists
└─→ _expand_neighbors(top_results)
└─ Fetch chunk_index ± 1 for top 5 hits
Embedding Function
def get_embedding(text: str) -> list[float]:
with httpx.Client() as client:
resp = client.post(
f"{OLLAMA_URL}/api/embed",
json={"model": EMBED_MODEL, "input": text},
timeout=30.0,
)
resp.raise_for_status()
return resp.json()["embeddings"][0]