Skip to main content
Glama

provmcp

A provenance and replay-verification proxy for scientific MCP tool use.

Status: working, 62 tests passing, pip install provmcp. Verified end to end against MCPmed's geo-mcp on 2026-09-20 (record → verify → replay). Not production-ready: no load testing, one ledger writer per process, no auth.

What it does

Sits between an MCP client and any downstream MCP servers — GEO, Open Targets, ClinicalTrials.gov, your own. Forwards everything. On the way through, records each tool call into an append-only, hash-chained, Ed25519-signed ledger:

  • the call and its arguments (with named fields redacted if configured)

  • a SHA-256 of the result over canonical JSON

  • the resolved data-source release, where the source exposes one

  • the calling client's name/version, when it identifies itself

  • a contemporaneous UTC timestamp and duration

Exports a portable W3C PROV bundle you can attach to an ELN entry, a methods section, or supplementary material, and verify without provmcp installed.

Related MCP server: tatastu-proof

The feature that matters: replay verification

$ provmcp replay ./provenance.jsonl --config servers.yaml      # illustrative output
12 recorded calls replayed
  3 of 12 now return different data:
    seq 4    geo__search_geo_series    [GEO gds Build260301-1102.1 -> GEO gds Build260919-1942.1]
    seq 7    opentargets__target_info  [OT 26.06 -> OT 26.09]
    seq 9    geo__search_geo_datasets  [GEO gds Build260301-1102.1 -> GEO gds Build260919-1942.1]
  1 flaky:
    seq 11   trials__search            two fresh runs disagree with each other

Signed, hash-chained MCP audit proxies already exist — mcp-audit-gateway, auditmcp, ArkForge's commercial certifying proxy, Oculix's in-server journal. They prove a call happened. Record/replay tools exist too — Agent VCR, mcp-cassette — but they replay cassettes as mock servers and diff recordings against each other. None re-executes against the live source, none knows which release the source was at, and none tells flaky apart from changed. That is the part provmcp adds, and it is the only part that is new.

Replay distinguishes genuine upstream change from a flaky tool by re-running a disagreeing call a second time. Without that, nondeterminism reads as changed data, which is the false alarm that would make the whole tool untrustworthy. --single-run turns this off, and a test shows exactly what you lose.

Release resolution

A changed hash is an alarm. A changed hash with [OT 26.06 -> OT 26.09] next to it is an explanation. Resolvers are per-source, best-effort, and return None rather than guess. Verified live on 2026-09-20:

source

Where the release comes from

Example (2026-09-20)

geo

NCBI E-utilities einfo dbbuild, db=gds

GEO gds Build260919-1942.1

eutils

same, any NCBI db (pubmed, clinvar, sra, …)

PubMed Build-2026.09.20.06.18

opentargets

GraphQL meta.dataVersion

OT 26.06

clinicaltrials

API v2 /version dataTimestamp

CT.gov 2026-09-18T09:00:04

uniprot

X-UniProt-Release response header

UniProt 2026_03

ensembl

REST /info/data

Ensembl 116

chembl

/chembl/api/data/status

ChEMBL_37

string

/api/json/version

STRING 12.0

reactome

ContentService /data/database/version

Reactome 97

hgnc

/info lastModified

HGNC 2026-09-18T12:19:06.043Z

ols

OLS4 ontology config.version (mondo, go, hp, …)

mondo 2026-09-01

kegg

/info/<db> last-update line

KEGG pathway 2026/09/17

http-json

any JSON endpoint + dotted path

whatever you point it at

http-header

any endpoint + response header

field

a key inside the tool result

DepMap 24Q2

static

a string in config

a pinned local dataset

Every preset above has a live test (tests/test_sources.py) that runs when the network is reachable. Checked and rejected: DepMap (no API — use field), Europe PMC (reports API version, not data release), RCSB PDB, gnomAD.

Resolver plugins

A resolver for a source provmcp doesn't know is a separate package, not a pull request. Declare an entry point in the provmcp.resolvers group:

[project.entry-points."provmcp.resolvers"]
depmap = "provmcp_resolver_depmap:resolve"

where resolve is async (source_options: dict, result: object) -> str | None. pip install it, and source: depmap works in servers.yaml. provmcp resolvers lists everything installed and where it came from; provmcp resolve <name> --opt k=v runs one now. A plugin cannot shadow a built-in name (so a stray install can't change what geo means in someone's ledger), a plugin that fails to import is skipped with a warning, and a plugin that raises yields an unresolved release, never a failed call.

examples/provmcp-resolver-example is a complete, installable plugin in one file; the test suite builds a venv, installs it, and checks it appears.

Resolution is a side channel: it says what the source was serving around the time of the call, not what the downstream server actually used. A server that bundles or caches data can lag the live release. That is a property of the server, and exactly what replay surfaces.

What this is not

Not GxP compliance. Not a validated system. It is provenance capture that supports an audit trail.

The limitation is structural and worth stating plainly. A practitioner systematization of regulated agent deployments argues that auditability is an interface design constraint that must be established before tools are built, not a logging concern addressable at the infrastructure layer — tools built without audit requirements in scope carry no case identifiers or session context in their call signatures, and retrofitting that meant changing every tool's interface contract and revalidating each one.

That critique is correct. provmcp records what is observable at the proxy boundary and nothing the downstream tools never carried. It records which client software made a call, not which person; it cannot vouch for what a downstream tool reported — only that what it reported was recorded faithfully and has not been altered since.

Ledger guarantees

Property

Mechanism

Test

No entry altered after writing

SHA-256 over canonical JSON; chain verified on the recomputed hash so an edit invalidates every later link

test_tampering_with_content_is_detected, test_verify_tool_detects_tampering

No entry removed

sequence numbers + chain continuity

test_removing_an_entry_is_detected

Chain not silently rebuilt

Ed25519 signature per entry; a foreign key fails

test_bundle_with_foreign_pubkey_fails_signatures

Nothing sensitive on disk

hash-only retention by default; full retention is per-server opt-in; named arguments redacted before hashing

test_result_is_not_stored_by_default, test_redacted_arguments_never_reach_disk

No unrecorded calls

a ledger write failure fails the call with an error the client sees

test_ledger_write_failure_fails_the_call

Bundle verifies standalone

manifest hashes + chain + bundled public key

test_bundle_round_trips, test_bundle_detects_tampering

Signing keys never enter the repo. A missing key fails loudly; --unsigned is an explicit opt-out that still hashes the chain.

There is no update or delete path on the ledger, and there must never be one.

Known weaknesses, stated rather than hidden:

  • The ledger is signed by the thing that wrote it. A signature proves the chain wasn't rebuilt by someone without the key; it proves nothing against the proxy operator. ArkForge answers this with RFC 3161 timestamps and a Sigstore Rekor entry per call; provmcp does not, yet. Treat the signature as tamper-evidence, not non-repudiation.

  • Result hashes are SDK-dependent. The hash covers the Python SDK's JSON rendering of the result. mcp-audit-gateway's differential tests found 26 serialization divergences across 10 SDKs. Replay through provmcp is consistent with itself; comparing a hash against one computed by another implementation is not a supported claim.

  • Fail-closed by choice. A ledger write failure fails the call. auditmcp made the opposite choice (fail-open) for an ops audit log. For provenance, an unrecorded call is worse than a failed one; for uptime, it isn't.

Usage

pip install provmcp                    # or: git clone … && uv pip install -e ".[dev]"
provmcp keygen --out ~/.provmcp/
export PROVMCP_SIGNING_KEY=~/.provmcp/signing.pem

cp servers.example.yaml servers.yaml   # edit
provmcp serve --config servers.yaml                                  # stdio
provmcp serve --config servers.yaml --transport streamable-http --port 8080

provmcp verify ./provenance.jsonl --pubkey ~/.provmcp/pub.pem
provmcp replay ./provenance.jsonl --config servers.yaml [--json]
provmcp export ./provenance.jsonl --out ./bundle/
provmcp verify-bundle ./bundle/        # from any machine, no key needed

Point your MCP client at provmcp instead of the downstream servers. Tools appear as <server_id>__<tool> with their original schemas. The proxy adds three of its own: provmcp__verify, provmcp__status (live releases per server), and provmcp__export. Those are not recorded; they are questions about the record.

A Claude Desktop / Claude Code entry:

{
  "mcpServers": {
    "provmcp": {
      "command": "provmcp",
      "args": ["serve", "--config", "/abs/path/servers.yaml"],
      "env": {"PROVMCP_SIGNING_KEY": "/abs/path/.provmcp/signing.pem"}
    }
  }
}

The bundle

bundle/
  provenance.jsonl   the ledger, verbatim
  provenance.json    W3C PROV-JSON — loads with the `prov` library; convert to
                     PROV-N, PROV-O (RDF), or PROV-XML from there
  manifest.json      chain head, entry count, file hashes, provmcp version
  PUBKEY.pem         Ed25519 public key, so a third party can verify signatures

PROV mapping: each call is an Activity associated with the downstream server, the client, and provmcp (SoftwareAgents); it used its arguments and the source release (Entitys) and generated the result, an Entity identified by its content hash so identical results share a node. wasInformedBy links each call to the previous one, so ledger order survives in the graph.

Protocol notes

Built on mcp 2.2, which implements MCP revision 2026-07-28. That revision made the protocol stateless: no initialize handshake, no Mcp-Session-Id, no standalone GET stream (the proxy answers GET with 405). With nothing to pin, the proxy holds the audit chain and nothing else.

The legacy handshake is still accepted on both sides — the proxy serves handshake-era clients, and connects to handshake-era servers (which, today, is most of them: geo-mcp 0.1.2 speaks 2025-11-25 and crashes outright on the 2.x SDK). Tests cover both eras.

Deployment

stdio is the default and the right choice for one person's work: the proxy runs as a child of the client, the ledger is a local file, the key is a local file.

Streamable HTTP is stateless per request, so any replica can serve any call. The one shared thing is the ledger. One process per ledger file is the current constraint: appends are serialized in-process and the chain head is cached in memory, so two replicas writing the same file would fork the chain. Run one instance, or one instance per ledger. Multi-writer (a database-backed ledger with an atomic head) is out of scope for now.

There is no auth. Do not expose the HTTP transport beyond localhost or a private network; the SDK's DNS-rebinding protection is on for localhost binds.

License

MIT.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables users to define and run MCP tools using declarative YAML configs with built-in trust enforcement, credential brokering, and tamper-evident audit logging.
    14
    MIT
  • A
    license
    B
    quality
    B
    maintenance
    Privacy-first, tamper-evident receipts for MCP tool calls. It hashes and signs arguments/results so you can verify what ran, when, without storing raw payloads.
    2
    MIT