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'sgeo-mcpon 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.
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.
Release files for provmcp 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| provmcp-0.1.0.tar.gz | 91.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| provmcp-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 123.9 kB
Release files / provmcp-0.1.0.tar.gz
| Download URL | provmcp-0.1.0.tar.gz |
|---|---|
| Size | 91.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e104eeacdbae9bf7b77f2817c23dc3bbac5ee43b194bba07d6fba238d9ea326c
|
|
BLAKE2b-256 checksum How to use checksums |
aceb897da3e08fdd27bcbca4d7e0b6d8f51f56ef0c8b7deceb12ed423ef58032
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency logRelease files / provmcp-0.1.0-py3-none-any.whl
| Download URL | provmcp-0.1.0-py3-none-any.whl |
|---|---|
| Size | 32.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
baae944c7207fa336d7949093007e11f6df5b5c5282b49cb50cf6aae6bcabc5d
|
|
BLAKE2b-256 checksum How to use checksums |
0d435ee2151652d57d84298a22ad710ae5df65aaf455e1d0ff244e2ca59a12d2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency log