neo4j-mcp-gateway
# Neo4j MCP Gateway
A single local **MCP gateway** for Neo4j. Run it once, connect from VS Code and
Claude Desktop, and get **two categories of tools behind one stdio endpoint**:
1. **Generic querying** — *proxied* from the official
[neo4j/mcp](https://github.com/neo4j/mcp) server (schema introspection +
read/write Cypher + GDS). These are **not reimplemented**: the gateway spawns
the supported server as a downstream child and re-exposes its tools unchanged
(`get-schema`, `read-cypher`, `write-cypher`, `list-gds-procedures`).
2. **Use-case tools** — parameterized, purpose-built tools defined as **YAML
files**, shipped in swappable **[bundles](#bundles-swappable-use-cases)**
(`bundles/<name>/tools/`). Adding one is: drop in a new `*.yaml` and restart.
They run their own parameterized Cypher and are namespaced (`usecase_*`) so
they never collide with the proxied tools.
The point: keep the official, supported server intact for generic work, while
making it trivial to add and iterate curated use-case tools.
```
┌──────────────────────── neo4j-mcp-gateway (this repo) ─────────────────────────┐
│ │
VS Code│ ┌───────────────┐ mount ┌──────────────────────────────┐ stdio (child) │
Claude ─┼─▶│ FastMCP server │◀──────────│ FastMCP proxy (create_proxy) │─────────────────┼─▶ official neo4j/mcp
Desktop│ │ (stdio) │ └──────────────────────────────┘ │ (uvx / docker / binary)
(stdio)│ │ │ add_tool ┌──────────────────────────────┐ bolt │
│ │ │◀──────────│ YAML tools (neo4j driver) │─────────────────┼─▶ Neo4j
│ └───────────────┘ └──────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
```
---
## Bundles (swappable use cases)
The `gateway/` package is a **domain-agnostic engine**. Everything use-case-specific
lives in a **bundle** — a self-contained folder under `bundles/`:
```
bundles/<name>/
bundle.yaml # metadata only (name, description, model instructions,
# REQUIRED security.mode, downstream.*, tool prefix) — NO secrets
.env # optional, git-ignored: this bundle's Neo4j connection override
tools/*.yaml # static parameterized use-case tools
pytools/*.py # optional code-backed tools (build_tools(ctx) -> [Tool]) for
# logic that isn't static Cypher
data/*.cypher # demo dataset generator(s) + demo docs
```
Pick the active bundle with `ACTIVE_BUNDLE` (or `--bundle`). The engine points the
tools, data path, downstream connection, and the server's model-facing
`instructions` at that bundle.
**Connection is env-only** — URI / username / password / database are read from
the root `.env`, then a bundle's git-ignored `.env` **overrides** them. Nothing
connection-related lives in `bundle.yaml`, so a bundle can target an entirely
separate Neo4j instance (e.g. a different Aura) with no secrets in committed files.
```bash
uv run neo4j-mcp-gateway --list-bundles # ato, iam, …
ACTIVE_BUNDLE=iam uv run neo4j-mcp-gateway # serve a specific bundle
uv run python scripts/new_bundle.py <name> # scaffold a new bundle
ACTIVE_BUNDLE=<name> uv run python scripts/validate_bundle.py # run all its tools
```
### Serving several bundles at once
`ACTIVE_BUNDLE` accepts a comma list. Each bundle keeps its **own connection**, so
they may sit on different databases or entirely different Neo4j instances:
```bash
ACTIVE_BUNDLE=ato,iam uv run neo4j-mcp-gateway # or --bundle ato,iam
```
Tools are then namespaced per bundle (`ato_mule_hubs`, `iam_client_activity`,
`iam_secure-read-cypher`), and each bundle keeps its own security posture — in the
example above `ato_read-cypher` stays available while `iam_read-cypher` is hidden.
Bundles sharing a datasource share one downstream official server rather than
spawning a redundant child. A single bundle behaves exactly as before (no bundle
prefix), so nothing changes unless you opt in.
> **Safety rule:** the gateway **refuses to start** if an `open` bundle and a
> `mediated` bundle resolve to the same database. The open bundle's unfiltered
> tools would read the very rows the mediated bundle protects, and no amount of
> tool-hiding fixes that. Give them separate databases, make both mediated, or run
> separate gateway processes.
Two costs worth knowing: every bundle's `instructions` are concatenated (which
dilutes tool selection — keep it to a handful of bundles), and each distinct
datasource spawns its own downstream child.
**Swap without editing files** — register one client entry per bundle, each pinned
via `env`:
```json
"mcpServers": {
"neo4j-ato": { "command": "uv", "args": ["run","--directory","/ABS/PATH","neo4j-mcp-gateway"], "env": {"ACTIVE_BUNDLE":"ato"} },
"neo4j-iam": { "command": "uv", "args": ["run","--directory","/ABS/PATH","neo4j-mcp-gateway"], "env": {"ACTIVE_BUNDLE":"iam"} }
}
```
### Access mode is a required declaration
Every `bundle.yaml` **must** state `security.mode` — there is no default, so
"unfiltered" is a recorded decision rather than something that happens by omission:
```yaml
security:
mode: open # tools read directly (all consumers uniformly entitled)
# mode: mediated # every read is entitlement-filtered against the caller
```
Under `mediated`, the engine:
1. registers **`resolve-identity`** and (optionally) **`secure-read-cypher`** —
entitlement mediation is an engine capability, so no bundle ships security code;
2. wraps **every curated YAML tool** in the authorization prelude + entitlement
filter, so the same tool returns different rows per caller;
3. **auto-hides raw `read-cypher`**, which would bypass the filter, and defaults
the downstream to read-only;
4. requires tools to use the mediated authoring form (below) and to be read-only.
Mediated tools declare the split explicitly rather than having the engine parse
Cypher to find the `RETURN` — getting that wrong would be a security bug:
```yaml
match: | # no RETURN
MATCH (t:Trade)-[:FOR_CLIENT]->(c:Client {name: $client})
scope: [t, c] # variables carried into the return; ALL are filtered
protect: [t] # optional: strict — must carry an ACL or the row is dropped
return: | # runs AFTER filtering, so aggregates are per-entitlement
RETURN t.tradeId AS tradeId, t.notional AS notional
```
**Postures.** A mediated bundle can publish curated tools *only* by setting
`security.expose_open_query_tool: false` (or `EXPOSE_OPEN_QUERY_TOOL=false` at
runtime). The open-ended `secure-read-cypher` is then never registered, so no
Cypher is generated at runtime — the stance for regulated workflows. The env
variable can only tighten: a bundle declaring curated-only cannot be re-opened
from the shell.
**Denials withdraw access someone otherwise holds.** A restricted list is not the
absence of a grant — coverage still covers the client — so it cannot be modelled
by deleting one. Grants and denials share a shape: `via` (a path from the caller),
`where` (a condition on the row), or both.
```yaml
grants:
- label: Trade
via: "(caller)-[:ON_DESK]->(:Desk)<-[:BOOKED_ON]-(resource)"
where: "resource.notional < 50000000" # a condition on the ROW
denials:
- label: Trade
where: "resource.restricted = true"
reason: "the trade is flagged restricted"
```
The test is `(granted) AND NOT (denied)` — **deny always wins**, and
`explain-access` reports which grants matched but were overridden. A denial whose
predicate is `NULL` does *not* fire, because an absent property yields `NULL` and
absence is not ambiguity; where absence should deny, write
`coalesce(resource.clearance, 0) < 3`.
**Which edges decide access?** Computed, not asserted:
```bash
uv run python scripts/entitlement_surface.py asset_platform
```
An entitlement edge is not a kind of edge — `AUTHORED_BY` is a business fact until
a grant traverses it. So the entitlement graph is the *projection* of the graph
onto the types and properties named in `grants`, `denials` and `identity`.
The split that decides how each edge can be **governed** is who writes it, which is
a deployment fact rather than a rule fact — so it is declared once in
`security.ingested_rels`. Anything absent from that map is **authored**, and a role
can be denied write on it outright:
```bash
uv run python scripts/entitlement_surface.py asset_platform --write-guard business_feed
```
In `asset_platform` that is 4 edges out of 22. The other 18 are written by a feed,
so a routine upstream edit moves access and the privilege cannot be taken away
without breaking ingestion. **A parallel "entitlement-only" copy of them does not
help** — it moves the same edit one step downstream and adds a staleness failure
nothing in the query can detect. Mint a separate type when you need to revoke
someone's ability to *write* it, not to tidy the diagram; full argument in
[`docs/entitlement-edges.md`](docs/entitlement-edges.md).
The report also flags a rule whose relationship type is absent from the graph — for
a grant that under-grants, but **for a denial it fails open** — and prices every
denial for **barrier coupling**: a denial can be lifted while a grant survives
whenever it traverses a feed-written edge that grant does not. Nothing is missing
from anyone's results when that happens, so no per-caller case and no
edge-presence invariant catches it. The only structurally total barrier is a
`where`-only denial, which traverses nothing.
**Thresholds are not principals.** `rankLevel >= 5` is an ordering, and a set of
names cannot express one without minting a principal per rank. Declare
`identity.caller_attributes: [rankLevel]` and a rule reads
`authz.attrs.rankLevel >= 5`, resolved once in the prelude. A scalar crosses a
separated-identity boundary where the caller *node* cannot, so thresholds work
unchanged under all three `identity.source` values.
Declare `protected_labels` so [`scripts/validate_bundle.py`](scripts/validate_bundle.py)
fails when a business record is missing its access-control list — otherwise such a
record silently flows to everyone. The validator also persona-diffs mediated tools
to prove the filter actually discriminates between callers.
New to this? [`docs/entitlement-model-brief.md`](docs/entitlement-model-brief.md)
explains the model conceptually in about three minutes. Full reference and known
limits: [`docs/mediation-spec.md`](docs/mediation-spec.md). What each entitlement
model costs the data pipeline — what must be ingested, by whom, and what breaks
when it is late: [`docs/data-ingestion.md`](docs/data-ingestion.md).
> **Note:** `get-schema` stays exposed even under `mediated`, because
> text-to-Cypher needs it. It reveals structure (labels, relationship types,
> property keys) but no row data. Add it to `downstream.hide` if your deployment
> treats the schema itself as sensitive.
### Audit logging
Set a path and every tool call appends one JSON object — who called, as whom,
which tool, in which bundle, the outcome, and **how many rows survived the
filter**:
```bash
NEO4J_MCP_AUDIT_LOG=/var/log/neo4j-mcp/audit.jsonl
NEO4J_MCP_AUDIT_ARGUMENTS=true # optional: also record argument VALUES
```
```json
{"ts":"2026-08-18T19:18:36.407+00:00","event":"tool_call","tool":"client_opportunities",
"bundle":"asset_platform","mode":"mediated","identitySource":"graph","grantModel":"both",
"principal":"evan.brooks@bank.com","principalSource":"impersonation-request",
"impersonated":true,"argumentNames":["client"],"durationMs":18.1,"outcome":"ok","rows":1}
```
**Row contents are never logged.** An audit log that copies the rows it audits is
a second, less-protected replica of the data the filter exists to restrict —
usually on a filesystem with weaker controls, often shipped to an aggregator a
different team can read. The record carries the row *count* and nothing about the
rows. Argument values are the judgement call and are off by default; argument
*names* are always recorded, so you can see which question was asked without its
subject.
`impersonated` is top-level rather than something to infer: running as another
principal is a privileged action and is the first thing a reviewer looks for.
Proxied tools are covered too, so an `open` bundle's raw `read-cypher` is audited
on the same terms — and a call to a hidden tool is recorded as a rejection.
A bundle can declare `security.require_audit: true`, and the gateway then
**refuses to start** without a log path — the same fail-closed stance as
`security.mode`.
**Records are hash-chained** (`seq`, `prev`, `hash`), so editing, deleting or
reordering a line is detectable:
```bash
uv run python scripts/verify_audit.py audit.jsonl --checkpoints checkpoints.jsonl
```
A chain proves tampering *only if its head exists somewhere the log's writer
cannot rewrite* — otherwise the file can be truncated and restarted and will
verify clean. `NEO4J_MCP_AUDIT_FORWARDER` publishes `(seq, head)` periodically to
such a place. `stderr` and `file:<path>` ship for development; a deployment
registers its SIEM or WORM sink with `gateway.audit.register_forwarder()` — a
one-method contract. Startup warns when nothing anchors the chain.
### Where identity lives
By default the identity graph sits beside the data and the prelude traverses it
in the same statement. `security.identity.source` moves it:
```yaml
security:
identity:
source: graph # default — identity beside the data, one statement
# source: composite # identity and data in separate databases, joined by a
# identity_graph: fed.identity # composite database. Still ONE
# data_graph: fed.data # statement and one transaction.
# source: remote # identity resolved over a SECOND connection, from
# remote_env_prefix: IDENTITY # IDENTITY_NEO4J_URI etc. in .env
```
`composite` and `remote` make the identity store independent — its own instance,
credentials and lifecycle, shareable across domains. Neither gives the data query
a caller *node*, so both forbid anchoring and tools that reference `caller`.
**Path grants survive both**, because a grant does not need the caller *node* —
it needs a value derived from the caller. A relationship cannot span two
databases, but a *traversal* can be cut at a node present in both: Neo4j's
documented **proxy node** pattern. The engine finds that cut itself and re-roots
the data-side half at the proxy, so patterns are authored once and mean the same
thing co-located or split. A grant that cannot be cut safely — an identity
relationship appearing after the boundary, which would deny silently — is
rejected at load. See GRANT_SPLITTING in
[`gateway/mediation.py`](gateway/mediation.py).
| | `composite` | `remote` |
| --- | --- | --- |
| Path grants (`grant_model: path` / `both`) | ✅ | ✅ |
| Anchoring | ✅ | ✅ |
| Round trips | 1 (one statement, one transaction) | 2, with a consistency window |
| Needs a composite database | yes | no — any two connections |
| Proxy nodes in the data database | required | required |
Anchors split by the same rule, so the performance lever survives too — measured
at ~17x either side of the split on 100,000 rows
([`scripts/bench_separation.py`](scripts/bench_separation.py)). What both give up
is tools that reference `caller` in their match; express that scoping with an
anchor or a parameter instead.
**Cutting at a property instead of a proxy node** removes the proxies entirely.
Where the boundary is already recorded as a property — the covering team on the
Client, the author on the Interaction — declare it and the grant compares that
property instead of traversing to a proxy:
```yaml
identity:
boundary_properties:
Client: coverageTeam
Interaction: loggedByEmail
```
Measured against a database holding **no proxy nodes at all**, results were
identical to co-located across 24 comparisons. Each grant also loses a hop, and a
boundary property on the row itself collapses to a bare comparison with no
subquery. See the recipe in
[`docs/entitlement-testing-tutorial.md`](docs/entitlement-testing-tutorial.md).
> The property and the relationship are two recordings of one fact and can drift
> apart. Nothing detects that from the pattern alone, so keep a `differential:`
> conformance case proving they agree on real data.
### Downstream identity: making native rules apply to the end user
Native database rules (RBAC, property rules, ABAC-assigned roles) are evaluated
against the account that **connects**. A gateway holding one service connection
gets them evaluated against the service account, so no per-user rule applies at
all. Two ways to close that, per session:
```bash
NEO4J_MCP_ACCESS_TOKEN=<jwt> # the caller's token authenticates the session
NEO4J_MCP_DB_IMPERSONATION=true # service account impersonates the principal
```
With a token, **the database validates it** — signature, issuer, audience,
expiry — and maps its claims to roles. The gateway never inspects it, which is
the point: token validation belongs to something built for it. Setting both is
refused, since a token already asserts who the caller is.
These compose with mediation rather than replacing it. Measured with a real PBAC
rule (`FOR (o:Opportunity) WHERE o.stage = 'Proposal'`) on a real native role:
| | Rows |
| --- | --- |
| service account — mediation only (her coverage) | 2 |
| impersonated — mediation ∩ PBAC | **1** |
The caller sees the **intersection**, which is what a layered model should do.
> One deployment note found the hard way: with identity co-located, the
> impersonated user also needs read access to the identity graph, or the
> authorization prelude resolves nothing and every query returns zero rows. It
> fails closed, but it looks like an entitlement bug. `identity.source: remote`
> avoids it — identity resolution uses its own connection and only the data query
> is impersonated.
Adding another source (an external entitlement service, LDAP, token
introspection) means implementing `IdentitySource` and registering it — see
[`gateway/identity_sources.py`](gateway/identity_sources.py).
Want to try the entitlement model on Aura?
[`docs/entitlement-testing-tutorial.md`](docs/entitlement-testing-tutorial.md)
walks through it across six identity/data topologies.
Shipped bundles: **`ato`** (account-takeover; 7 YAML tools; `mode: open`),
**`asset_platform`** (sector-classified asset universe; taxonomy-scoped
entitlement, two caller classes, dated scopes; the reference model) and
**`iam`** (investment-bank entitlements; `mode: mediated`, curated tools filtered
per caller, raw `read-cypher` auto-hidden). Neither bundle contains security
code — a bundle declares a policy and the engine enforces it.
---
## Prerequisites
- **Python 3.11+**
- **[uv](https://docs.astral.sh/uv/)** (`brew install uv` / `pipx install uv`)
- A reachable **Neo4j** instance (local, Docker, or Aura) with credentials
- The official downstream server is fetched automatically on first run via
`uvx neo4j-mcp-server` — no manual install. (Docker / a built Go binary also
work; see [`.env.example`](.env.example).)
> **Note:** the official server *verifies Neo4j connectivity at startup and
> exits if it cannot connect.* If your credentials are wrong or Neo4j is
> unreachable, the proxied `get-schema` / `*-cypher` tools will not appear —
> check the gateway's stderr log. The YAML use-case tools still load regardless
> and report connection problems as clean per-call errors.
---
## Setup
```bash
# from the project root
cp .env.example .env
# edit .env with your Neo4j URI / user / password / database
uv sync
```
`.env` (git-ignored) holds the real credentials. The **same** credentials flow
to both the downstream official server and the YAML tool executor.
| Variable | Default | Purpose |
| --- | --- | --- |
| `NEO4J_URI` | `bolt://localhost:7687` | Neo4j bolt URI (shared) |
| `NEO4J_USERNAME` | `neo4j` | Neo4j user (shared) |
| `NEO4J_PASSWORD` | `password` | Neo4j password (shared) |
| `NEO4J_DATABASE` | `neo4j` | Target database (shared) |
| `NEO4J_MCP_CMD` | `uvx neo4j-mcp-server` | How to launch the official downstream server |
| `NEO4J_READ_ONLY` | *(unset)* | `true` disables downstream `write-cypher` |
| `NEO4J_TELEMETRY` | `false` | Downstream telemetry opt-in |
| `ACTIVE_BUNDLE` | `ato` | Bundle(s) to serve — comma list for several at once |
| `EXPOSE_OPEN_QUERY_TOOL` | *(bundle)* | Set `false` to drop `secure-read-cypher` (tighten only) |
| `USECASE_PREFIX` | `usecase_` | Tool-name prefix (also settable in `bundle.yaml`) |
Tools and data come from the active bundle (`bundles/<ACTIVE_BUNDLE>/`); the
database and model instructions can be declared in its `bundle.yaml`.
---
## Run
```bash
uv run neo4j-mcp-gateway
# equivalent:
uv run python -m gateway.server
```
The gateway serves over **stdio** — that's what editors launch. On startup it
logs (to **stderr**) the downstream command, the mounted official tools, and the
YAML use-case tools it registered.
### Verify with the MCP Inspector
```bash
# List the union of tools (official proxied + YAML use-case)
npx @modelcontextprotocol/inspector --cli uv run neo4j-mcp-gateway --method tools/list
# Call a generic proxied tool
npx @modelcontextprotocol/inspector --cli uv run neo4j-mcp-gateway \
--method tools/call --tool-name get-schema
# Call a YAML use-case tool
npx @modelcontextprotocol/inspector --cli uv run neo4j-mcp-gateway \
--method tools/call --tool-name usecase_ato_session_triage --tool-arg min_risk=5
```
Or launch the Inspector UI (drop `--cli`) and browse/click the tools.
---
## Adding a use-case tool (the whole point)
1. Create `bundles/<active-bundle>/tools/my_tool.yaml` (e.g. `bundles/ato/tools/my_tool.yaml`):
```yaml
name: recent_transactions_for_customer
description: Recent transactions performed by a customer's accounts.
parameters:
- name: customer_id
type: string
description: Customer.customerId
required: true
- name: limit
type: integer
description: Max rows to return
required: false
default: 25
cypher: |
MATCH (c:Customer {customerId: $customer_id})-[:HAS_ACCOUNT]->(:Account)
-[:PERFORMS]->(t:Transaction)
RETURN t.transactionId AS id, t.amount AS amount, t.date AS date
ORDER BY t.date DESC
LIMIT $limit
read_only: true # set false to run in write mode
```
2. Restart the gateway (see [Restarting](#restarting-to-pick-up-new-tools)). It
appears as `usecase_recent_transactions_for_customer`.
> Tools are discovered once at startup and MCP clients cache the tool list, so a
> new/edited YAML file needs a restart to show up — saving alone is not enough.
**Schema reference**
| Field | Required | Notes |
| --- | --- | --- |
| `name` | ✅ | Alphanumeric/underscore. Final tool name is `<USECASE_PREFIX><name>`. |
| `description` | ✅ | Shown to the model. |
| `parameters` | — | List of `{name, type, description, required, default}`. |
| `parameters[].type` | — | `string` · `integer` · `number` · `boolean` · `array` · `object` (default `string`). |
| `cypher` | ✅ | Parameters bind to `$name` placeholders. |
| `read_only` | — | `true` (default) → read transaction; `false` → write transaction. |
Malformed files fail loudly at startup with a message naming the file. Results
are returned as JSON: `{ "count": N, "records": [ ... ] }`, with Neo4j temporal /
spatial / graph values converted to JSON-friendly forms.
**Fast dev loop** — test a tool without MCP or a gateway restart:
```bash
uv run python scripts/try_tool.py --list
uv run python scripts/try_tool.py mule_hubs min_victims=2
```
`scripts/try_tool.py` runs one tool's Cypher through the same loader/executor the
gateway uses, straight against Neo4j — so you get instant feedback while writing
YAML. Use the MCP Inspector to check the tool over MCP, and a client (Claude
Desktop) for the final integration.
---
## Restarting to pick up new tools
Adding or editing a YAML tool requires a restart. The cleanest way depends on how
the gateway is running:
- **In VS Code / Claude Desktop (normal use):** don't kill it in a terminal — let
the client restart it, which stops the process by closing its stdin (a clean,
instant shutdown).
- **VS Code:** open `.vscode/mcp.json` and click **Restart** on the server, or
run *MCP: List Servers → neo4j-gateway → Restart* from the command palette.
- **Claude Desktop:** toggle the connector off/on (or quit and reopen Claude).
- **Running it yourself in a terminal (e.g. testing with the Inspector):** a
single **Ctrl+C** stops it immediately — the gateway installs a fast
SIGINT/SIGTERM handler that exits at once and lets the downstream child close
via stdin-EOF, rather than blocking on an async teardown. `kill <pid>`
(SIGTERM) works the same way.
If you ever see leftover `neo4j-mcp-server` processes from an earlier session:
```bash
pgrep -fl 'neo4j-mcp-server|neo4j-mcp-gateway' # inspect first
pkill -f 'neo4j-mcp-server' # then clean up stale ones
```
> Heads-up: `pkill` will also stop the instance your editor is actively using, so
> restart that connector afterwards.
## Client configuration
Both clients launch the gateway over stdio. Credentials are read from this repo's
`.env` (no secrets in the client config).
### VS Code — `.vscode/mcp.json` (portable, already in this repo)
```json
{
"servers": {
"neo4j-gateway": {
"type": "stdio",
"command": "uv",
"args": ["run", "--directory", "${workspaceFolder}", "neo4j-mcp-gateway"]
}
}
}
```
Nothing is machine-specific here: `${workspaceFolder}` resolves automatically.
For the CodeLens **Start/Restart** buttons (and for `${workspaceFolder}`) to work,
**open this repo folder as the workspace root** (`File → Open Folder → the
`neo4j-mcp-gateway` folder`), not a parent directory — VS Code only reads
`.vscode/mcp.json` from the opened folder's root.
- **Start/stop it:** click **Start** on the CodeLens above `"neo4j-gateway"`, or
Command Palette → **MCP: List Servers → neo4j-gateway → Start**.
- **Use it:** in Copilot Chat switch to **Agent** mode, open the 🛠️ tools picker,
and enable the `neo4j-gateway` tools.
- **If VS Code can't find `uv`:** it was launched without your shell `PATH`.
Either start VS Code from a terminal (`cd neo4j-mcp-gateway && code .`), install
`uv` to a system-wide location, or replace `"uv"` with the absolute path from
`which uv`.
### Claude Desktop — `claude_desktop_config.json`
macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` ·
Windows: `%APPDATA%\Claude\claude_desktop_config.json`
Claude Desktop has no `${workspaceFolder}` and does **not** inherit your shell
`PATH`, so both paths must be absolute. Fill in your own with
`which uv` (the `uv` path) and `pwd` (this repo's path):
```json
{
"mcpServers": {
"neo4j-gateway": {
"command": "/ABSOLUTE/PATH/TO/uv",
"args": ["run", "--directory", "/ABSOLUTE/PATH/TO/neo4j-mcp-gateway", "neo4j-mcp-gateway"]
}
}
}
```
> Tip: you can add an `"env": { "NEO4J_URI": "…", "NEO4J_PASSWORD": "…" }` block
> here instead of using `.env` if you prefer per-client credentials.
### Sharing this repo
The repo is self-contained — a new user only needs, per machine:
```bash
git clone <repo-url> neo4j-mcp-gateway
cd neo4j-mcp-gateway
cp .env.example .env # fill in their Neo4j URI / user / password / database
uv sync # creates the venv; uvx fetches the downstream on first run
code . # open THIS folder in VS Code, then MCP: List Servers → Start
```
Prerequisites: **Python 3.11+**, **[uv](https://docs.astral.sh/uv/)**,
and (for the Inspector smoke test) **Node/npx**. No absolute paths to edit for the
VS Code flow; only the Claude Desktop config needs their own two paths.
## Demo data (account-takeover)
`bundles/ato/data/ato_demo.cypher` seeds a small, self-contained **ATO** dataset —
realistic legitimate baseline, two fraud patterns (classic takeover + mule ring),
and a false-positive traveler for precision discussion. Load it with:
```bash
cypher-shell -a "$NEO4J_URI" -u "$NEO4J_USERNAME" -p "$NEO4J_PASSWORD" -d "$NEO4J_DATABASE" -f bundles/ato/data/ato_demo.cypher
```
It's idempotent and namespaced (`source:'ato-demo'`), so it won't disturb other
data. See [`bundles/ato/data/README.md`](bundles/ato/data/README.md) for the roster,
the ground-truth scoring fields, and copy-paste detection queries.
**ATO demo:** [`bundles/ato/DEMO.md`](bundles/ato/DEMO.md) — quickest path to a
working demo: load, verify, serve, and the results to expect.
**Demo docs:**
- [`bundles/ato/data/README.md`](bundles/ato/data/README.md) — the **presenter
runbook** (drives the tools explicitly; good with the MCP Inspector).
- [`bundles/ato/data/demo_prompts.md`](bundles/ato/data/demo_prompts.md) —
**conversational prompts** to paste into Claude Desktop so the model orchestrates
the tools itself — the assistant picks the tools and narrates the investigation.
---
## Project layout
```
neo4j-mcp-gateway/
gateway/ # ENGINE (domain-agnostic; never changes per use case)
server.py # entrypoint: build proxy + load bundle tools + serve stdio
proxy.py # spawn & re-expose the official neo4j/mcp downstream
yaml_tools.py # YAML discovery, validation, MCP registration, Cypher execution
mediation.py # entitlement mediation: prelude + filter composition
security_tools.py # resolve-identity / secure-read-cypher for mediated bundles
pytools.py # load code-backed bundle tools (build_tools(ctx))
middleware.py # HideToolsMiddleware (hide proxied tools, e.g. read-cypher)
bundles.py # bundle manifest parsing + discovery
config.py # env + active-bundle resolution
scripts/
try_tool.py # fast dev loop: run one tool, no MCP/restart
new_bundle.py # scaffold a new bundle from bundles/_template
validate_bundle.py# run every tool in a bundle against a live DB
check_entitlements.py # conformance cases; --identity-source sweeps topology
entitlement_surface.py # which edges decide access; --write-guard emits DENY DDL
ingest_business_hierarchy.py# project the HR view -> OrgUnit tree, IN_UNIT, REPORTS_TO
ingest_coverage_teams.py # project the coverage view -> CoverageTeam, COVERS
load_asset_platform.sh # the five load steps, in dependency order
bundles/ # SWAPPABLE use cases (pick one with ACTIVE_BUNDLE)
_template/ # skeleton copied by new_bundle.py
ato/ # account-takeover bundle
bundle.yaml # metadata + non-secret config
tools/*.yaml # the 7 ATO tools
data/ # ato_demo.cypher + demo docs
iam/ # investment-bank entitlements bundle
bundle.yaml # security.mode: mediated + protected_labels
tools/*.yaml # curated mediated tools (match/scope/return)
data/iam_demo.cypher
asset_platform/ # the reference entitlement model — 20 labels, 47 cases
bundle.yaml # grants, denials, caller_attributes, ingested_rels
tools/*.yaml # research, interactions, trade blotter, compensation
data/views/ # sample business_hierarchy + coverage_teams extracts
data/*.cypher # business graph, authored policy, trades and compensation
.vscode/mcp.json
.env.example # root creds/defaults (per-bundle .env overrides)
pyproject.toml
README.md
```
## Design notes / extending
- **Namespacing** — official tools keep their original names; YAML tools are
prefixed (`usecase_`), so names can never collide.
- **Lazy driver** — the YAML executor connects to Neo4j on first tool call, so
the gateway starts and lists tools even if Neo4j is briefly down; connection
errors surface as clean tool errors.
- **Retrieval-ready** — the YAML registry (`load_tool_specs` in
[`yaml_tools.py`](gateway/yaml_tools.py)) is cleanly separated from execution,
so a future vector-index / kNN routing layer could sit in front of it without
touching the executor. (Not implemented — out of scope for now.)
- **Extending routing** — to add non-YAML tools, register them on the `gateway`
server in [`server.py`](gateway/server.py) with `gateway.add_tool(...)`.
## Troubleshooting
| Symptom | Cause / fix |
| --- | --- |
| Only `usecase_*` tools appear | Downstream couldn't reach Neo4j and exited. Fix `NEO4J_URI`/creds; check gateway stderr. |
| `uvx` slow on first run | It downloads the official server wheel once, then caches it. |
| Claude Desktop can't start it | Use the **absolute** path to `uv` in `command`. |
| YAML tool returns an error | The message includes the Neo4j error code — verify the Cypher and params. |
TDQS
Scored across 3 tools
Each tool addresses a distinctly different use case: synthetic identity detection, movie search, and high-risk transaction analysis. There is no overlap or ambiguity in their intended purposes.
All tools share the consistent 'usecase_' prefix, but the action part varies: 'detect', 'search', and a noun phrase 'high_risk_transactions' (no verb). This mix of verb_noun and noun-only patterns is somewhat inconsistent.
With only 3 tools, the set is on the low side for a Neo4j gateway that could plausibly support many graph operations. It feels like a small demo set rather than a comprehensive service, but it is not absurdly thin.
The tools are a random assortment of curated use cases with no clear domain coverage. There is no general query, write, or management capability, and the movie search seems unrelated to fraud/AML use cases, leaving significant gaps for any given purpose.