Cookbooks

Govern: MCP to KYA-OS Migration

Add identity and authorization to an existing MCP server

Goal

Add KYA-OS capabilities to your existing MCP server. By the end of this cookbook, you'll have:

  • An agent identity (DID) for your existing server
  • Delegation-based authorization on tool endpoints
  • Cryptographic proof generation for responses
  • Dashboard integration for monitoring

Best for: Teams with existing MCP servers who want to add identity and governance without a full rewrite.

Prerequisites

  • An existing Node.js MCP server whose tools are HTTP routes: each step gates a route. For a server that talks over stdio, or answers every tool on one MCP endpoint, deploy a new KYA-OS server, or scaffold one with kya-os mcp create, and move the tools into it.
  • Node.js 20+; @kya-os/bouncer-middleware needs Node.js 20.19+ or 22.12+
  • A Vouched KYA account with an API key

Time Estimate

25-30 minutes


What Changes

ComponentBefore (MCP)After (KYA-OS)
IdentityNoneDID (did:key:z6Mk...)
AuthorizationNone or customDelegation-based
Tool accessOpenScope-gated
Audit trailNoneProofs + dashboard
DiscoveryNoneWell-known endpoints

Your server's core functionality stays the same. KYA-OS adds an authorization layer on top.


Steps

Install KYA-OS Packages

Add the bouncer middleware to your project:

npm install @kya-os/bouncer-middleware @kya-os/mcp-i

Generate Agent Identity

Your KYA-OS server needs a persistent identity (Ed25519 key pair → DID). The @kya-os/create-mcpi-app package exports a generateIdentity helper for this:

npm install -D @kya-os/create-mcpi-app

The identity holds a private key, so keep its directory out of git before you create it:

echo ".mcpi/" >> .gitignore
// scripts/generate-identity.mjs
import { generateIdentity } from '@kya-os/create-mcpi-app/helpers';
import fs from 'node:fs';

const identity = await generateIdentity();
fs.mkdirSync('.mcpi', { recursive: true });
fs.writeFileSync('.mcpi/identity.json', JSON.stringify(identity, null, 2));
console.log(`Generated DID: ${identity.did}`);
node scripts/generate-identity.mjs

This creates .mcpi/identity.json:

{
  "did": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
  "kid": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#key-1",
  "privateKey": "base64-encoded-private-key",
  "publicKey": "base64-encoded-public-key",
  "createdAt": "2024-01-15T10:00:00.000Z",
  "type": "development"
}

Prefer a guided setup? npx @kya-os/create-mcpi-app wrap detects your existing MCP server, generates wrapper files, writes .mcpi/identity.json, and adds .mcpi/ to .gitignore for you.

Never commit private keys. Use environment variables for production.

Configure Environment

.env holds secrets, so make sure git ignores it before any go in:

git check-ignore -q .env || echo ".env" >> .gitignore

Then add Vouched KYA configuration to your environment:

# .env
CHECKPOINT_API_KEY=sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CHECKPOINT_PROJECT_ID=proj_abc123def456

# For production, inline the full identity triple
MCP_IDENTITY_PRIVATE_KEY=base64-private-key-here
MCP_IDENTITY_PUBLIC_KEY=base64-public-key-here
MCP_IDENTITY_AGENT_DID=did:key:z6Mk...

# Your server's public URL
BASE_URL=https://your-mcp-server.com

Add Well-Known Endpoints

KYA-OS servers must expose discovery endpoints for their identity:

// Add to your Express app
import { createMCPIRuntime } from '@kya-os/mcp-i';

// The runtime loads its own identity: MCP_IDENTITY_PRIVATE_KEY /
// MCP_IDENTITY_PUBLIC_KEY / MCP_IDENTITY_AGENT_DID env vars when set,
// otherwise the identity.json inside `identity.devIdentityPath`.
const runtime = createMCPIRuntime({
  identity: {
    environment: process.env.NODE_ENV === 'production' ? 'production' : 'development',
    devIdentityPath: '.mcpi', // directory containing identity.json
  },
});
await runtime.initialize();

const wellKnown = runtime.createWellKnownHandler({
  serviceName: 'Your MCP Server',
  serviceEndpoint: process.env.BASE_URL,
});

// Well-known DID document
app.get('/.well-known/did.json', async (req, res) => {
  const response = await wellKnown('/.well-known/did.json');
  if (response && 'status' in response) {
    res.status(response.status).set(response.headers).send(response.body);
  } else {
    res.status(404).end();
  }
});

// Well-known agent metadata
app.get('/.well-known/agent.json', async (req, res) => {
  const response = await wellKnown('/.well-known/agent.json');
  if (response && 'status' in response) {
    res.status(response.status).set(response.headers).send(response.body);
  } else {
    res.status(404).end();
  }
});

Protect Tool Endpoints

Wrap your existing tool handlers with the bouncer middleware:

Before (unprotected):

// Original tool handler
app.post('/tools/read_file', async (req, res) => {
  const { path } = req.body;
  const content = await fs.readFile(path, 'utf-8');
  res.json({ content });
});

After (KYA-OS protected):

import { createBouncerMiddleware } from '@kya-os/bouncer-middleware';

// Create middleware instance
const bouncer = createBouncerMiddleware({
  apiKey: process.env.CHECKPOINT_API_KEY!,
  projectId: process.env.CHECKPOINT_PROJECT_ID!,
});

// Protected tool handler
app.post('/tools/read_file', bouncer, async (req, res) => {
  // Bouncer verified the delegation: access agent info
  const { agentDid, scopes } = req.bouncer;

  // Check required scope
  if (!scopes.includes('files:read')) {
    return res.status(403).json({
      error: 'Scope not granted',
      required: 'files:read',
      granted: scopes,
    });
  }

  // Execute the tool through the runtime: it runs your handler and
  // generates a signed proof automatically. The proof stays out-of-band
  // (see the Add Proof Generation step); it is never added to the response.
  const result = await runtime.processToolCall(
    'read_file',
    req.body,
    async ({ path }: { path: string }) => {
      const content = await fs.readFile(path, 'utf-8');
      return { content };
    }
  );

  res.json(result);
});

Add Scope Checks per Tool

Define scope requirements for each of your tools:

// tools/config.ts
export const toolScopes: Record<string, string[]> = {
  read_file: ['files:read'],
  write_file: ['files:write'],
  delete_file: ['files:delete'],
  list_directory: ['files:read'],
  send_email: ['email:send'],
  read_calendar: ['calendar:read'],
  create_event: ['calendar:write'],
};

// Reusable scope checker
function requireScopes(toolName: string) {
  return (req, res, next) => {
    const { scopes } = req.bouncer;
    const required = toolScopes[toolName] || [];

    const missing = required.filter((s) => !scopes.includes(s));
    if (missing.length > 0) {
      return res.status(403).json({
        error: 'Insufficient scopes',
        required,
        missing,
        granted: scopes,
      });
    }

    next();
  };
}

// Usage
app.post('/tools/read_file', bouncer, requireScopes('read_file'), handler);
app.post('/tools/write_file', bouncer, requireScopes('write_file'), handler);

Register Tools in Dashboard

Register your tools in the Vouched KYA dashboard for proper consent screens:

  1. Open Policy → Auth. A tool is listed once Vouched KYA has heard of it: register the server with its tool list (registerServer in @kya-os/checkpoint-mcp, or POST /api/v1/kya-os/servers) and they arrive open; otherwise a tool is added the first time a proof for it is submitted, or when you run discovery.
  2. Assign each tool an owning auth method with the scopes it requires: for example, read_file owned by an auth method granting files:read

Assignments here decide what the consent screen asks for. What refuses a call is still the bouncer and requireScopes middleware above, and neither reads these assignments.

The tool list itself is the Tools table in card 02 Authentication methods, and it fills itself as above. Discover tools in its header runs discovery on demand, and Remove tool… in a tool's menu removes a tool.

Repeat the assignment for all tools you want to protect; tools left unassigned stay open.

Add Proof Generation

The runtime generates cryptographic proofs for you. Wrap tool execution in runtime.processToolCall(toolName, args, handler, session?): it runs your handler, signs a proof over the result, and stores the proof for out-of-band retrieval:

// `runtime` is the KYA-OS runtime initialized in the well-known step
app.post('/tools/read_file', bouncer, async (req, res) => {
  const { path } = req.body;

  // Executes the tool and generates a signed proof automatically
  const result = await runtime.processToolCall('read_file', { path }, async () => {
    const content = await fs.readFile(path, 'utf-8');
    return { content, path };
  });

  // The proof is NOT in the response body: retrieve it out-of-band
  // if you want to inspect or archive it
  const proof = runtime.getLastProof();

  res.json(result);
});

Proofs let verifiers confirm a response came from your server and hasn't been tampered with. They stay outside the response body by design, so agents and LLM clients never see cryptographic material.

Test the Migration

Test well-known endpoints:

curl https://your-server/.well-known/did.json
# Should return DID document

curl https://your-server/.well-known/agent.json
# Should return agent metadata

Test protected endpoint without delegation:

curl -X POST https://your-server/tools/read_file \
  -H "Content-Type: application/json" \
  -d '{"path": "/test.txt"}'

# Expected: 401 Unauthorized - No delegation

Test with delegation (after obtaining one via OAuth flow):

curl -X POST https://your-server/tools/read_file \
  -H "Content-Type: application/json" \
  -d '{"path": "/test.txt", "_meta": {"proof": {"jws": "<compact-jws-from-delegation-flow>"}}}'

# Expected: 200 OK with the tool result (proofs stay out-of-band)

Migration Checklist

StepStatus
Install @kya-os/bouncer-middleware and @kya-os/mcp-i☐
Add .mcpi/ to .gitignore☐
Generate agent identity (.mcpi/identity.json)☐
Make sure git ignores .env☐
Configure environment variables☐
Add /.well-known/did.json endpoint☐
Add /.well-known/agent.json endpoint☐
Wrap tool handlers with bouncer middleware☐
Add scope checks per tool☐
Wrap tool execution with processToolCall☐
Register tools in Vouched KYA dashboard☐
Test protected endpoints☐
Deploy and verify☐

Gradual Migration Strategy

You don't have to migrate all tools at once. Use a gradual approach:

Phase 1: Identity Only

Add well-known endpoints without protecting tools:

// Just add discovery, no protection yet
app.get('/.well-known/did.json', ...);
app.get('/.well-known/agent.json', ...);

Phase 2: Protect Sensitive Tools

Wrap only high-risk tools:

// Sensitive tools get protection
app.post('/tools/write_file', bouncer, ...);
app.post('/tools/delete_file', bouncer, ...);
app.post('/tools/send_email', bouncer, ...);

// Read-only tools stay open (for now)
app.post('/tools/read_file', ...); // No bouncer

Phase 3: Full Protection

Eventually protect all tools:

// All tools protected
app.use('/tools', bouncer);

Troubleshooting

Vouched KYA Middleware Returns 401 for All Requests

SymptomCauseFix
"Invalid API key"Wrong keyCheck CHECKPOINT_API_KEY
"Project not found"Wrong projectCheck CHECKPOINT_PROJECT_ID
MISSING_PROOFNo _meta.proof.jws in the bodyClient must send the proof envelope

Scopes Always Empty

  • Check delegation: Delegation may not grant required scopes
  • Check tool registration: Tools must be registered in dashboard
  • Check consent flow: User must consent to specific scopes

Proof Verification Fails

  • Check identity: Private key must match DID
  • Check timestamp: Proofs have a validity window
  • Check format: Proof must be properly signed JWS

What You Learned

  • How to add KYA-OS identity to an existing MCP server
  • How to protect tools with delegation-based authorization
  • How to add scope requirements per tool
  • How to generate proofs automatically with processToolCall
  • A gradual migration strategy

Next Steps

GoalResource
Configure auth methodsAuth Methods
Customize consent flowConsent Flows
Understand delegationsDelegations
Full KYA-OS serverSelf-Host