Metadata-Version: 2.5
Name: provmcp
Version: 0.1.0
Summary: Provenance and replay verification for scientific MCP tool use
Project-URL: Homepage, https://github.com/AbhiAdhikari/provmcp
Project-URL: Repository, https://github.com/AbhiAdhikari/provmcp
Project-URL: Issues, https://github.com/AbhiAdhikari/provmcp/issues
Author: Abhinav Adhikari
License: MIT
License-File: LICENSE
Keywords: audit-trail,life-sciences,mcp,provenance,reproducibility
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
Requires-Python: >=3.11
Requires-Dist: cryptography>=42
Requires-Dist: httpx>=0.27
Requires-Dist: mcp>=2.2.0
Requires-Dist: pydantic>=2.7
Requires-Dist: pyyaml>=6
Requires-Dist: uvicorn>=0.30
Provides-Extra: dev
Requires-Dist: prov>=2.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# 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.

## 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](https://github.com/elang2/mcp-audit-gateway),
[auditmcp](https://github.com/Ahlyx/auditmcp), ArkForge's commercial certifying
proxy, Oculix's in-server journal. They prove a call happened. Record/replay
tools exist too — [Agent VCR](https://github.com/Jarvis2021/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:

```toml
[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`](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

```bash
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:

```json
{
  "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 (`SoftwareAgent`s); it `used` its arguments and the
source release (`Entity`s) 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.
