• Grep for Feelings: An MCP Tool for Semantic Search

    A teal armchair beside a warm floor lamp, with glowing threads connecting blank notes beneath a window overlooking the city at twilight

    Grep doesn’t understand feelings. You search for “fear” and it dutifully returns every literal occurrence of those four characters. Meanwhile, paragraphs discussing “anxiety”, “dread”, and “terror” slip right through. Grep is extremely good at its job — it’s just that its job is pattern matching, not comprehension.

    This becomes a problem when you’re analyzing text that’s semantically rich. Like, say, psychological journals.

    The Use Case

    I’ve been digging through psychology papers and therapy notes, looking for patterns in how emotions are described. The thing is, humans are wonderfully inconsistent. One author writes about “fear responses,” another discusses “anxiety disorders,” a third mentions “panic states.” They’re all circling the same conceptual territory, but grep treats them as completely unrelated strings.

    What I needed was grep that went to therapy itself — something that understands words have meaning.

    Enter Word2Vec

    Word2Vec is one of those “why didn’t we think of this sooner” ideas. Train a neural network on massive amounts of text, and it learns to represent words as vectors in high-dimensional space. Words that appear in similar contexts end up close together. “King” minus “man” plus “woman” equals “queen” — that kind of magic.

    The practical upshot: you can measure semantic similarity between words. And if you can do that, you can build a grep that finds not just what you typed, but what you meant.

    mcp-w2vgrep

    So I built mcp-w2vgrep — an MCP server that combines Word2Vec embeddings with ripgrep’s speed. You give it a search term, it finds semantically similar words, then searches for all of them in your files.

    Search for “fear” and you’ll get results containing “anxiety”, “terror”, “dread”, “panic”, and “horror”. The similarity scores tell you how close each match is to your original query.

    Docker: Because Dependencies Are a Horror Story

    Here’s the thing about Word2Vec: it requires models. Big ones. The FastText models I’m using are about 2.3GB each. Plus you need the w2vgrep binary, ripgrep, Node.js, and various other pieces.

    Installing all of this on your machine is possible. It’s also the kind of experience that makes you question your life choices.

    So everything runs in Docker. One docker compose build and the toolchain is done. The models still have to land on your disk once, but that’s a single curl and a long coffee break.

    Setup

    Clone the repository and build the image:

    bash
    git clone https://github.com/astartsky/mcp-w2vgrep.git
    cd mcp-w2vgrep
    docker compose build

    Compose names the image after the directory and the service, so you end up with mcp-w2vgrep-mcp-w2vgrep:latest.

    Next, download a model. The FastText vectors are published by Meta; the English one unpacks to roughly 2.3GB:

    bash
    mkdir -p ~/.config/semantic-grep
    curl -L https://dl.fbaipublicfiles.com/fasttext/vectors-crawl/cc.en.300.bin.gz | gunzip > ~/.config/semantic-grep/english.bin

    Swap cc.en.300 for cc.ru.300 (and english.bin for russian.bin) if your notes are in Russian. Both models can live side by side.

    Now wire it into Claude Code. MCP servers are configured in .mcp.json at the project root (shared with anyone who opens that project) or in ~/.claude.json for your user scope. Not in ~/.claude/settings.json — a mistake I made the first time around, which is why the original version of this post sent people down the wrong path. A project-scope .mcp.json looks like this:

    json
    {
      "mcpServers": {
        "w2vgrep": {
          "command": "docker",
          "args": [
            "run",
            "--rm",
            "-i",
            "-v",
            "/Users/you/.config/semantic-grep:/app/models:ro",
            "-v",
            "/Users/you/Documents/research:/search:ro",
            "-e",
            "DOWNLOAD_MODELS=none",
            "mcp-w2vgrep-mcp-w2vgrep:latest"
          ]
        }
      }
    }

    Use absolute paths: Docker doesn’t expand ~ inside those arguments. The first volume is where the models live inside the container (/app/models), the second is whatever directory you want to search. Both are mounted read-only, because a search tool has no business writing anything.

    If you’d rather not touch JSON by hand, the CLI does the same thing:

    bash
    claude mcp add-json w2vgrep '{"command":"docker","args":["run","--rm","-i","-v","/Users/you/.config/semantic-grep:/app/models:ro","-v","/Users/you/Documents/research:/search:ro","-e","DOWNLOAD_MODELS=none","mcp-w2vgrep-mcp-w2vgrep:latest"]}'

    Add --scope user to make the server available in every project. Claude Code asks for approval before using a project .mcp.json server for the first time; claude mcp list from the shell or /mcp inside a session shows whether it actually connected.

    Usage

    Once configured, you can ask Claude to search semantically:

    “Search my research notes for content related to ‘anxiety’”

    Claude calls the semantic_search tool with a query and a model_path — inside the container that’s /app/models/english.bin. By default it only looks at *.md files; pass a glob to widen that. Under the hood, mcp-w2vgrep expands “anxiety” into similar terms, then ripgreps through /search. Results come back as JSON sorted by similarity, where an exact match scores 1.0 (context lines trimmed here):

    json
    {
      "query": "anxiety",
      "total": 2,
      "matches": [
        {
          "similarity": 0.82,
          "match": "The patient described intense fear when approaching the building",
          "locations": [{ "file": "notes/session-03.md", "line": 47 }]
        },
        {
          "similarity": 0.76,
          "match": "A sense of dread that builds throughout the workday",
          "locations": [{ "file": "notes/session-07.md", "line": 23 }]
        }
      ]
    }

    Tuning the Threshold

    The threshold parameter controls how similar a word needs to be to count as a match:

    • 0.7 (default): Strict. Only very close semantic matches.
    • 0.5–0.6: Balanced. Good for exploratory searches.
    • Below 0.5: Cast a wider net. Expect noise — and brace for output measured in millions of characters. The tool will warn you, but it won’t stop you from shooting yourself in the foot. Setting context to 0 keeps the flood somewhat manageable.

    Limitation: Single Words Only

    One important limitation: queries must be single words. No phrases, no multi-word concepts — a phrase may even crash the search outright. This is a constraint of the underlying w2vgrep tool — Word2Vec embeddings are per-word, and there’s no phrase composition happening under the hood.

    If you need to search for a concept that spans multiple words, find the closest single-word synonym. “Cognitive behavioral therapy” becomes “CBT” or just “therapy”. “Panic attack” becomes “panic”. It’s a constraint, but one that forces clarity about what you’re actually looking for.

    Closing Thoughts

    Regular grep is a precision instrument. You know exactly what you’re looking for, you write the pattern, you get the matches. It’s deterministic and reliable.

    Semantic search is fuzzier. It’s for when you’re exploring, when you want to cast a net around a concept and see what you catch. It’s not a replacement for grep — it’s a different tool for a different job.

    The source code is on GitHub. MIT licensed, because knowledge should be free and I’m too lazy to enforce anything else.