The Challenge: Insecure Tool Schemas
The Model Context Protocol (MCP) relies on JSON schemas to define tool capabilities. If a schema is tampered with, an attacker can hijack your AI agent's tools. This is a critical supply chain vulnerability.
SchemaPin solves this by using cryptographic signatures to guarantee that tool schemas are authentic and unmodified. The Python package (schemapin) provides:
- ECDSA P-256 signing and verification
- Offline verification with trust bundles
- Pluggable discovery resolvers (HTTP, local files, trust bundles, chained)
- TOFU key pinning and combined revocation checking
Step 1: Install SchemaPin
Install the schemapin package from PyPI. It includes both the library and CLI tools for signing.
pip install schemapin
Step 2: Create & Sign Your Schema
Create a file named get_issue_schema.json with the tool's definition.
{
"name": "get_issue",
"description": "Gets the contents of an issue within a repository",
"input_schema": {
"type": "object",
"properties": {
"owner": { "type": "string", "description": "Repository owner" },
"repo": { "type": "string", "description": "Repository name" },
"issue_number": { "type": "number", "description": "Issue number" }
},
"required": ["owner", "repo", "issue_number"]
}
}
Generate your cryptographic key pair.
schemapin-keygen
Now, use your private key to sign the schema, producing a secure, signed version.
schemapin-sign --schema get_issue_schema.json \
--key private.key \
--output get_issue_signed.json
Step 3: Host Public Key for Discovery
For services to verify your signatures, they must be able to find your public key. The standard approach is to host it at a .well-known/schemapin.json URL on your domain. Use the automated well-known server to host your public key.
Run the well-known server, which automatically creates the proper directory structure and serves your public key. Keep this server running in a separate terminal.
python -m schemapin.well_known_server --public-key-file public.key --port 8000
Step 4: Build the Secure Python Server
Setup
pip install schemapin Flask
touch server.py
Add to server.py
import json
from flask import Flask, jsonify
from schemapin import core, discovery
app = Flask(__name__)
TOOL_SCHEMAS = {}
def load_and_verify_schema(path, host, key_id):
print(f"Verifying {path}...")
try:
with open(path, 'rb') as f:
signed_schema_bytes = f.read()
print(f"Discovering key from {host}...")
public_key = discovery.discover_public_key(host, key_id)
verified_bytes = core.verify(signed_schema_bytes, public_key)
schema = json.loads(verified_bytes)
print(f"SUCCESS: Verified '{schema['name']}'.")
return schema
except Exception as e:
print(f"ERROR: Verification failed! {e}")
return None
@app.route('/tools', methods=['GET'])
def get_tools():
return jsonify(list(TOOL_SCHEMAS.keys()))
if __name__ == '__main__':
verified_tool = load_and_verify_schema(
'get_issue_signed.json',
'http://localhost:8000',
'default'
)
if verified_tool:
TOOL_SCHEMAS[verified_tool['name']] = verified_tool
app.run(port=5001)
else:
print("\nShutting down due to verification failure.")
Run and Test Security
Run python server.py. Then, stop the server, tamper with the signature in get_issue_signed.json, and run it again to see the failure.
Verifying get_issue_signed.json...
Discovering key from http://localhost:8000...
SUCCESS: Verified 'get_issue'.
* Serving Flask app 'server'
* Running on http://127.0.0.1:5001
Step 5: Verify Offline (No HTTP Required)
The core of SchemaPin v1.3 is offline verification — verify schemas without any network calls using a pre-shared discovery document.
from schemapin.verification import verify_schema_offline, KeyPinStore
# Load the discovery document (pre-shared or fetched earlier)
discovery = {
"schema_version": "1.3",
"developer_name": "My Corp",
"public_key_pem": open("public.key").read(),
"revoked_keys": []
}
# Client-side: verify the schema offline
pin_store = KeyPinStore()
result = verify_schema_offline(
schema,
signature_b64,
"example.com",
"get_issue",
discovery,
None, # no standalone revocation doc
pin_store,
)
if result.valid:
print(f"Schema verified for {result.domain}")
print(f"Developer: {result.developer_name}")
print(f"Key pinning: {result.key_pinning.status}")
# Safe to use the tool
else:
print(f"Verification failed: {result.error_message}")
# Reject the tool
Toggle: Valid vs Tampered
See what happens when a schema is modified after signing.
Verifying schema for get_issue @ example.com... Checking key revocation... OK TOFU key pinning: first_use (pinned) Canonicalizing and hashing schema... Verifying ECDSA signature... Schema verified for example.com Developer: My Corp Key pinning: first_use
Step 6: Use Trust Bundles for Air-Gapped Environments
Trust bundles let you pre-share discovery and revocation documents for environments without internet access — CI pipelines, enterprise deployments, or air-gapped networks.
from schemapin.resolver import TrustBundleResolver
from schemapin.verification import verify_schema_with_resolver, KeyPinStore
# Load a trust bundle from a JSON file
with open("trust-bundle.json") as f:
resolver = TrustBundleResolver.from_json(f.read())
# Verify using the resolver
pin_store = KeyPinStore()
result = verify_schema_with_resolver(
schema,
signature_b64,
"example.com",
"get_issue",
resolver,
pin_store,
)
assert result.valid
Trust Bundle Format
A trust bundle packages discovery documents and revocation lists into a single JSON file.
{
"schemapin_bundle_version": "1.3",
"created_at": "2026-02-11T00:00:00Z",
"documents": [
{
"domain": "example.com",
"schema_version": "1.3",
"developer_name": "My Corp",
"public_key_pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
"revoked_keys": []
}
],
"revocations": []
}
Step 7: Pluggable Discovery Resolvers
SchemaPin v1.3 provides a SchemaResolver abstract base class with four built-in implementations. Chain them together for flexible fallback strategies.
from schemapin.resolver import (
ChainResolver,
LocalFileResolver,
TrustBundleResolver,
)
# Try trust bundle first, fall back to local files
with open("trust-bundle.json") as f:
bundle_resolver = TrustBundleResolver.from_json(f.read())
file_resolver = LocalFileResolver(
discovery_dir="/etc/schemapin/discovery",
revocation_dir="/etc/schemapin/revocations",
)
chain = ChainResolver([bundle_resolver, file_resolver])
# The chain tries each resolver in order until one succeeds
discovery = chain.resolve_discovery("example.com")
Available Resolvers
| Resolver | Use Case | Description |
|---|---|---|
TrustBundleResolver | Air-gapped | In-memory trust bundle |
LocalFileResolver | CI / servers | Reads {domain}.json from a directory |
ChainResolver | Production | First-wins fallthrough chain |
WellKnownResolver | Online | HTTP discovery via .well-known |