Metadata-Version: 2.5
Name: mcpindex-gate
Version: 0.22.0
Summary: mcpindex gate - the in-path trust-to-act client for MCP tool calls, from mcpindex.ai (the MCP server index). The config-wire install wires a proxy that posts nothing; the Python SDK wrapper posts credential-blind call receipts by default (MCPINDEX_RECEIPT_INGEST_ENABLED=0 to disable).
Project-URL: Homepage, https://mcpindex.ai
Project-URL: Documentation, https://mcpindex.ai/docs
Author: mcpindex.ai
License-Expression: MIT
Keywords: agent,drift,gate,mcp,security,tools,trust
Requires-Python: >=3.12
Requires-Dist: pydantic<3,>=2.12
Requires-Dist: rfc8785==0.1.4
Provides-Extra: signed
Requires-Dist: sigstore<5,>=4.5; extra == 'signed'
Description-Content-Type: text/markdown

# mcpindex-gate

The in-path **drift gate** for agent tool calls - the open-core client of
[mcpindex.ai](https://mcpindex.ai), the drift-monitored MCP server index.
It runs on your host, sends no credentials anywhere (wiring does pass your server env through its process), and checks every MCP tool definition
against *your own pinned baseline* before a call goes out. The Python SDK `wrap()` path
posts a credential-blind receipt per gated call to mcpindex.ai by default; set
`MCPINDEX_RECEIPT_INGEST_ENABLED=0` for zero egress. The `mcpindex-config-wire` install
wires the stdio proxy, and the proxy does not emit receipts today, so that path sends
nothing. See "Call receipts" below.

> **Renamed:** previously published as `mcpindex-preflight`. That name remains a
> deprecated alias that depends on this package. New installs should use
> `mcpindex-gate`.

## What it does

`mcpindex-gate` produces a **drift decision** - `PROCEED` / `HOLD` /
`INCONCLUSIVE` - by diffing the live tool contract against the contract you pinned.
If a server silently changes a tool's schema, description, or surface, the gate can
**HOLD** the call and surface why.

```python
from mcpindex_gate import wrap, PreflightPin, PreflightHold

session = wrap(your_mcp_client_session, pin=PreflightPin(path="~/.mcpindex/pin.json"))

try:
    result = await session.call_tool("transfer_funds", {...})
except PreflightHold as hold:
    # The tool contract drifted from your pin - inspect `hold` and decide.
    print(hold)
```

`wrap()` accepts any duck-typed client session; this package does **not** depend on
the `mcp` SDK.

## What it is - and is not

- It is a **contract diff**, not a safety oracle. It detects that a tool *changed*;
  it does not judge whether the change is malicious.
- A `HOLD` means "this drifted from your pin - look before you act." It is
  **advisory**. It does **not** block attacks, guarantee safety, or make a server
  tamper-proof.
- It mints **no** clearance verdict. The offline client can detect drift and HOLD;
  it can never publish a "SAFE" verdict.

### What `status` tells you (0.11.0)

`mcpindex-config-wire status` reports **enforcement**, not merely that our proxy is in
the launch line:

- `[x]` requires that *nothing* is un-wired, unreadable, or non-enforcing. Before 0.11.0
  the mark appeared as soon as one server was wired and could never turn off.
- `[!]` means real coverage gaps, and names them by host.
- `notify-only` counts servers launched with `--posture monitor`, which releases every
  drift hold (a tamper hold and an internal error still stop the call). It
  is a legitimate choice, but it is not protection, so it is never counted as such.
- `will not start (bad gate command)` (0.13.2) counts servers whose launch line the proxy
  rejects outright, so the server never starts and you lose the server as well as the
  protection. Nothing we wire produces this; it comes from a hand edit to the config. Fix it
  by un-wiring that host and wiring it again, which rebuilds the launch line from scratch.
- `keeps its baseline outside mcpindex` (0.13.4) counts servers whose config entry points
  `--pin-store` at a file outside `~/.mcpindex/pins/`. That server may well still hold, but
  against a baseline nothing else here can read, and one whose deletion the gate can no
  longer detect, because the record of what was pinned lives beside the store and moves with
  it. Wiring never writes that flag, so an entry only carries one because something edited
  your config. It is not counted as protection, and `mcpindex-config-wire wire --repin`
  moves the baseline back. In 0.13.3 and earlier these servers were counted as fully
  enforcing.
- `hosts scanned: N` is stated because project-scoped configs (a repo's own `.mcp.json`,
  `.cursor/mcp.json`, `.vscode/mcp.json`, ...) are **not** scanned yet. The total is a
  floor, not a census.

### If the gate HOLDs on your baseline

A HOLD whose reason names the *baseline* rather than a tool contract means the pin store
for that server was present but unreadable, or was deleted after having been pinned. The
gate refuses to proceed rather than silently re-learning the contract from whatever the
server is serving right now. This holds in every posture and regardless of `fail_open`,
because neither is consent to trust a baseline that may have been rewritten.

To recover deliberately, run `mcpindex-config-wire reset-pins <server>` (add
`--config PATH` when the entry is not in a default host config, and `--yes` to skip the
confirm). That removes the pin store and its ever-pinned record for that server. The next
list pins from whatever the server serves at that moment - a reset re-trusts the current
contract - so do it only when you know why the store was unreadable (an interrupted write,
a restored backup, a machine migration).

**Honest limit:** this is tamper-*evident*, never tamper-*proof*. A process running as you
that replaces the store with a well-formed one carrying its own baseline is still trusted.
No same-user control prevents that: any secret the gate can read, a process running as you
can read too.

## Install

```sh
uv tool install mcpindex-gate
```

One-click host wiring + a resident auto-onboard watcher are available via the
installer at <https://mcpindex.ai/install.sh>.

## Call receipts (on by default, SDK wrapper only)

Receipts are emitted by the Python SDK `wrap()` path. The `mcpindex-config-wire`
install wires the stdio proxy, and the proxy does not emit receipts today, so a
wire install sends nothing. Naming which half sends is the point.

After each gated call the gate emits a compact, credential-blind receipt: a hash of
the tool identity, the gate verdict, and the action class (read / write / execute). It
never includes tool arguments, result content, server names, or URLs. Receipts are
linked by a random per-install token stored in `~/.mcpindex/install_id` -- pseudonymous,
not derived from you or your machine. Keyless installs have no account link; if you sign
in on mcpindex.ai and configure an `api_key`, receipts from that install are associated
with your key.

From gate v0.9.0, the first run on a machine prints a one-line disclosure of exactly
this behavior to stderr and sends nothing that session -- emission starts on run 2, so
no receipt ever leaves before the notice and the opt-out were visible. Earlier versions
emit without the runtime notice, per this README.

After 10 gated calls the ambient notifier fires a one-line message on stderr:

```
mcpindex - 10 tool calls noted - mcpindex.ai/receipts?id=<your-install-id>
```

That link shows your per-install call log. To suppress receipt egress entirely:

```sh
export MCPINDEX_RECEIPT_INGEST_ENABLED=0
```

## Weekly summary line (local, zero egress)

At most once per ISO week the gate prints one line summarizing what it did last week --
calls gated, tools and servers watched, drift seen, holds issued:

```
mcpindex - week 2026-W29: 240 call(s) gated across 12 tool(s) / 3 server(s), 0 drift - your baseline is holding - mcpindex.ai/receipts?id=<your-install-id>
```

The counters live only in `~/.mcpindex/weekly_stats.json`; nothing about this line is
sent anywhere. To silence it:

```sh
export MCPINDEX_WEEKLY_SUMMARY=0
```

## Project config scanning (local read, ON by default)

`mcpindex-config-wire` counts the MCP servers you actually run. Many of them are not in
your global host config: they are declared per-project, in a repo's own `.mcp.json`,
`.cursor/mcp.json`, `.vscode/mcp.json`, `.gemini/settings.json` or `.zed/settings.json`.
A gate that cannot see them reports a coverage number that is a floor, not a census - on
one real machine it said 25 servers while 35 were loaded.

To find those repos it reads the project list Claude Code already keeps in
`~/.claude.json`. **This widens what the tool reads from about ten fixed paths in your
home directory to every repository you have opened in Claude Code.** It is a local,
read-only scan and nothing about it is sent anywhere, which is stricter than the gated-call
path: that one posts a receipt by default.

**Detect-only.** Project configs are counted and reported, never wired. They are
version-controlled and shared, so wiring one would commit a change pointing your
teammates at a gate they have not installed - and every server would fail for them.
Wiring one is possible, but it takes an explicit `--config <path>`, after you have read
the file.

To turn the scan off, or to add a repo Claude Code does not know about:

```sh
mcpindex-config-wire status --no-project-scan      # or: export MCPINDEX_PROJECT_SCAN=off
mcpindex-config-wire status --project ~/work/my-repo   # repeatable; for Cursor / Gemini / Zed users
```

With the scan off, the server count is a floor and `status` says so rather than printing
a smaller number without the caveat.

## Drift telemetry (opt-in, OFF by default)

The gate can report **that** a tool's contract drifted - so mcpindex can track drift on
servers it can't crawl itself (private / auth-gated). It is **off by default and sends
nothing** unless you turn it on:

```sh
export MCPINDEX_DRIFT_TELEMETRY=detection   # off (default) | lookup | detection | contribute
```

- `off` - the default. Sends nothing, asks nothing.
- `lookup` - **read-only**. Asks whether a tool's contract has already drifted, so the gate
  can warn you on the first call. Sends one fingerprint per tool, which is a key over a public registry rather than
  anonymity, and reports **none of
  your own catches back**.
- `detection` - `lookup`, plus each tool you pin and each contract drift sends **one one-way
  signal**.
- `contribute` - reserved for a future richer tier; behaves identically to `detection` today.

Each tier is a superset of the one before it. See https://mcpindex.ai/privacy and
https://mcpindex.ai/docs for exactly which fields leave the machine.

### Where the setting lives (v0.13.0+)

For servers you wired through the gate, set it with the wiring command rather than in your
shell - the wired entry is what the proxy actually launches with:

```sh
mcpindex-config-wire --drift-telemetry lookup    # or detection / contribute / off
```

Your choice is recorded in `~/.mcpindex/consent.json` and **re-applied on every later
re-wire**, including on servers already wired.

> **0.13.2 and earlier:** this did not work. Wiring skipped servers it had already wired, so on
> an already-configured machine `--drift-telemetry off` reported success and cleared nothing.
> On those versions you must pass `--repin`. Fixed in 0.13.3.

Before v0.13.0 the setting lived only in the host config entry, where the gate could not
tell your own opt-in from one something else had written - so wiring cleared it, and a
scripted re-wire (`--yes`, an editor auto-wire) silently turned your setting off.

The record holds the mode, a label for how it was set, when, and - when the setting was
carried over from an existing wired config - the host label it came from. No paths, no server
or tool names. (In 0.13.1 and 0.13.2 that field held a file path from your machine instead.)
It is local, is never
sent anywhere, and is not a security boundary: anything running as your user can edit it, exactly as it can edit your
host config. What it buys is that your setting survives, and that a wired entry which
disagrees with it gets reported instead of silently obeyed.

To turn everything off and clear it from every wired entry:

```sh
mcpindex-config-wire wire --drift-telemetry off
```

## Upgrade, pin-versions, and review (0.18.0 and later)

Under `guard` and `strict`, a new tool that appears on a server you already pinned is
left out of the tool list and a call to it holds. It is not trusted mid-session on first
sight. A process running as you that rewrites the pin store with its own well-formed pins
is still trusted, as the honest-limit paragraph above says. Pass `--new-tools tofu` (or
`new_tools=tofu` on the wrapper) only when you want that older first-sight behaviour back
for that server.

The pin records every top-level field a server lists for a tool: `title`, `icons`, `_meta`,
`execution`, and any field a later spec adds, plus annotation keys other than the four hint
booleans. A change to any of them on a pinned tool is drift: under `guard` and `strict` the
tool is left out of the list and its call holds; `monitor` shows it. A pin written before
0.19.0 records those fields once, from the first list whose definition matches, and compares
them from then on. A field that changes between the upgrade and that first list is trusted
the way a first-seen tool is. A first `outputSchema` is held the same way when it carries
any string or any property name, which covers most structured output: expect one review per
tool when a server starts returning it. A bare `{"type": "object"}` is shown.

The proxy and the Python wrapper (`on_context_drift=`) read the server's `instructions`,
pin them beside the tool pins and report a change. They never edit or withhold the text.

### Upgrade a wired server

```sh
mcpindex-config-wire upgrade demo --to 1.1.0
```

Example (from the upgrade approval fixtures):

```text
demo: launch npx -y pkg@1.1.0
demo: new=1 changed=0 removed=0 unchanged=0
{
  "description": "Echo a line",
  "inputSchema": {
    "properties": {},
    "type": "object"
  },
  "name": "echo"
}
demo: pinned
```

The command rewrites the wired launch to the target version, shows the review, and pins
what the probe listed after you confirm. First-recorded fields and real changes both need
a confirm when stdin is a TTY (or pass `--yes`).

### Record launch versions on existing pins

```sh
mcpindex-config-wire pin-versions
```

Example:

```text
filesystem/read_file: title = "Read File (Deprecated)"
filesystem: stamped=1 first_recorded=1 changed=0 new=0 removed=0
context7/resolve-library-id: outputSchema = {"properties": {"id": {"type": "string"}}, "type": "object"}
context7/resolve-library-id: title = "Resolve Library Id"
context7: stamped=1 first_recorded=1 changed=0 new=0 removed=0
```

`pin-versions` writes the launched version onto pins whose definition still matches, and
asks once before recording first-recorded fields (titles and other fields the pin never
stored). A real field change uses the upgrade confirm path.

### List and approve held tools

```sh
mcpindex-report pending
mcpindex-report approve demo note sha256:...
mcpindex-report approve --all
```

Example `pending` output:

```text
srv note changed-without-upgrade sha256:...
{
  "description": "Write a note for the operator",
  "inputSchema": {
    "properties": {},
    "type": "object"
  },
  "name": "note"
}
```

`approve <server> <tool> <hash>` pins one held definition when the digest matches.
`approve --all` lists each floating row, asks for confirm, then pins only those digests.
It refuses `changed-without-upgrade` records; those need an explicit `approve` or an
`upgrade` after you have reviewed the change.

## Lock mode (mcp-lock draft 1, opt-in)

Lock mode is a second way to run the gate. Instead of pinning what a server served the first
time, the host reads a lock file kept in your repository, `.mcp/lock`, and shows and allows
only the tools approved there, digest for digest. Nothing is approved at runtime: no first
use, no auto-accept, no re-pin. A tool that is new, changed or listed twice is withheld from
the model, and a call to it gets JSON-RPC error -32011 with the reason in `data.reason`.

It follows the mcp-lock format, draft 1. The format can still change before it settles, and
lock mode is off unless you pass `--lock`. The default proxy and gateway behave as before.

A few limits, stated plainly:

- A digest match says the definition the model sees is the one you approved. It says nothing
  about what the server does when a tool is called.
- `mcpindex-lock ci` and the mcp-lock-watch action are advisory. They open their own
  connection, and a server can answer them differently from your hosts. Only a host
  running in lock mode enforces the lock, on its own connection.
- Without a configured signer, anyone who can write to the repository can change the lock.
  Review of `.mcp/` changes is then the only control.

### Build a lock with mcpindex-lock

Run these in the repository that will hold the lock. `add` connects to the server, records
every tool it serves as pending and approves nothing:

```sh
mcpindex-lock init
mcpindex-lock add --stdio "npx -y @modelcontextprotocol/server-memory" --name memory
mcpindex-lock add --url https://mcp.deepwiki.com/mcp --name deepwiki
```

```text
server npm:@modelcontextprotocol/server-memory -> .mcp/lock/modelcontextprotocol-server-memory--7f87d99c372307a2.json (new)
alias memory
9 tools listed over 1 page(s); none approved by this command
  pending add_observations sha256:1cfe175c1b1959b73de6ce4b50a56cdd2c06f967008ca47596b1f0072fc25a94
  ...
  pending read_graph sha256:f651a47dec6b1222061803c2bbf1eacfdb401cb8b5b1e9a74ad08faf0f287b7c
  pending search_nodes sha256:91412ee61bb324117c7446bb38dd6b8e8030b6a4b75743481faadc1b80aff31a
approve with: mcpindex-lock approve memory TOOL --digest sha256:...
```

`mcpindex-lock diff memory` shows each pending definition in full, from the contract files
`add` wrote under `.mcp/contracts`. Approve one exact digest at a time:

```sh
mcpindex-lock approve memory read_graph \
  --digest sha256:f651a47dec6b1222061803c2bbf1eacfdb401cb8b5b1e9a74ad08faf0f287b7c \
  --subject github:your-handle --via pr --ref pr-12
mcpindex-lock approve deepwiki --instructions --digest sha256:... --subject github:your-handle
mcpindex-lock verify
```

`--subject` is recorded as you type it and is not checked. In a pull request it is the
author's claim; the merge commit, and the manifest signature when you sign, record who
approved. `verify` is an offline check of the whole lock and exits 1 when anything is wrong.
`revoke` drops one approval, and `manifest` writes `.mcp/lock/MANIFEST.json` with the next
sequence number.

`ci` lists the servers named in a servers file and compares them with the lock:

```sh
mkdir -p ops
cat > ops/servers.json <<'JSON'
{"servers": [
  {"name": "deepwiki", "url": "https://mcp.deepwiki.com/mcp"},
  {"name": "memory", "stdio": "npx -y @modelcontextprotocol/server-memory"}
]}
JSON
mcpindex-lock ci --servers ops/servers.json --allow-stdio
```

Every line it prints starts with `ADVISORY:`, and it exits 1 when a server differs. It
contacts only what the servers file names, never a URL from the lock. Keep that file on a
protected branch. A token goes only to the URL it is paired with, `--bearer-env
https://mcp.example.com/mcp=MCP_DOCS_TOKEN`, and the variable name must start with `MCP_`.
On GitHub Actions, `--allow-stdio` and `--bearer-env` run only on `push`, `schedule` and
`workflow_dispatch`; every other event name is refused, `pull_request*`, `merge_group`,
`issue_comment` and `workflow_run` among them, since on those the servers file or the flags
may come from someone other than the branch's owners. Outside Actions (`GITHUB_EVENT_NAME`
unset) nothing is refused.

If you already run the default proxy, `migrate` turns its pins for one server into lock
entries marked `via: migrated`. It is a dry run until you add `--write`:

```sh
mcpindex-lock migrate --server memory --stdio "npx -y @modelcontextprotocol/server-memory"
```

### Enforce it in the stdio proxy

Add `--lock` with the repository root to the proxy launch. `--lock-server` names the entry in
`.mcp/lock/aliases.json` that `add --name` wrote; without it the identity comes from the
launch line. In a host's `mcp.json`:

```json
{
  "mcpServers": {
    "memory": {
      "command": "uvx",
      "args": [
        "--from", "mcpindex-gate", "mcpindex-proxy", "--mcpindex-stdio",
        "--lock", "/path/to/repo", "--lock-server", "memory",
        "--lock-events", "/path/to/lock-events.jsonl",
        "--upstream-command", "npx",
        "--upstream-arg=-y", "--upstream-arg=@modelcontextprotocol/server-memory"
      ]
    }
  }
}
```

With `read_graph` and `search_nodes` approved, the host lists those two tools, and a call to
anything else is refused before it reaches the server:

```text
mcpindex lock: listing posture=guard served=9 shown=2 withheld=7 removed=0
mcpindex lock: withheld tool=create_entities reason=new-tool
```

```json
{"code": -32011, "message": "Tool withheld by lock", "data": {"reason": "new-tool", "tool": "create_entities"}}
```

`--lock-posture monitor` shows every tool and reports what guard would withhold; `strict` is
`guard` in draft 1. Server instructions that are not approved are removed under guard. The
events file gets one JSON line per listing and per refused call, with names and digests and
no descriptions or arguments. Lock mode sends no receipts and no drift telemetry, whatever
the environment says.

Signing is optional. To require a signed manifest, install the extra and give the host a
config file that lives outside the repository:

```sh
uv tool install 'mcpindex-gate[signed]'
```

```json
{
  "signer": {
    "issuer": "https://token.actions.githubusercontent.com",
    "san": "https://github.com/acme/app/.github/workflows/lock-sign.yml@refs/heads/main"
  },
  "trustedRoot": "/etc/mcpindex/trusted_root.json",
  "sequencePath": "/var/lib/mcpindex/app.seq",
  "requireManifest": true
}
```

Pass it with `--lock-host-config`. The config file, `trustedRoot` and `sequencePath` must all
be outside the repository that holds the lock, since whoever writes the lock could otherwise
choose them. The repository is the nearest directory at or above the lock root with a `.git`
directory or a linked worktree's `.git` file; a submodule's `.git` file sends the search on to
the superproject. Directories are compared by device and inode, so a case-folded spelling on
macOS and a symlink followed by `..` are judged where they land, and a `..` in the part of a
path that does not exist yet is refused. When there is no `.git` entry at or above the lock
root, only the lock root itself is protected: a file beside it in the same untracked directory
is accepted, so keep host files somewhere the lock's writers cannot reach. A signer without a `sequencePath` stops the launch with
`lock-signature-invalid`: without a sequence store an older signed manifest would be accepted
after a newer one.

### Enforce it in the HTTP gateway: mcpindex-lock-gateway

`mcpindex-lock-gateway` is the loopback HTTP gateway in lock mode. Each route forwards to one
upstream, and each upstream is checked against the lock the same way the stdio proxy checks
it. The routes file is yours, not the repository's:

```json
{
  "tenant": "default",
  "routes": {
    "deepwiki": {"url": "https://mcp.deepwiki.com/mcp", "server": "deepwiki"},
    "docs": {
      "url": "https://mcp.example.com/mcp",
      "credential": {"header": "Authorization", "file": "/etc/mcpindex/docs.token"}
    }
  }
}
```

`server` is an alias from `aliases.json`; without it the identity is the URL. A
`credential` is read once at launch from the file, which holds one line such as
`Bearer sk-...`, and is sent only to that route's URL.

```sh
mcpindex-lock-gateway --port 8765 --lock /path/to/repo --lock-routes ops/routes.json \
  --lock-token-path ops/gateway.token --lock-events ops/lock-events.jsonl
```

Every request needs the launch token, which is new on each start. It is written 0600 to
`--lock-token-path`; without that flag it is printed once to stderr, and the gateway refuses
to start when stderr is not a terminal, so the token never ends up in a log. A client sends
it as a bearer token, with `Content-Type: application/json`:

```sh
curl -s http://127.0.0.1:8765/deepwiki \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $(cat ops/gateway.token)" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

With the TypeScript MCP SDK, pass the same header through the transport:

```js
new StreamableHTTPClientTransport(new URL("http://127.0.0.1:8765/deepwiki"), {
  requestInit: { headers: { Authorization: `Bearer ${token}` } },
});
```

The inbound Authorization header is the gateway's own token and is never forwarded. The
gateway also requires the exact Host `127.0.0.1:<port>` and refuses any `Origin` you did not
allow with `--lock-origin https://app.example`. An upstream must be https on a public
address, unless its exact URL is in a `--lock-private-upstreams` file:

```json
{"privateUpstreams": ["http://192.168.1.20:8080/mcp", "http://100.101.102.103:8080/mcp"]}
```

A listed URL may reach RFC 1918, loopback, IPv4 link-local, ULA and 100.64.0.0/10 (Tailscale)
addresses. It is resolved once at launch and pinned, and redirects are not followed. An
IPv6 link-local address is refused, because the pinned connection cannot carry its interface
zone.

### The default gateway and browsers

The default gateway (no lock) checks the same front door except the token: the Host must be
`127.0.0.1:<port>` or `localhost:<port>`, the body must be `application/json`, and a request
that carries an `Origin`, or a browser's `Sec-Fetch-Site` without one, is refused. That keeps
web pages you visit from driving it. MCP clients built on Node's fetch, the TypeScript SDK and
the Python SDK send none of those and pass. It refuses every browser `Origin`, and it has no
setting to allow one; a browser-based client needs `mcpindex-lock-gateway` with
`--lock-origin`.
