MCP server signing and supply-chain scan¶
Third-party MCP servers are the next big supply-chain attack surface for agentic apps. CVE-2025-6514 alone compromised ~437K developer environments through a single unsigned mcp-remote package. Bernstein treats every MCP server load as a potentially-hostile binary and runs two gates before the manager spawns it.
| Gate | Source | Purpose |
|---|---|---|
| Ed25519 manifest signature | mcp_verifier.py | Prove the manifest was signed by a trusted publisher |
| Static supply-chain scan | mcp_scanner.py | Catch the four attack classes that drove the OpenClaw 433+ CVE corpus |
| Strict / warn-only enforcement | mcp_signing_policy.py | Decide what to do when one of the gates fails |
Manifest signing (Ed25519 + JCS)¶
Every MCP server ships with an mcp-server.yaml (YAML or JSON) and a detached mcp-server.sig. The manifest carries the publisher fingerprint:
name: example-mcp
version: 1.4.0
publisher:
name: Example Org
fingerprint: ed25519/abcd1234...
content_hash: sha256/<hex>
The signing path is:
- The verifier parses the manifest and structurally validates it (name, version, publisher block, fingerprint shape, optional
content_hashprefix). A malformed manifest fails closed withBAD_MANIFESTbefore any crypto runs. canonicalize_manifest()emits RFC 8785 JCS bytes over the{typ, name, version, publisher, content_hash}body. Thetyptag is bound tomcp-server-manifest+ed25519so a signature minted for a different JWS context cannot replay here.- The detached Ed25519 signature is verified against the publisher's PEM, resolved by fingerprint from the operator's
publisher_keysmap. - The publisher fingerprint must appear in the operator's
trusted_publishersset; otherwise the verdict isUNTRUSTED_PUBLISHEReven if the math is correct. - When the caller passes
bundle_bytesand the manifest declares acontent_hash, the bytes are hashed and compared. A mismatch isCONTENT_HASH_MISMATCH.
Verdicts¶
| Verdict | Meaning |
|---|---|
ok | Signed, signature valid, publisher trusted, hash matched (when present) |
unsigned | Empty signature on a manifest the policy expected to be signed |
bad_signature | Signature failed the Ed25519 verify call |
untrusted_publisher | Math correct but the fingerprint is not in trusted_publishers |
bad_manifest | Structural validation failed |
content_hash_mismatch | Bundle bytes do not match content_hash |
The verdict is part of the structured result so log/UX surfaces and audit records share one taxonomy.
Sigstore deferred¶
The substrate in core.security.sigstore_attestation is attestation-side only today. The Sigstore (Fulcio + Rekor) verification path is deferred to a follow-up so the verifier review surface stays tight. Ed25519 alone is a complete content-integrity path; what defers is the second who-published-this signal.
Static supply-chain scanner¶
scan_mcp_bundle() walks the source bundle and matches four attack classes against regex/string patterns. Regex matching is deliberate: an AST visitor would be more precise but only works against Python sources, while MCP servers ship in Python, Node, Go, and Rust.
| Rule | Lineage |
|---|---|
Path traversal - Path.resolve missing in tool handlers | Anthropic Git MCP CVE-2025-68145 |
Shell injection - subprocess with shell=True or unsanitised concatenation | OpenClaw CVE-2026-25253 |
| OAuth callback RCE - callback handlers without redirect-URI allowlist | mcp-remote CVE-2025-6514 |
Scope escalation - token re-use with widened scope= | OpenClaw CVE-2026-32922 (CVSS 9.9) |
Each finding carries a severity (info / low / medium / high / critical), a path:line pointer, a CWE tag, and a remediation hint.
Plus a known-bad-package gate: package names on DEFAULT_KNOWN_BAD_PACKAGES (currently the public CVE-tracked mcp-remote and openclaw-gateway-vulnerable placeholders) are flagged immediately. Operators extend the denylist via the known_bad_packages argument so the in-tree feed can ride alongside an external vulnerability source.
Lockfile-aware diffing is exposed at scan_dependency_diff() for the manager to call when a lockfile is present; AST-level taint tracking is deferred.
Enforcement policy¶
enforce_mcp_server_load() is the single entry point the MCPManager consults before spawning a third-party server. It applies the operator's policy to the verification result and the scanner findings.
from bernstein.core.protocols.mcp.mcp_signing_policy import (
MCPSigningPolicy, enforce_mcp_server_load,
)
policy = MCPSigningPolicy(
strict=True,
trusted_publishers=frozenset({"ed25519/abcd..."}),
publisher_keys={"ed25519/abcd...": pem_bytes},
)
decision = enforce_mcp_server_load(
server_name="example-mcp",
manifest_yaml=manifest_text,
signature_b64=sig_b64,
bundle_files={"tools/exec.py": source_text},
policy=policy,
bundle_bytes=tarball_bytes,
)
Strict vs warn-only¶
| Mode | Unsigned / bad sig / untrusted publisher | Critical scanner finding |
|---|---|---|
strict=True | Refuse load (raises MCPVerificationError) | Refuse load |
strict=False (warn-only) | Log warning, increment mcp_unsigned_loaded_total, allow load | Log warning, allow load |
The default for new environments is strict. The first run in an existing environment defaults to warn-only so an in-place upgrade does not break every running deployment until operators flip the flag explicitly. Operators choose their on-ramp:
| Layer | Knob |
|---|---|
| Per-process escape hatch | BERNSTEIN_MCP_ALLOW_UNSIGNED=true |
| Config file | mcp.allow_unsigned: true in bernstein.yaml |
| Code | MCPSigningPolicy(strict=False) |
The escape hatch logs loudly on each unsigned load and ticks the mcp_unsigned_loaded_total counter, so misuse surfaces in audit even when the load is permitted.
Strict-mode refusal message¶
The remediation message names:
- the verdict in plain English (the same string the metric exporter records),
- the CLI verb the operator can run for full diagnostics (
bernstein mcp test <server-name>), - the override knobs (
mcp.allow_unsigned: trueandBERNSTEIN_MCP_ALLOW_UNSIGNED=true), - up to three CRITICAL scanner findings with their CWE tags and source location.
The message stays log-friendly even when many findings fire - only the first three show up in the head; the full list is on the returned MCPLoadDecision.scanner_findings.
Metrics¶
| Metric | Source |
|---|---|
mcp_unsigned_loaded_total | Ticked on every unsigned load that the policy permits |
The counter is read via unsigned_loaded_counter_value() so the metrics exporter and tests share one accessor.
Related¶
- Source:
src/bernstein/core/protocols/mcp/mcp_signing_policy.py,mcp_verifier.py,mcp_scanner.py - Capability matrix - the upstream gate that pins which MCP tool calls a role may dispatch
- OWASP ASI04 - Agentic Supply Chain - the heuristic detector that delegates to this signature gate when callers populate
loaded_componentswith{name, signed}entries - Lethal-trifecta security model - the structural exfiltration gate that runs alongside MCP signing
- RFC 8785 (JCS), RFC 8037 (Ed25519) - the canonicalisation and signing primitives the verifier reuses