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 Go module (github.com/ThirdKeyAi/schemapin/go) 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
Add the SchemaPin Go module to your project. The CLI tools for signing are distributed via Pip (a one-time setup step).
go get github.com/ThirdKeyAi/schemapin/go@v1.3.0
pip install schemapin # for CLI signing tools
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 Go Server
Setup
go mod init secure-server
go get github.com/ThirdKeyAi/schemapin/go@v1.3.0
touch main.go
Add to main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"github.com/ThirdKeyAi/schemapin/go/pkg/crypto"
"github.com/ThirdKeyAi/schemapin/go/pkg/utils"
)
var TOOL_SCHEMAS = make(map[string]interface{})
func loadAndVerifySchema(path, host string) (map[string]interface{}, error) {
log.Printf("Verifying %s...", path)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read: %w", err)
}
var signedSchema map[string]interface{}
json.Unmarshal(data, &signedSchema)
schema := signedSchema["schema"].(map[string]interface{})
signature := signedSchema["signature"].(string)
// Discover public key
log.Printf("Discovering key from %s...", host)
resp, err := http.Get(fmt.Sprintf("%s/.well-known/schemapin.json", host))
if err != nil {
return nil, fmt.Errorf("discovery failed: %w", err)
}
defer resp.Body.Close()
var wellKnown map[string]interface{}
json.NewDecoder(resp.Body).Decode(&wellKnown)
publicKeyPEM := wellKnown["public_key_pem"].(string)
// Verify signature
schemaHash, _ := utils.CalculateSchemaHash(schema)
km := crypto.NewKeyManager()
pubKey, _ := km.ImportPublicKeyPEM(publicKeyPEM)
sm := crypto.NewSignatureManager()
if !sm.VerifySchemaSignature(schemaHash, signature, pubKey) {
return nil, fmt.Errorf("VERIFICATION FAILED")
}
log.Printf("SUCCESS: Verified '%s'.", schema["name"])
return schema, nil
}
func main() {
verifiedTool, err := loadAndVerifySchema(
"get_issue_signed.json", "http://localhost:8000",
)
if err != nil {
log.Fatalf("Could not start server: %v", err)
}
TOOL_SCHEMAS[verifiedTool["name"].(string)] = verifiedTool
http.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) {
var names []string
for name := range TOOL_SCHEMAS {
names = append(names, name)
}
json.NewEncoder(w).Encode(names)
})
log.Println("Go MCP server running on port 5003")
http.ListenAndServe(":5003", nil)
}
Run and Test Security
Run the server. Then, stop it, tamper with the signature in get_issue_signed.json, and run it again to see the fatal error.
2026/02/11 14:38:00 Verifying get_issue_signed.json...
2026/02/11 14:38:00 Discovering key from http://localhost:8000...
2026/02/11 14:38:00 SUCCESS: Verified 'get_issue'.
2026/02/11 14:38:00 Go MCP server running on port 5003
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.
import (
"os"
"github.com/ThirdKeyAi/schemapin/go/pkg/discovery"
"github.com/ThirdKeyAi/schemapin/go/pkg/verification"
)
// Load the discovery document (pre-shared or fetched earlier)
publicKeyPEM, _ := os.ReadFile("public.key")
disc := &discovery.WellKnownResponse{
SchemaVersion: "1.3",
DeveloperName: "My Corp",
PublicKeyPEM: string(publicKeyPEM),
RevokedKeys: []string{},
}
// Client-side: verify the schema offline
pinStore := verification.NewKeyPinStore()
result := verification.VerifySchemaOffline(
schema,
signatureB64,
"example.com",
"get_issue",
disc,
nil, // no standalone revocation doc
pinStore,
)
if result.Valid {
log.Printf("Schema verified for %s", result.Domain)
log.Printf("Developer: %s", result.DeveloperName)
log.Printf("Key pinning: %s", result.KeyPinning.Status)
// Safe to use the tool
} else {
log.Printf("Verification failed: %s", result.ErrorMessage)
// 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.
import (
"os"
"github.com/ThirdKeyAi/schemapin/go/pkg/resolver"
"github.com/ThirdKeyAi/schemapin/go/pkg/verification"
)
// Load a trust bundle from a JSON file
bundleJSON, _ := os.ReadFile("trust-bundle.json")
tbResolver, _ := resolver.FromJSON(string(bundleJSON))
// Verify using the resolver
pinStore := verification.NewKeyPinStore()
result := verification.VerifySchemaWithResolver(
schema,
signatureB64,
"example.com",
"get_issue",
tbResolver,
pinStore,
)
if !result.Valid {
log.Fatal(result.ErrorMessage)
}
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 interface with four built-in implementations. Chain them together for flexible fallback strategies.
import (
"os"
"github.com/ThirdKeyAi/schemapin/go/pkg/resolver"
)
// Try trust bundle first, fall back to local files
bundleJSON, _ := os.ReadFile("trust-bundle.json")
bundleResolver, _ := resolver.FromJSON(string(bundleJSON))
fileResolver := resolver.NewLocalFileResolver(
"/etc/schemapin/discovery",
"/etc/schemapin/revocations",
)
chain := resolver.NewChainResolver([]resolver.SchemaResolver{
bundleResolver,
fileResolver,
})
// The chain tries each resolver in order until one succeeds
disc, _ := chain.ResolveDiscovery("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 |