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.
Step 1: Install CLI Tools
Install the SchemaPin CLI tools via Cargo. These provide key generation and schema signing.
cargo 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 Rust Server
Setup
cargo init secure-server
cd secure-server
cargo add schemapin axum tokio --features tokio/full
touch src/main.rs
Add to src/main.rs
use axum::{routing::get, Json, Router};
use schemapin::{core, discovery};
use std::collections::HashMap;
use std::sync::Arc;
type ToolSchemas = Arc<HashMap<String, serde_json::Value>>;
async fn load_and_verify_schema(
path: &str,
host: &str,
key_id: &str,
) -> Option<serde_json::Value> {
println!("Verifying {path}...");
let signed_bytes = std::fs::read(path).ok()?;
println!("Discovering key from {host}...");
let public_key = discovery::discover_public_key(host, key_id)
.await
.ok()?;
match core::verify(&signed_bytes, &public_key) {
Ok(verified_bytes) => {
let schema: serde_json::Value =
serde_json::from_slice(&verified_bytes).ok()?;
let name = schema["name"].as_str()?.to_string();
println!("SUCCESS: Verified '{name}'.");
Some(schema)
}
Err(e) => {
eprintln!("ERROR: Verification failed! {e}");
None
}
}
}
async fn get_tools(tools: axum::extract::State<ToolSchemas>) -> Json<Vec<String>> {
Json(tools.keys().cloned().collect())
}
#[tokio::main]
async fn main() {
let verified = load_and_verify_schema(
"get_issue_signed.json",
"http://localhost:8000",
"default",
)
.await;
match verified {
Some(schema) => {
let mut map = HashMap::new();
let name = schema["name"].as_str().unwrap().to_string();
map.insert(name, schema);
let tools: ToolSchemas = Arc::new(map);
let app = Router::new()
.route("/tools", get(get_tools))
.with_state(tools);
let listener =
tokio::net::TcpListener::bind("127.0.0.1:5004").await.unwrap();
println!("Rust MCP server on port 5004");
axum::serve(listener, app).await.unwrap();
}
None => {
eprintln!("\nShutting down due to verification failure.");
std::process::exit(1);
}
}
}
Run and Test Security
Run cargo run. 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'.
Rust MCP server on port 5004