A Zero-Config MCP Server for Filtered Vector Search in Neo4j
Auto-discover Neo4j vector indexes, sample property types and generate typed MCP search tools that use Cypher 25 in-index filtering without any manual setup.
This article is published in English.
Picture a knowledge graph full of news articles and an agent facing this request: show the positive coverage of renewable energy from Reuters last week. Three separate things are packed into that sentence. One is a semantic search on "renewable energy". The others are metadata filters on source (Reuters), sentiment (positive) and date (last week).
Plain vector search handles the semantic part. Without filtering, though, it will cheerfully return negative articles from 2019 posted by random blogs, and the LLM has no means of knowing they're irrelevant until it has read every one. That's the metadata-filtering vector search problem, and Neo4j long offered two ways to attack it.
- Pre-filter with Cypher to shrink the candidate set, then run the vector search over what remains. Results are exact, but it can get slow when the candidate set is still big.
- Run the vector search across the full index first, then post-filter. That's fast, yet recall can drop as filters get narrower, because relevant items may never make the initial top-k.
Both had real strengths, and both left room for improvement.
What Neo4j 2026.02 changes
Neo4j 2026.02 (see the release notes) made vector search with filters generally available as part of Cypher 25. Predicates can now be applied inside the vector index at query time, so the HNSW algorithm skips non-matching vectors before ranking. Latency stays low, recall stays high and nothing has to be over-fetched. A new native SEARCH clause also arrives, replacing the older db.index.vector.queryNodes() procedure and making vector search a first-class citizen of the query language.
That raises a question: could an MCP server discover vector indexes by itself, inspect their metadata properties and generate filtered search tools with no manual configuration?
A generic vector search server with zero configuration
Anyone who has built MCP tools for vector search knows the routine: write the Cypher, define the parameters, set up the embedding model, document the filters. For one well-known index that's fine. But a drop-in server that works with any Neo4j database, where it's simply pointed at an instance and works out the rest, is more appealing.
That is what this server does. At startup it connects to Neo4j, finds every vector index, samples a node from each to detect property types and creates one fully typed MCP tool per index, with filter parameters and a docstring the LLM can read.
One thing can't be discovered automatically: the embedding model. Neo4j doesn't store that in index metadata yet, and an API key would be needed to call the model anyway. So the embedding provider and its credentials are the only configuration that has to be supplied.
Step 1: find the vector indexes
First, the server asks Neo4j for all vector indexes:
records, _, _ = driver.execute_query(
"""
SHOW VECTOR INDEXES
""",
database_=NEO4J_DATABASE,
)
The result gives each index's name, the node label it covers and the indexed properties. If there are no vector indexes, the server exits with an error, since there's nothing to serve.
Step 2: sample types and example values
For every index, the server needs to know which property holds the embedding and what type each of the others has. Does published_date hold a plain date or a full timestamp? Is source text? Is the sentiment score a decimal number? A dedicated _sample_property helper answers by inspecting a single node per property:
def _sample_property(driver, label: str, prop: str) -> tuple[str, Any]:
"""Return (type, sample_value) for a single property."""
cypher = (
"MATCH (n:$($label)) WHERE n[$prop] IS NOT NULL "
"WITH n LIMIT 1 RETURN n[$prop] AS val"
)
result, _, _ = driver.execute_query(
cypher, label=label, prop=prop, database_=NEO4J_DATABASE
)
if not result:
return "string", None
val = result[0]["val"]
return _detect_type(val), val
It returns the detected type and the actual sample value. The main function gathers those into two dictionaries, one for types and one for examples:
def _sample_property_types(
driver, label: str, properties: list[str]
) -> tuple[str | None, dict[str, str], dict[str, Any]]:
embedding_prop: str | None = None
meta_types: dict[str, str] = {}
meta_examples: dict[str, Any] = {}
for p in properties:
t, sample = _sample_property(driver, label, p)
if t == "vector":
embedding_prop = p
else:
meta_types[p] = t
meta_examples[p] = sample
return embedding_prop, meta_types, meta_examples
The _detect_type function checks, in order, for a list (a vector), bool, int, float, datetime and date, and falls back to string. Datetime and date count as different types. A property with time components is typed datetime and gets Neo4j's datetime() cast, not date(). The list of floats is the embedding, and every other property becomes a filterable metadata field. For the news example, the outcome looks like this:
embedding -> vector
published_date -> date (or datetime, if timestamps are present)
source -> string
sentiment -> float
The meta_examples dictionary keeps the real sample values, such as "Reuters" for source or 0.72 for sentiment, and they enrich the generated docstrings in the next step.
Step 3: generate the search tools
Now everything comes together. For each index, a closure captures that index's metadata and produces a search function:
def _make_search_fn(idx: dict[str, Any]):
"""Factory that closes over a single index's metadata."""
index_name = idx["name"]
label = idx["label"]
embed_prop = idx["embed_prop"]
prop_types = idx["prop_types"]
prop_examples = idx["prop_examples"]
# Build the filter-parameter docstring dynamically
filter_lines = []
for prop, t in prop_types.items():
safe = prop.replace(" ", "_").replace("-", "_")
ex = _format_example(prop_examples.get(prop), t)
if t in ("float", "int"):
filter_lines.append(
f" {safe}: Optional dict with 'min' and/or 'max' (numeric range). Example value: {ex}"
)
elif t == "datetime":
filter_lines.append(
f" {safe}: Optional dict with 'min' and/or 'max' (ISO datetime strings, uses datetime()). Example value: {ex}"
)
elif t == "date":
filter_lines.append(
f" {safe}: Optional dict with 'min' and/or 'max' (ISO date strings, uses date()). Example value: {ex}"
)
elif t == "bool":
filter_lines.append(
f" {safe}: Optional bool. Example value: {ex}"
)
else:
filter_lines.append(
f" {safe}: Optional str (exact match). Example value: {ex}"
)
filters_doc = "\n".join(filter_lines) if filter_lines else " (none)"
async def search(
query: str,
top_k: int = 10,
filters: Optional[dict[str, Any]] = None,
) -> str:
query_vec = embedder.embed_query(query)
where_clause, cypher_params = _build_where(prop_types, filters or {})
where_part = f"\n WHERE {where_clause}" if where_clause else ""
cypher = (
f"CYPHER 25\n"
f"MATCH (n:$($label))\n"
f" SEARCH n IN (\n"
f" VECTOR INDEX {index_name}\n"
f" FOR $query_vec{where_part}\n"
f" LIMIT $top_k\n"
f" ) SCORE AS score\n"
f"RETURN n {{ .*, `{embed_prop}`: null }} AS doc, score\n"
f"ORDER BY score DESC"
)
cypher_params["query_vec"] = query_vec
cypher_params["top_k"] = top_k
rows, _, _ = driver.execute_query(
cypher, cypher_params, database_=NEO4J_DATABASE
)
results = [{"doc": dict(r["doc"]), "score": r["score"]} for r in rows]
return json.dumps(results, indent=2, default=str)
search.__name__ = f"search_{index_name}"
search.__qualname__ = f"search_{index_name}"
search.__doc__ = (
f"Semantic vector search over :{label} nodes using the "
f"'{index_name}' index.\n\n"
f"Args:\n"
f" query: Natural-language search text (will be embedded).\n"
f" top_k: Number of results to return (default 10).\n"
f" filters: Optional dict of metadata filters:\n"
f"{filters_doc}\n"
)
return search
Some details deserve attention:
- The
CYPHER 25prefix switches on the newer syntax needed for the SEARCH clause and in-index filtering. - The
WHEREclause lives inside the SEARCH block. That's the 2026.02 in-index filtering at work. HNSW itself honors the predicates, so approximate nearest-neighbor results come back filtered without losing recall to post-filtering. - The embedding property is nulled in the returned rows (
{embed_prop}: null) so large vectors don't travel back to the LLM, where they would only burn tokens. - The docstring is built dynamically from the sampled types and includes a concrete example value for each filter. The agent therefore sees which filters exist, which types they expect and what realistic values look like.
Building the WHERE clause
A _build_where function turns a Python filter dictionary into a Cypher predicate, choosing the form from each property's declared type:
- Floats and ints get range filters through
{"min": ..., "max": ...}, producing>=and<=comparisons. - Datetimes use the same range logic, wrapped in the
datetime()cast. - Dates use it too, wrapped in
date(). - Booleans use exact equality.
- Strings, the default, also use exact equality.
All predicates are joined with AND, which matches the simple property comparisons Neo4j supports inside vector-index filtering.
def _build_where(
prop_types: dict[str, str], filters: dict[str, Any]
) -> tuple[str, dict[str, Any]]:
clauses: list[str] = []
params: dict[str, Any] = {}
for prop, t in prop_types.items():
safe = prop.replace(" ", "_").replace("-", "_")
val = filters.get(safe)
if val is None:
continue
if t in ("float", "int"):
if isinstance(val, dict):
if val.get("min") is not None:
k = f"filt_{safe}_min"
clauses.append(f"n.`{prop}` >= ${k}")
params[k] = val["min"]
if val.get("max") is not None:
k = f"filt_{safe}_max"
clauses.append(f"n.`{prop}` <= ${k}")
params[k] = val["max"]
elif t == "datetime":
if isinstance(val, dict):
if val.get("min") is not None:
k = f"filt_{safe}_min"
clauses.append(f"n.`{prop}` >= datetime(${k})")
params[k] = val["min"]
if val.get("max") is not None:
k = f"filt_{safe}_max"
clauses.append(f"n.`{prop}` <= datetime(${k})")
params[k] = val["max"]
elif t == "date":
if isinstance(val, dict):
if val.get("min") is not None:
k = f"filt_{safe}_min"
clauses.append(f"n.`{prop}` >= date(${k})")
params[k] = val["min"]
if val.get("max") is not None:
k = f"filt_{safe}_max"
clauses.append(f"n.`{prop}` <= date(${k})")
params[k] = val["max"]
elif t == "bool":
k = f"filt_{safe}"
clauses.append(f"n.`{prop}` = ${k}")
params[k] = val
else: # string exact match
k = f"filt_{safe}"
clauses.append(f"n.`{prop}` = ${k}")
params[k] = val
where = " AND ".join(clauses)
return where, params
Why not let the LLM write the Cypher?
The Neo4j MCP Cypher server could generate queries via text-to-Cypher, but vector search needs embeddings, and today the only way to move an embedding between tools is through the LLM context. Pushing 1536-dimensional vectors through the model wastes tokens and time.
Wrapping the whole workflow, embedding generation, Cypher construction and execution, in a single tool keeps embeddings out of the model's context. The LLM supplies a natural-language query and optional filters and receives scored results.
Running it
The server can run straight from GitHub with uvx. Add an entry like this to the MCP client configuration for Claude Desktop, VS Code, Cursor or similar:
{
"mcpServers": {
"neo4j-vector": {
"command": "uvx",
"args": [
"--from",
"git+https://github.com/tomasonjo/neo4j-prefiltering-mcp",
"--with",
"langchain-openai",
"neo4j-prefiltering-mcp"
],
"env": {
"NEO4J_URI": "neo4j+s://demo.neo4jlabs.com:7687",
"NEO4J_USER": "companies2",
"NEO4J_PASSWORD": "companies2",
"NEO4J_DATABASE": "companies2",
"EMBEDDING_MODEL": "openai:text-embedding-3-small",
"OPENAI_API_KEY": "<your-key>"
}
}
}
}
The --with langchain-openai flag installs the OpenAI embedding provider. With another provider, such as Cohere or a local model, swap that dependency and update EMBEDDING_MODEL. Embedding lookup goes through LangChain's init_embeddings(), so any provider LangChain supports will work.
Testing in Claude Desktop with a sample question and expanding the tool call shows parameters like these:
{
"query": "positive developments in artificial intelligence AI breakthroughs progress",
"top_k": 10,
"filters": {
"language": "en",
"date": {
"min": "2026-01-01",
"max": "2026-12-31"
},
"sentiment": {
"min": 0.5
}
}
}
Nothing needs hand-writing: no tool definitions, no queries, no filter schemas. Create another vector index, restart the server and another tool appears.
The takeaway
Vector search with filtering in Neo4j 2026.02, together with the Cypher 25 SEARCH syntax, gives a clean route to filtered vector search tools. Combined with MCP and a little startup introspection, the result is a server that finds vector indexes, detects filterable properties and generates typed search tools without a single line of tool-specific code. Aim it at a Neo4j database that has vector indexes and it assembles its own toolset. The filtering documentation has the full syntax.