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 MCP server (stdio, SSE, or HTTP)
- Node.js 20+
- A Checkpoint account with an API key
Time Estimate
25-30 minutes
What Changes
| Component | Before (MCP) | After (KYA-OS) |
|---|---|---|
| Identity | None | DID (did:key:z6Mk...) |
| Authorization | None or custom | Delegation-based |
| Tool access | Open | Scope-gated |
| Audit trail | None | Proofs + dashboard |
| Discovery | None | Well-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-iGenerate 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// 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.mjsThis 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.
Add to .gitignore:
echo ".mcpi/" >> .gitignoreNever commit private keys. Use environment variables for production.
Configure Environment
Add Checkpoint configuration to your environment:
# .env
AGENTSHIELD_API_KEY=sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AGENTSHIELD_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.comTwo env names, one key: AGENTSHIELD_API_KEY is the KYA-OS/bouncer naming convention for the same
dashboard API key the Checkpoint SDKs read as CHECKPOINT_API_KEY — see
Credentials for where to find the key and which name applies where. The
bouncer middleware reads no environment variables itself — you pass values into its config
explicitly.
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();
}
});import { createMCPIRuntime } from '@kya-os/mcp-i';
const runtime = createMCPIRuntime({
identity: {
environment: process.env.NODE_ENV === 'production' ? 'production' : 'development',
devIdentityPath: '.mcpi',
},
});
await runtime.initialize();
const wellKnown = runtime.createWellKnownHandler({
serviceName: 'Your MCP Server',
serviceEndpoint: process.env.BASE_URL,
});
fastify.get('/.well-known/did.json', async (request, reply) => {
const response = await wellKnown('/.well-known/did.json');
if (response && 'status' in response) {
reply.status(response.status).headers(response.headers);
return response.body;
}
reply.status(404);
});
fastify.get('/.well-known/agent.json', async (request, reply) => {
const response = await wellKnown('/.well-known/agent.json');
if (response && 'status' in response) {
reply.status(response.status).headers(response.headers);
return response.body;
}
reply.status(404);
});import { Hono } from 'hono';
import { createMCPIRuntime } from '@kya-os/mcp-i';
const app = new Hono();
const runtime = createMCPIRuntime({
identity: {
environment: process.env.NODE_ENV === 'production' ? 'production' : 'development',
devIdentityPath: '.mcpi',
},
});
await runtime.initialize();
const wellKnown = runtime.createWellKnownHandler({
serviceName: 'Your MCP Server',
serviceEndpoint: process.env.BASE_URL,
});
app.get('/.well-known/did.json', async (c) => {
const response = await wellKnown('/.well-known/did.json');
if (response && 'status' in response) {
return c.body(response.body, response.status, response.headers);
}
return c.notFound();
});
app.get('/.well-known/agent.json', async (c) => {
const response = await wellKnown('/.well-known/agent.json');
if (response && 'status' in response) {
return c.body(response.body, response.status, response.headers);
}
return c.notFound();
});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.AGENTSHIELD_API_KEY!,
projectId: process.env.AGENTSHIELD_PROJECT_ID!,
});
// Protected tool handler
app.post('/tools/read_file', bouncer, async (req, res) => {
// Bouncer verified the delegation — access agent info
const { agentDid, scopes, reputation } = 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 Checkpoint dashboard for proper consent screens:
- Discover (or manually add/remove) your server's tools on the legacy tools surface at
/dashboard/{orgId}/{projectId}/control-access/tools(legacy surface, moving to Access) - Go to Policy → Auth and assign each tool an owning protection with the scopes it requires — for example,
read_fileowned by a protection grantingfiles:read
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 metadataTest 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 delegationTest 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
| Step | Status |
|---|---|
Install @kya-os/bouncer-middleware and @kya-os/mcp-i | ☐ |
Generate agent identity (.mcpi/identity.json) | ☐ |
Add .mcpi/ to .gitignore | ☐ |
| 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 Checkpoint 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 bouncerPhase 3: Full Protection
Eventually protect all tools:
// All tools protected
app.use('/tools', bouncer);Troubleshooting
Bouncer Returns 401 for All Requests
| Symptom | Cause | Fix |
|---|---|---|
| "Invalid API key" | Wrong key | Check AGENTSHIELD_API_KEY |
| "Project not found" | Wrong project | Check AGENTSHIELD_PROJECT_ID |
MISSING_PROOF | No _meta.proof.jws in the body | Client 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
| Goal | Resource |
|---|---|
| Configure auth methods | Auth Methods |
| Customize consent flow | Consent Flows |
| Understand delegations | Delegations |
| Full KYA-OS server | Self-Host |