Skip to content

Examples

Complete, runnable examples showing MCP Auth in real scenarios.

Example 1: MCP server with required auth

An MCP server that rejects unauthenticated tool calls. Two tools: resolve (look up an entity) and merge (merge two entities).

typescript
// server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import {
  verifyMcpToolCall,
  identityFromBytes,
  hexToBytes,
  CryptoError,
  type McpAuthMode,
  type McpAuthOutcome,
} from "@kanoniv/agent-auth";

// --- Configuration ---
const ROOT_PUBLIC_KEY = process.env.KANONIV_ROOT_PUBLIC_KEY;
if (!ROOT_PUBLIC_KEY) {
  console.error("KANONIV_ROOT_PUBLIC_KEY is required");
  process.exit(1);
}
const rootIdentity = identityFromBytes(hexToBytes(ROOT_PUBLIC_KEY));
const authMode: McpAuthMode = "required";

// --- Auth middleware ---
function authenticate(
  toolName: string,
  args: Record<string, unknown>,
): McpAuthOutcome {
  return verifyMcpToolCall(toolName, args, rootIdentity, authMode);
}

// --- Server ---
const server = new Server(
  { name: "secure-mcp-server", version: "1.0.0" },
  { capabilities: { tools: {} } },
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "resolve",
      description: "Look up an entity by source and external ID",
      inputSchema: {
        type: "object",
        properties: {
          source: { type: "string" },
          external_id: { type: "string" },
        },
        required: ["source", "external_id"],
      },
    },
    {
      name: "merge",
      description: "Merge two entities into one",
      inputSchema: {
        type: "object",
        properties: {
          source_entity_id: { type: "string" },
          target_entity_id: { type: "string" },
        },
        required: ["source_entity_id", "target_entity_id"],
      },
    },
  ],
}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: rawArgs } = request.params;

  // --- Authenticate ---
  let outcome: McpAuthOutcome;
  try {
    outcome = authenticate(name, rawArgs ?? {});
  } catch (err) {
    const message = err instanceof CryptoError ? err.message : String(err);
    return {
      content: [{ type: "text", text: `Auth failed: ${message}` }],
      isError: true,
    };
  }

  const agent = outcome.verified!.invoker_did;
  const args = outcome.args;

  // --- Tool dispatch ---
  switch (name) {
    case "resolve": {
      const { source, external_id } = args as {
        source: string;
        external_id: string;
      };
      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            entity_id: "ent_abc123",
            source,
            external_id,
            resolved_by: agent,
          }),
        }],
      };
    }

    case "merge": {
      const { source_entity_id, target_entity_id } = args as {
        source_entity_id: string;
        target_entity_id: string;
      };
      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            merged_entity_id: target_entity_id,
            consumed_entity_id: source_entity_id,
            merged_by: agent,
          }),
        }],
      };
    }

    default:
      return {
        content: [{ type: "text", text: `Unknown tool: ${name}` }],
        isError: true,
      };
  }
});

const transport = new StdioServerTransport();
await server.connect(transport);

Run with:

bash
KANONIV_ROOT_PUBLIC_KEY="your_64_char_hex_key" node server.js

Example 2: Agent creating a delegation and calling MCP

An agent receives a delegation from the root authority and uses it to make an authenticated MCP tool call.

typescript
// agent.ts
import {
  generateKeyPair,
  createRootDelegation,
  McpProof,
  bytesToHex,
} from "@kanoniv/agent-auth";

// --- Setup: Root operator creates keypair (done once, stored securely) ---
const root = generateKeyPair();
console.log("Root DID:", root.identity.did);
console.log("Root public key:", bytesToHex(root.identity.publicKeyBytes));
// ^ This hex value goes into KANONIV_ROOT_PUBLIC_KEY on the server

// --- Setup: Agent generates its own keypair ---
const agent = generateKeyPair();
console.log("Agent DID:", agent.identity.did);

// --- Root delegates to agent ---
const delegation = createRootDelegation(root, agent.identity.did, [
  { type: "action_scope", value: ["resolve", "search"] },
  { type: "expires_at", value: "2026-12-31T23:59:59.999Z" },
]);

// --- Agent makes an MCP tool call ---
function callMcpTool(
  toolName: string,
  toolArgs: Record<string, unknown>,
): Record<string, unknown> {
  // Create proof for this specific call
  const proof = McpProof.create(agent, toolName, toolArgs, delegation);

  // Inject proof into arguments
  const argsWithProof = McpProof.inject(proof, toolArgs);

  // This is what gets sent over the MCP transport
  const jsonRpcRequest = {
    jsonrpc: "2.0",
    id: 1,
    method: "tools/call",
    params: {
      name: toolName,
      arguments: argsWithProof,
    },
  };

  console.log("Sending JSON-RPC request:");
  console.log(JSON.stringify(jsonRpcRequest, null, 2));

  return jsonRpcRequest;
}

// --- Make the call ---
callMcpTool("resolve", { source: "crm", external_id: "customer-456" });

The full JSON-RPC request sent over the wire:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "resolve",
    "arguments": {
      "source": "crm",
      "external_id": "customer-456",
      "_proof": {
        "invocation": {
          "invoker_did": "did:agent:7f3a...",
          "action": "resolve",
          "args": {
            "source": "crm",
            "external_id": "customer-456"
          },
          "delegation": {
            "issuer_did": "did:agent:e2b1...",
            "delegate_did": "did:agent:7f3a...",
            "issuer_public_key": "e2b1...",
            "caveats": [
              { "type": "action_scope", "value": ["resolve", "search"] },
              { "type": "expires_at", "value": "2026-12-31T23:59:59.999Z" }
            ],
            "parent_proof": null,
            "proof": {
              "payload": { "..." : "..." },
              "signer_did": "did:agent:e2b1...",
              "nonce": "...",
              "timestamp": "2026-03-15T12:00:00.000Z",
              "signature": "..."
            }
          },
          "proof": {
            "payload": { "..." : "..." },
            "signer_did": "did:agent:7f3a...",
            "nonce": "...",
            "timestamp": "2026-03-15T12:00:01.000Z",
            "signature": "..."
          }
        },
        "invoker_public_key": "7f3a..."
      }
    }
  }
}

The server responds with a standard JSON-RPC response:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"entity_id\":\"ent_abc123\",\"source\":\"crm\",\"external_id\":\"customer-456\",\"resolved_by\":\"did:agent:7f3a...\"}"
      }
    ]
  }
}

Example 3: Multi-agent delegation chain

A three-level delegation: Root -> Manager -> Worker. The root grants broad permissions to the manager, who narrows them before delegating to the worker.

typescript
// multi-agent.ts
import {
  generateKeyPair,
  createRootDelegation,
  delegateAuthority,
  McpProof,
  verifyMcpCall,
  bytesToHex,
} from "@kanoniv/agent-auth";

// --- Three agents ---
const root = generateKeyPair();
const manager = generateKeyPair();
const worker = generateKeyPair();

console.log("Root DID:   ", root.identity.did);
console.log("Manager DID:", manager.identity.did);
console.log("Worker DID: ", worker.identity.did);

// --- Step 1: Root delegates to Manager ---
// Manager can resolve, search, and merge
const rootToManager = createRootDelegation(root, manager.identity.did, [
  { type: "action_scope", value: ["resolve", "search", "merge"] },
  { type: "expires_at", value: "2026-12-31T23:59:59.999Z" },
]);

console.log("\nRoot -> Manager delegation created");
console.log("  Scopes: resolve, search, merge");
console.log("  Expires: 2026-12-31");

// --- Step 2: Manager delegates to Worker ---
// Worker can only resolve (narrowed from manager's permissions)
// Caveats accumulate - the worker's chain has both the root's and manager's caveats
const managerToWorker = delegateAuthority(
  manager,
  worker.identity.did,
  [
    { type: "action_scope", value: ["resolve"] },
    { type: "max_cost", value: 5.0 },
  ],
  rootToManager,
);

console.log("\nManager -> Worker delegation created");
console.log("  Scopes: resolve (narrowed from resolve, search, merge)");
console.log("  Max cost: 5.0");

// --- Step 3: Worker creates proof and calls MCP tool ---
const toolArgs = {
  source: "crm",
  external_id: "customer-789",
  cost: 2.5,
};

const proof = McpProof.create(worker, "resolve", toolArgs, managerToWorker);

console.log("\nWorker created proof for 'resolve' tool call");

// --- Step 4: Server verifies ---
const result = verifyMcpCall(proof, root.identity);

console.log("\nVerification result:");
console.log("  Invoker DID:", result.invoker_did);
console.log("  Root DID:   ", result.root_did);
console.log("  Chain:      ", result.chain.join(" -> "));
console.log("  Depth:      ", result.depth);
// Output:
//   Invoker DID: did:agent:...  (worker)
//   Root DID:    did:agent:...  (root)
//   Chain:       did:agent:worker -> did:agent:manager -> did:agent:root
//   Depth:       2

// --- Step 5: Demonstrate caveat enforcement ---

// Worker tries to merge (not in their scope) - FAILS
try {
  const badProof = McpProof.create(
    worker,
    "merge",
    { source_entity_id: "a", target_entity_id: "b" },
    managerToWorker,
  );
  verifyMcpCall(badProof, root.identity);
  console.log("\nERROR: merge should have been rejected");
} catch (err) {
  console.log("\nWorker tried to merge - correctly rejected:");
  console.log(" ", String(err));
}

// Worker tries to exceed cost limit - FAILS
try {
  const expensiveProof = McpProof.create(
    worker,
    "resolve",
    { source: "crm", external_id: "123", cost: 10.0 },
    managerToWorker,
  );
  verifyMcpCall(expensiveProof, root.identity);
  console.log("\nERROR: cost should have been rejected");
} catch (err) {
  console.log("\nWorker tried cost=10.0 (max 5.0) - correctly rejected:");
  console.log(" ", String(err));
}

The JSON-RPC request from the worker has a two-level delegation chain:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "resolve",
    "arguments": {
      "source": "crm",
      "external_id": "customer-789",
      "cost": 2.5,
      "_proof": {
        "invocation": {
          "invoker_did": "did:agent:worker...",
          "action": "resolve",
          "args": {
            "source": "crm",
            "external_id": "customer-789",
            "cost": 2.5
          },
          "delegation": {
            "issuer_did": "did:agent:manager...",
            "delegate_did": "did:agent:worker...",
            "issuer_public_key": "...",
            "caveats": [
              { "type": "action_scope", "value": ["resolve", "search", "merge"] },
              { "type": "expires_at", "value": "2026-12-31T23:59:59.999Z" },
              { "type": "action_scope", "value": ["resolve"] },
              { "type": "max_cost", "value": 5.0 }
            ],
            "parent_proof": {
              "issuer_did": "did:agent:root...",
              "delegate_did": "did:agent:manager...",
              "issuer_public_key": "...",
              "caveats": [
                { "type": "action_scope", "value": ["resolve", "search", "merge"] },
                { "type": "expires_at", "value": "2026-12-31T23:59:59.999Z" }
              ],
              "parent_proof": null,
              "proof": { "...": "root's signature over the delegation" }
            },
            "proof": { "...": "manager's signature over the delegation" }
          },
          "proof": { "...": "worker's signature over the invocation" }
        },
        "invoker_public_key": "worker's Ed25519 public key (hex)"
      }
    }
  }
}

The server response on success:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"entity_id\":\"ent_xyz789\",\"source\":\"crm\",\"external_id\":\"customer-789\",\"resolved_by\":\"did:agent:worker...\"}"
      }
    ]
  }
}

The server response when the worker sends a proof for merge (not in scope):

json
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Auth failed: Caveat violation: action 'merge' not in allowed scope"
      }
    ],
    "isError": true
  }
}

Key takeaways

  1. Caveats accumulate. When the manager delegates to the worker, both the root's caveats and the manager's additional caveats are checked. The worker's effective permissions are the intersection of all caveats in the chain.

  2. Narrowing only. A delegate can only further restrict authority, never expand it. The manager cannot grant the worker permissions the manager does not have.

  3. Per-call proofs. Each MCP tool call gets its own proof with a unique nonce and timestamp. Proofs are not reusable across calls.

  4. Offline verification. The server verifies the entire chain locally. No callbacks to the root or manager. No database lookups. The proof contains every public key and signature needed.

  5. Depth is visible. The VerificationResult.depth field tells the server how many delegation hops occurred. A depth of 0 means the root is calling directly. A depth of 2 means root -> intermediate -> caller.

The identity and delegation layer for AI agents.