View on GitHub →
← Back to Home

Secure Your JavaScript MCP Server

An interactive guide to implementing SchemaPin v1.3.0 in JavaScript — 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 npm 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

Advanced Demo: Open In Colab

Step 1: Install SchemaPin

Install the schemapin npm package. The CLI tools for signing are distributed via Pip (a one-time setup step).

npm install schemapin
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 JavaScript Server

Setup

npm init -y
npm install express schemapin
touch server.js

Add to server.js

const express = require('express');
const fs = require('fs').promises;
const { verify, discoverPublicKey } = require('schemapin');

const TOOL_SCHEMAS = {};

async function loadAndVerifySchema(path, domain, keyId) {
    console.log(`Verifying ${path}...`);
    try {
        const signedSchema = JSON.parse(await fs.readFile(path, 'utf-8'));
        console.log(`Discovering key from ${domain}...`);
        const publicKey = await discoverPublicKey(domain, keyId);
        const isVerified = await verify(signedSchema, publicKey);
        if (!isVerified) throw new Error("Signature invalid.");
        console.log(`SUCCESS: Verified '${signedSchema.payload.name}'.`);
        return signedSchema.payload;
    } catch (error) {
        console.error(`ERROR: Verification failed!`, error.message);
        return null;
    }
}

async function main() {
    const app = express();
    const verifiedTool = await loadAndVerifySchema(
        'get_issue_signed.json',
        'http://localhost:8000',
        'default'
    );

    if (verifiedTool) {
        TOOL_SCHEMAS[verifiedTool.name] = verifiedTool;
    } else {
        console.log("\nShutting down due to verification failure.");
        process.exit(1);
    }

    app.get('/tools', (req, res) => res.json(Object.keys(TOOL_SCHEMAS)));
    app.listen(5002, () => console.log('JS MCP server on port 5002'));
}

main();

Run and Test Security

Run node server.js. Then, stop the server, tamper with the signature in get_issue_signed.json, and run it again to see the failure.

Valid Schema Tampered
Verifying get_issue_signed.json...
Discovering key from http://localhost:8000...
SUCCESS: Verified 'get_issue'.
JS MCP server on port 5002

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.

const { verifySchemaOffline, KeyPinStore } = require('schemapin/verification');
const fs = require('fs');

// Load the discovery document (pre-shared or fetched earlier)
const discovery = {
    schema_version: "1.3",
    developer_name: "My Corp",
    public_key_pem: fs.readFileSync("public.key", "utf-8"),
    revoked_keys: []
};

// Client-side: verify the schema offline
const pinStore = new KeyPinStore();

const result = verifySchemaOffline(
    schema,
    signatureB64,
    "example.com",
    "get_issue",
    discovery,
    null,          // no standalone revocation doc
    pinStore,
);

if (result.valid) {
    console.log(`Schema verified for ${result.domain}`);
    console.log(`Developer: ${result.developer_name}`);
    console.log(`Key pinning: ${result.key_pinning.status}`);
    // Safe to use the tool
} else {
    console.error(`Verification failed: ${result.error_message}`);
    // 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.

const { TrustBundleResolver } = require('schemapin/resolver');
const { verifySchemaWithResolver, KeyPinStore } = require('schemapin/verification');
const fs = require('fs');

// Load a trust bundle from a JSON file
const bundleJson = fs.readFileSync("trust-bundle.json", "utf-8");
const resolver = TrustBundleResolver.fromJson(bundleJson);

// Verify using the resolver
const pinStore = new KeyPinStore();
const result = await verifySchemaWithResolver(
    schema,
    signatureB64,
    "example.com",
    "get_issue",
    resolver,
    pinStore,
);

console.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 base class with four built-in implementations. Chain them together for flexible fallback strategies.

const {
    ChainResolver,
    LocalFileResolver,
    TrustBundleResolver,
} = require('schemapin/resolver');
const fs = require('fs');

// Try trust bundle first, fall back to local files
const bundleResolver = TrustBundleResolver.fromJson(
    fs.readFileSync("trust-bundle.json", "utf-8")
);

const fileResolver = new LocalFileResolver(
    "/etc/schemapin/discovery",
    "/etc/schemapin/revocations"
);

const chain = new ChainResolver([bundleResolver, fileResolver]);

// The chain tries each resolver in order until one succeeds
const discovery = await 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