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 Rust crate (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: Add SchemaPin to Your Project
Add the schemapin crate to your Cargo.toml. By default, no HTTP dependencies are included — everything works offline.
[dependencies]
schemapin = "1.3.0"
To enable HTTP-based discovery (fetches public keys from .well-known endpoints), add the fetch feature:
[dependencies]
schemapin = { version = "1.3.0", features = ["fetch"] }
Step 2: Sign a Schema
Generate a key pair and sign a tool schema using the crate's crypto and canonicalize modules.
use schemapin::crypto::{generate_key_pair, sign_data, calculate_key_id};
use schemapin::canonicalize::canonicalize_and_hash;
use serde_json::json;
// Generate a new ECDSA P-256 key pair
let key_pair = generate_key_pair().unwrap();
// Define the tool schema
let schema = json!({
"name": "get_issue",
"description": "Gets the contents of an issue",
"input_schema": {
"type": "object",
"properties": {
"owner": { "type": "string" },
"repo": { "type": "string" },
"issue_number": { "type": "number" }
},
"required": ["owner", "repo", "issue_number"]
}
});
// Canonicalize + hash, then sign
let hash = canonicalize_and_hash(&schema);
let signature = sign_data(&key_pair.private_key_pem, &hash).unwrap();
// Key fingerprint for discovery/pinning
let key_id = calculate_key_id(&key_pair.public_key_pem).unwrap();
println!("Signature: {signature}");
println!("Key ID: {key_id}");
Step 3: Build a Discovery Document
Create a .well-known/schemapin.json discovery document that clients will use to find your public key.
use schemapin::discovery::build_well_known_response;
// Build the discovery response
let discovery = build_well_known_response(
&key_pair.public_key_pem,
Some("My Corp"),
vec![], // no revoked keys
"1.3",
);
// Serialize to JSON for hosting at /.well-known/schemapin.json
let json = serde_json::to_string_pretty(&discovery).unwrap();
std::fs::write(".well-known/schemapin.json", &json).unwrap();
Step 4: 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.
use schemapin::verification::verify_schema_offline;
use schemapin::pinning::KeyPinStore;
// Client-side: verify the schema offline
let mut pin_store = KeyPinStore::new();
let result = verify_schema_offline(
&schema,
&signature,
"example.com",
"get_issue",
&discovery,
None, // no standalone revocation doc
&mut pin_store,
);
if result.valid {
println!("Schema verified for {}", result.domain.unwrap());
println!("Developer: {}", result.developer_name.unwrap());
println!("Key pinning: {}", result.key_pinning.unwrap());
// Safe to use the tool
} else {
eprintln!("Verification failed: {}", result.error_message.unwrap());
// 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 5: 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.
use schemapin::resolver::TrustBundleResolver;
use schemapin::verification::verify_schema_with_resolver;
use schemapin::pinning::KeyPinStore;
// Load a trust bundle from a JSON file
let bundle_json = std::fs::read_to_string("trust-bundle.json").unwrap();
let resolver = TrustBundleResolver::from_json(&bundle_json).unwrap();
// Verify using the resolver
let mut pin_store = KeyPinStore::new();
let result = verify_schema_with_resolver(
&schema,
&signature,
"example.com",
"get_issue",
&resolver,
&mut 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 6: Pluggable Discovery Resolvers
SchemaPin v1.3 provides a SchemaResolver trait with four built-in implementations. Chain them together for flexible fallback strategies.
use schemapin::resolver::{
ChainResolver, LocalFileResolver,
TrustBundleResolver, SchemaResolver,
};
// Try trust bundle first, fall back to local files
let bundle_resolver = TrustBundleResolver::from_json(&bundle_json).unwrap();
let file_resolver = LocalFileResolver::new(
"/etc/schemapin/discovery".into(),
Some("/etc/schemapin/revocations".into()),
);
let chain = ChainResolver::new(vec![
Box::new(bundle_resolver),
Box::new(file_resolver),
]);
// The chain tries each resolver in order until one succeeds
let discovery = chain.resolve_discovery("example.com").unwrap();
Available Resolvers
| Resolver | Feature | Description |
|---|---|---|
TrustBundleResolver | default | In-memory trust bundle |
LocalFileResolver | default | Reads {domain}.json from a directory |
ChainResolver | default | First-wins fallthrough chain |
WellKnownResolver | fetch | HTTP discovery via .well-known |