View on GitHub →
← Back to Home

Secure Your Go MCP Server

An interactive guide to implementing SchemaPin v1.3.0 in Go — with offline verification, trust bundles, and pluggable discovery.

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

Advanced Demo: Open In Colab

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.

Valid Schema Tampered
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.

Valid Schema Tampered
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
TrustBundleResolverAir-gappedIn-memory trust bundle
LocalFileResolverCI / serversReads {domain}.json from a directory
ChainResolverProductionFirst-wins fallthrough chain
WellKnownResolverOnlineHTTP discovery via .well-known