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:

  1. Fetch 3x limit candidates from each engine
  2. Score each result as 1/(k + rank) from each list (k=60)
  3. Results in both lists get summed scores — boosted to the top
  4. Take top limit results
  5. 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.7 strong, 0.5–0.7 related, < 0.5 weak
  • Keyword mode: ts_rank_cd values (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]

Wesley Ray · blog · git · resume