The TypeScript client of mcpindex.ai - the drift-monitored
MCP server index with an in-path trust gate for agent tool calls.
Pre-flight MCP tool-contract drift interceptor. One line - an MCP client
session and the gate the moment the
tool's contract silently changes from what you pinned. In-path, on your host; no
credentials, no egress.
Pre-flight MCP tool-contract drift interceptor from mcpindex.ai, the drift-monitored MCP server index. One-line wrap() over an MCP client session that HOLDs a tool call before your agent runs it when the contract drifted from your pin. No credential handl
2026-11-12
Sterne
—
Tools
—
Uptime
—
wrap()
HOLDs a tool call before your agent acts
Install
npm i @mcp-index/sdk
Peer dependency: @modelcontextprotocol/sdk >=1.0.0 <2 (the wrapper gates by seam name; the cap holds until the 2.x client surface is audited).
Use
import { wrap } from '@mcp-index/sdk';
// wrap an already-connected MCP client session; a stable serverId keeps pins on disk
const guarded = wrap(session, { serverId: 'my-mcp-server' });
// use `guarded` exactly like `session`; a drifted contract is HELD before the call
// fires. A PreflightHold surfaces (Guard/Strict) so you can route the agent to
// Review / Re-pin / Validate.
The serverId is what makes the baseline survive restarts: pins go to
MCPINDEX_STATE_DIR/pins/<server id>.json or ~/.mcpindex/pins/<server id>.json,
and the next process compares against them. wrap(session) with no server id keeps an
in-memory baseline that dies with the process, so a contract that changed while
your agent was down is pinned fresh on the next start and never held. Passing
pin: new PreflightPin() does the same even alongside a serverId: a PreflightPin
built without a path is in memory, and an explicit pin wins over the default store.
pinStore: 'memory' keeps pins in the process even with a server id, and
pinStore: 'file' forces the file store without one. You can also pass your own
PreflightPin as pin.
New tools on an already-pinned server
Under guard and strict, when a server you already pinned adds a tool, that
tool is left out of the 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. onWithheld receives the definition digest. Pin
the stored definition with approvePending(name, digest) when you have reviewed it:
Pass newTools: 'tofu' to restore first-sight trust for new tools on that
wrapper.
The MCP client's own listChanged refresh is filtered too: wrap() routes the
inner client's listTools through the gate, so onChanged receives only the
tools the list would show. After a tools/list_changed notification, calls hold
under guard and strict until the list is read again. wrap() throws under guard
and strict for a client object that rejects new properties (a frozen object).
The pin records every top-level field a server lists for a tool (title,
icons, _meta, execution, and any later field) and annotation keys other
than the four hints. A change to one of them on a pinned tool leaves the tool
out under guard and strict. A pin written before 0.17.0 records those fields
once, from the first matching list. A first outputSchema is held the same way when it
carries any string or property name, which covers most structured output: expect
one review per tool when a server starts returning it.
With a file pin store, the wrapper also reads the server's instructions after
connect, pins them, and reports a first pin or a change through
onContextDrift. getInstructions() still returns what the server sent.
Sign in (optional) - mcpindex login
Signs you in with GitHub and stores a free API key at ~/.mcpindex/credentials.json
(mode 0600), used to authenticate deeper trust checks. Optional - wrap() needs no
account and sends nothing on its own.
npm i -g @mcp-index/sdk # then:
mcpindex login # opens your browser; captures the key on a loopback listener
mcpindex login --provider google # sign in with Google instead of GitHub (default: github)
mcpindex whoami # shows sign-in state (never prints the key)
mcpindex --version # prints the installed version
# or without a global install:
npx -p @mcp-index/sdk mcpindex login
Every command takes --help / -h, which prints usage and does nothing else. An
unknown flag or extra argument prints usage to stderr and exits 2 without signing in.
The key is minted server-side after GitHub OAuth and handed to a one-shot HTTP listener
bound to 127.0.0.1 - it never transits a third party. Point MCPINDEX_WEB_BASE at a
different host to override the default (https://mcpindex.ai).
What it does
Pins each MCP tool's contract on first sight in an empty store. With a serverId
(or pinStore: 'file'), those pins are kept on disk and survive restarts. Without
one they stay in memory for the process only.
Under guard and strict, a new tool on an already-pinned server is left out of
the list until you review it (approvePending). newTools: 'tofu' restores
first-sight trust for that wrapper.
Runs a deterministic contract-diff (the ChangeKind taxonomy: added-required-param,
constraint-narrowed, annotation-flip-to-destructive, output-schema-changed,
type/enum/removed, ...) plus an injection/exfil marker scan over input and output schema.
HOLDs the call with the mcpindex hold banner when the contract drifted; PROCEEDs silently
on a benign change (no false alarm).
Postures: Monitor (notify + proceed: a call guard or strict would hold runs, and
stderr gets one line per tool and reason) / Guard (default; hold dangerous) /
Strict (hold any drift).
Grades each call's blast radius (Tier-0a action classification) on the verdict:
action type (read / write / delete / send / ...), reversibility, egress, and a static
autonomy ceiling, computed locally from the live contract. Advisory - it rides
alongside the decision and never moves HOLD/PROCEED. On by default; opt out with
MCPINDEX_ACTION_CLASSIFICATION_ENABLED=0.
What it is NOT
The verdict is a contract-diff, not a safety oracle - it tells you the contract
changed versus your pin, not that the new contract is safe. It does not "block attacks"
or "guarantee safe." You decide what a HOLD means for your agent.
Result-content scanning (opt-in, OFF by default)
Beyond the contract diff, the SDK can scan a tool's returned result (text blocks and
structuredContent) for prompt-injection / exfiltration / credential-path markers - the
attack where a compromised result, not a changed contract, tries to steer your agent. It
is off by default (zero behavior change) and enabled per-wrap:
Once on, a tainted result is withheld under Guard / Strict and passes through
under Monitor (flag-only), mirroring the contract-diff postures; an optional onHold
callback receives the verdict instead of throwing. Added in 0.7.0; parity with the Python
client's wrap(..., scan_results=...).
Drift telemetry (opt-in, OFF by default)
The SDK 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:
export MCPINDEX_DRIFT_TELEMETRY=detection # off (default) | detection | contribute
When enabled, each tool you pin and each contract drift sends one one-way signal:
What it sends: fingerprints (HMAC) of the server/tool id, the contract hashes, the
change type (the fixed ChangeKind vocabulary), a safety flag, an hour-rounded timestamp,
a random install id (a token that links one machine's signals so distinct installs can be
counted - not derived from you), and a client SDK tag (py or ts).
What it NEVER sends: tool schemas, arguments, descriptions, URLs, or any of your data.
The fingerprints carry no plaintext name and they are not anonymity: the salt is a
constant in this client and the registry is public, so a listed server's fingerprint
reverses. The payload has no free-text field by construction, and the ingest rejects
anything that isn't the closed shape.
Fail-open: telemetry never blocks, slows, or changes a tool call. Losing a signal is always
preferred to perturbing the gate.
detection enables the closed signal above; contribute is reserved for a future, separately
opt-in richer tier (it behaves identically today). The full notice is exported as
DRIFT_TELEMETRY_NOTICE. Unset the variable any time to stop.
Lock mode (@mcp-index/sdk/lock)
Lock mode checks every tools/list against an mcp.lock kept in your repository
(.mcp/lock/ and .mcp/contracts/, format: mcp.lock draft 1, revision 1e). The model sees
only tools whose definition digest matches an approval, and a call to anything else is
refused with JSON-RPC error -32011 before it reaches the server. Lock mode is opt-in and
draft 1 is not stable. Nothing here runs unless you construct it, and the root
@mcp-index/sdk import does not load it.
Connect the official MCP client through lockClient:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { FileLockStore, lockClient } from "@mcp-index/sdk/lock";
import type { Identity } from "@mcp-index/sdk/lock";
const identity: Identity = { kind: "stdio", package: "npm:@example/docs-server" };
const store = new FileLockStore({ root: process.cwd() }); // the directory that holds .mcp/
const client = new Client({ name: "my-host", version: "1.0.0" });
const transport = new StdioClientTransport({ command: "npx", args: ["-y", "@example/docs-server"] });
const locked = await lockClient(client, transport, { store, identity, posture: "guard" });
const { tools } = await client.listTools(); // approved tools only
console.log(tools.map((t) => t.name), locked.decision?.withheld);
posture is monitor (show everything, report what does not match), guard (withhold it)
or strict (same as guard in draft 1). Lock mode never approves a tool on first sight and
never re-pins. The lock changes only through store.approve and store.revoke, or a
reviewed edit to the lock files:
import { readFileSync } from "node:fs";
// The Tool object exactly as the server sent it, saved for review. Not an SDK-parsed copy:
// the SDK drops members it does not know, and the digest covers every member.
const tool: unknown = JSON.parse(readFileSync("review/search.json", "utf8"));
await store.approve({ identity, tool, subject: "github:you", via: "api", ref: "pr-42" });
The identity names the server, in one of three forms:
remoteIdentity, packageIdentity and commandIdentity build them in normalized form.
commandIdentity throws when an argument looks like a credential.
A signed lock verifies .mcp/lock/MANIFEST.json.sigstore against a signer from your host
configuration. It needs the optional sigstore peer, a trust root file, and a sequence file
where the host keeps the highest manifest sequence it accepted, so an older signed manifest
is refused after a newer one:
const signed = new FileLockStore({
root: "/srv/app",
signer: {
issuer: "https://token.actions.githubusercontent.com",
san: "https://github.com/acme/app/.github/workflows/lock.yml@refs/heads/main",
},
trustedRootPath: "/etc/mcplock/trusted_root.json",
sequencePath: "/var/lib/mcplock/sequence",
});
Both paths must lie outside the repository that holds the lock, or the constructor throws
HostConfigError. The repository is the nearest directory at or above root with a .git
directory or a linked worktree's .git file; a submodule's .git file sends the search on to
the superproject. The check compares directories 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 no .git entry exists at or above
root, only root itself is protected: a path beside it in the same untracked directory is
accepted, so keep host files somewhere the lock's writers cannot reach. A signer without a sequence store makes every load
lock-signature-invalid.
Postgres lock store (@mcp-index/sdk/lock/pg)
PgLockStore keeps approvals in Postgres instead of files: append-only, hash-chained
records per tenant, with row-level security on both tables. pg is an optional peer.
The schema owner runs the migrations once. They are exported as SQL text:
import { writeFileSync } from "node:fs";
import { MIGRATION_SQL, ROLE_BOUND_SQL, ROLE_SQL } from "@mcp-index/sdk/lock/pg";
writeFileSync("mcplock-schema.sql", MIGRATION_SQL + ROLE_SQL);
writeFileSync("mcplock-role-bound.sql", ROLE_BOUND_SQL); // only for role-bound tenancy
// psql -v ON_ERROR_STOP=1 -f mcplock-schema.sql
ROLE_SQL creates two roles without login. mcplock_runtime may only read, and is what hosts
in lock mode connect as. mcplock_approver may also append records and move the head, and
belongs to the approval path only. Neither may update or delete a record. Grant each to its
own login, and never connect as a superuser, since a superuser bypasses row-level security:
GRANT mcplock_runtime TO host_login;
GRANT mcplock_approver TO approver_login;
Two tenancy modes:
app-scoped (the default, MIGRATION_SQL alone). The store sets the tenant from the
principal at the start of each transaction. This stops a query bug from reaching another
tenant. It does not stop someone holding a shared login, who can set any tenant.
role-bound (ROLE_BOUND_SQL after the other two). Each tenant gets its own logins, and
the administrator maps each login to its tenant in mcplock.tenant_logins. The policies
read the tenant from the login, so a login reaches only its own tenant. Use this before
sharing one database between tenants that do not trust each other.
import { PgLockStore, createPgPool } from "@mcp-index/sdk/lock/pg";
const pool = await createPgPool(process.env.MCPLOCK_PG_URL ?? "postgres://[email protected]/app");
const store = new PgLockStore({
pool,
principal: { tenantId: "acme", subject: "svc:docs-host" }, // from your own auth, never a header
tenancy: "app-scoped",
});
const snapshot = await store.loadSnapshot();
console.log(snapshot.snapshotId);
With headVerifier set, each load checks a signature over the head row, and the store must
also get a sequenceStore, for example fileSequenceStore("/var/lib/mcplock/pg-sequence")
from @mcp-index/sdk/lock, or every load is lock-signature-invalid. Without a head
signature, whoever can write the tables can rewrite the chain. Errors never carry the
connection string unredacted.
Lock mode for the Vercel AI SDK (@mcp-index/sdk/ai)
Wrap the transport you hand to createMCPClient from @ai-sdk/mcp (an optional peer). The
wrapper reads the raw tools/list exchange, checks every tool against your mcp.lock, and the
client only ever sees approved tools, through tools() or listTools() alike:
import { createMCPClient } from "@ai-sdk/mcp";
import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
import { lockTransport } from "@mcp-index/sdk/ai";
import { FileLockStore } from "@mcp-index/sdk/lock";
import type { Identity } from "@mcp-index/sdk/lock";
const identity: Identity = { kind: "stdio", package: "npm:@example/docs-server" };
const store = new FileLockStore({ root: process.cwd() });
const inner = new Experimental_StdioMCPTransport({ command: "npx", args: ["-y", "@example/docs-server"] });
const transport = lockTransport(inner, { store, identity, posture: "guard" });
const client = await createMCPClient({ transport });
const tools = await client.tools(); // approved tools only
console.log(Object.keys(tools));
A call to a withheld or unlisted tool never reaches the server. callTool() and the tool's
execute() reject with an MCPClientError whose code is -32011 and data.reason says why;
inside generateText that shows up as a tool-error part.
Limits:
A digest says whether the contract changed. It says nothing about what the server does
when a tool runs.
The AI SDK client does not refetch on notifications/tools/list_changed. After an approval
or a server change, call tools() again; until then the previous allow-list holds. While a
listing is being collected, every call is refused with no-listing.
A tool's top-level _meta is not part of its digest. It reaches the client only when it
matches the metaDigest recorded at approval; otherwise, under guard, the tool is shown
without it and the decision reports meta-changed or meta-unapproved.
@mcp-index/sdk/ai imports only types from @ai-sdk/mcp, so loading it needs nothing at
runtime. The @ai-sdk/mcp peer itself needs Node >= 22; that is the only part with that
requirement, and the rest of the package runs on Node 20.
The AI SDK's own transports reject any message carrying a __proto__ key before the wrapper
sees it, so such a listing never arrives and nothing is callable. The official SDK transports
pass it through and the lock withholds the tool. Instructions given to the client as
initialInitializeResult never cross the transport and are not checked.
Related packages
Three ways to bring mcpindex into an agent, for different surfaces:
Package
Install
What it does
@mcp-index/sdk(this package)
npm i @mcp-index/sdk
In-path drift gate: wrap() an MCP session and HOLD a call when a tool's contract drifts from your pin.
mcp-server-mcpindex
npm i -g mcp-server-mcpindex
Directory + advisory screen as an MCP server: find servers by task, and check_tool_trust before a call.
@mcp-index/mastra
npm i @mcp-index/mastra
The advisory screen wired into Mastra as a beforeToolCall hook (warn / enforce).
Drift gate vs advisory screen: this package asks a local question - "did this tool's contract change since I pinned it?" (no network verdict). The other two ask mcpindex "has this tool been vetted?" They are complementary, and none depends on another.
License
PolyForm Noncommercial 1.0.0 - (c) Bhartis LLC. Free for noncommercial use; commercial use
requires a separate license. See LICENSE and https://mcpindex.ai.
Pre-flight MCP tool-contract drift interceptor from mcpindex.ai, the drift-monitored MCP server index. One-line wrap() over an MCP client session that HOLDs a tool call before your agent runs it when
Pre-flight MCP tool-contract drift interceptor from mcpindex.ai, the drift-monitored MCP server index. One-line wrap() over an MCP client session that HOLDs a tool call before your agent runs it when the contract drifted from your pin. No credential handl
Tools
Tools
Noch keine Tool-Liste. Remote-Server erhalten eine nach dem ersten Health-Check.
FAQ
Fragen
Wie installiere ich @mcp Index/sdk?
Kopieren Sie auf dieser Seite die stdio-Konfiguration für Cursor, Claude Desktop, Claude Code oder VS Code. Sie nutzt die Installation des Herausgebers, keinen PlugMCP-Proxy.
In diesem Eintrag ist keine erforderliche Umgebungsvariable aufgeführt.
Welchen Transport verwendet @mcp Index/sdk?
@mcp Index/sdk läuft lokal über stdio.
Wann wurde @mcp Index/sdk zuletzt geprüft?
Zuletzt geprüft: 2026-11-12. Pre-flight MCP tool-contract drift interceptor from mcpindex.ai, the drift-monitored MCP server index. One-line wrap() over an MCP client session that HOLDs a tool call before your agent runs it when the contract drifted from your pin. No credential handl