@kya-os/mcp-i
Version:
The TypeScript MCP framework with identity features built-in
630 lines (629 loc) • 29.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.addToolsToServer = addToolsToServer;
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
const proof_1 = require("../proof");
const identity_1 = require("../identity");
const tool_protection_registry_1 = require("../tool-protection-registry");
const auth_handshake_1 = require("../auth-handshake");
const session_1 = require("../session");
const request_context_1 = require("../request-context");
const proof_batch_queue_1 = require("../proof-batch-queue");
const rawRuntimeConfigPath = typeof RUNTIME_CONFIG_PATH !== "undefined" ? RUNTIME_CONFIG_PATH : undefined;
// Single-parse to match single-stringify from webpack DefinePlugin
const runtimeConfigPath = rawRuntimeConfigPath
? typeof rawRuntimeConfigPath === "string"
? JSON.parse(rawRuntimeConfigPath)
: rawRuntimeConfigPath
: null;
/** Validates if a value is a valid Zod schema object */
function isZodRawShape(value) {
if (typeof value !== "object" || value === null) {
return false;
}
const obj = value;
return Object.entries(obj).every(([key, val]) => {
if (typeof key !== "string")
return false;
if (typeof val !== "object" || val === null)
return false;
if (!("parse" in val) || typeof val.parse !== "function")
return false;
return true;
});
}
function pathToName(path) {
const fileName = path.split("/").pop() || path;
return fileName.replace(/\.[^/.]+$/, "");
}
// Global ProofBatchQueue instance (singleton)
let globalProofQueue = null;
/** Load runtime config and initialize ProofBatchQueue if configured */
async function initializeProofBatchQueue(debug) {
// Return existing instance if already initialized
if (globalProofQueue) {
return globalProofQueue;
}
// Check if runtime config path is available
if (!runtimeConfigPath) {
if (debug) {
console.error("[MCPI] No runtime config found - proof submission disabled");
}
return null;
}
try {
if (debug) {
console.error("[MCPI] Loading runtime config from:", runtimeConfigPath);
}
// Dynamically import the runtime config
const configModule = await import(runtimeConfigPath);
const runtimeConfig = typeof configModule.getRuntimeConfig === "function"
? configModule.getRuntimeConfig()
: configModule.default;
// Check if proofing is enabled
if (!runtimeConfig?.proofing?.enabled) {
if (debug) {
console.error("[MCPI] Proofing is disabled in runtime config");
}
return null;
}
const proofingConfig = runtimeConfig.proofing;
if (!proofingConfig.batchQueue?.destinations ||
proofingConfig.batchQueue.destinations.length === 0) {
if (debug) {
console.error("[MCPI] No proof destinations configured");
}
return null;
}
// Create proof destinations
const destinations = [];
for (const destConfig of proofingConfig.batchQueue.destinations) {
if (destConfig.type === "agentshield") {
destinations.push(new proof_batch_queue_1.AgentShieldProofDestination(destConfig.apiUrl, destConfig.apiKey));
if (debug) {
console.error(`[MCPI] Added AgentShield destination: ${destConfig.apiUrl}`);
}
}
// Add other destination types here (KTA, etc.)
}
if (destinations.length === 0) {
if (debug) {
console.error("[MCPI] No valid proof destinations configured");
}
return null;
}
// Initialize ProofBatchQueue
globalProofQueue = new proof_batch_queue_1.ProofBatchQueue({
destinations,
maxBatchSize: proofingConfig.batchQueue.maxBatchSize || 10,
flushIntervalMs: proofingConfig.batchQueue.flushIntervalMs || 5000,
maxRetries: proofingConfig.batchQueue.maxRetries || 3,
debug: proofingConfig.batchQueue.debug || debug,
});
if (debug) {
console.error(`[MCPI] ProofBatchQueue initialized (batch: ${proofingConfig.batchQueue.maxBatchSize}, ` +
`flush: ${proofingConfig.batchQueue.flushIntervalMs}ms, ` +
`destinations: ${destinations.length})`);
}
return globalProofQueue;
}
catch (error) {
if (debug) {
console.error("[MCPI] Failed to initialize ProofBatchQueue:", error);
}
return null;
}
}
if (typeof global !== "undefined") {
global.__MCPI_HANDLERS_REGISTERED__ =
global.__MCPI_HANDLERS_REGISTERED__ || false;
}
/** Loads tools and injects them into the server */
async function addToolsToServer(server, toolModules, identityConfig) {
// Guard against double registration using global scope
// Set this IMMEDIATELY to prevent race conditions
if (typeof global !== "undefined") {
if (global.__MCPI_HANDLERS_REGISTERED__) {
if (identityConfig?.debug) {
console.error("[MCPI] Handlers already registered, skipping duplicate registration");
}
return server;
}
// Mark as registered RIGHT NOW before doing anything else
global.__MCPI_HANDLERS_REGISTERED__ = true;
}
if (identityConfig?.debug) {
console.error("[MCPI] Registering handlers for the first time");
}
// Initialize session manager for handshake support
const sessionManager = new session_1.SessionManager();
// Initialize identity manager if identity is enabled
let identityManager = null;
if (identityConfig?.enabled) {
try {
identityManager = new identity_1.IdentityManager({
environment: identityConfig.environment ||
"development",
devIdentityPath: identityConfig.devIdentityPath,
debug: identityConfig.debug,
});
// Ensure identity exists (loads or generates it)
const identity = await identityManager.ensureIdentity();
if (identityConfig.debug) {
console.error(`[MCPI] Identity enabled - DID: ${identity.did}`);
console.error(`[MCPI] Identity path: ${identityConfig.devIdentityPath || ".mcpi/identity.json"}`);
}
}
catch (error) {
if (identityConfig?.debug) {
console.error("[MCPI] Failed to initialize identity:", error);
}
// Continue without identity if initialization fails
// Set identityManager to null to prevent later unhandled calls to ensureIdentity()
identityManager = null;
}
}
// Initialize ProofBatchQueue for proof submission (if runtime config exists)
await initializeProofBatchQueue(identityConfig?.debug);
// Collect all tools and their handlers
const tools = [];
const toolHandlers = new Map();
toolModules.forEach((toolModule, path) => {
const defaultName = pathToName(path);
const toolConfig = {
name: defaultName,
description: "No description provided",
};
let toolSchema = {};
const { default: handler, metadata, schema } = toolModule;
if (typeof metadata === "object" && metadata !== null) {
Object.assign(toolConfig, metadata);
}
// Validate and ensure schema is properly typed
if (isZodRawShape(schema)) {
Object.assign(toolSchema, schema);
}
else if (schema !== undefined && schema !== null) {
console.warn(`Invalid schema for tool "${toolConfig.name}" at ${path}. Expected Record<string, z.ZodType>`);
}
// Make sure tools has annotations with a title
if (toolConfig.annotations === undefined) {
toolConfig.annotations = {};
}
if (toolConfig.annotations.title === undefined) {
toolConfig.annotations.title = toolConfig.name;
}
// Create tool definition for MCP
const tool = {
name: toolConfig.name,
description: toolConfig.description,
inputSchema: {
type: "object",
properties: Object.fromEntries(Object.entries(toolSchema).map(([key, zodType]) => [
key,
{ type: "string" }, // Simplified - in real implementation, convert Zod to JSON Schema
])),
},
};
tools.push(tool);
toolHandlers.set(toolConfig.name, handler);
});
// Debug: log server state before registering handler
if (identityConfig?.debug) {
console.error("[MCPI] About to register handlers (tools/list, tools/call, handshake)");
}
// Set server DID on session manager after identity is loaded
if (identityManager) {
try {
const identity = await identityManager.ensureIdentity();
sessionManager.setServerDid(identity.did);
}
catch (error) {
// If identity initialization failed earlier, ensureIdentity() may throw again
// Log but don't crash - server can operate without identity
if (identityConfig?.debug) {
console.error("[MCPI] Failed to set server DID on session manager:", error);
}
// Set to null to prevent future attempts
identityManager = null;
}
}
// Add internal handshake tool for MCP-I session establishment
// This allows MCP clients to perform a handshake and receive a sessionId
const handshakeTool = {
name: "_mcpi_handshake",
description: "Internal MCP-I handshake tool for session establishment. Clients should call this before protected tools.",
inputSchema: {
type: "object",
properties: {
nonce: {
type: "string",
description: "Unique nonce for replay prevention",
},
audience: {
type: "string",
description: "Target audience (server DID or URL)",
},
timestamp: { type: "number", description: "Unix timestamp in seconds" },
agentDid: {
type: "string",
description: "Agent DID for delegation verification (optional)",
},
clientInfo: {
type: "object",
description: "MCP client metadata (optional)",
properties: {
name: { type: "string" },
version: { type: "string" },
platform: { type: "string" },
vendor: { type: "string" },
},
},
clientProtocolVersion: {
type: "string",
description: "MCP protocol version (optional)",
},
clientCapabilities: {
type: "object",
description: "MCP client capabilities (optional)",
additionalProperties: true,
},
},
required: ["nonce", "audience", "timestamp"],
},
};
tools.push(handshakeTool);
// Handshake tool handler
toolHandlers.set("_mcpi_handshake", async (args) => {
// Validate required fields explicitly before conversion
const missingFields = [];
if (!args.nonce ||
(typeof args.nonce === "string" && args.nonce.trim().length === 0)) {
missingFields.push("nonce");
}
if (!args.audience ||
(typeof args.audience === "string" && args.audience.trim().length === 0)) {
missingFields.push("audience");
}
// Validate timestamp is present and valid
if (args.timestamp === undefined ||
args.timestamp === null ||
typeof args.timestamp !== "number" ||
args.timestamp <= 0 ||
!Number.isInteger(args.timestamp)) {
if (args.timestamp === undefined || args.timestamp === null) {
missingFields.push("timestamp");
}
else {
// Invalid format (not a positive integer)
return {
success: false,
error: {
code: "XMCP_I_EHANDSHAKE",
message: "Invalid timestamp: must be a positive integer (Unix timestamp in seconds)",
details: {
received: typeof args.timestamp,
value: args.timestamp,
},
},
};
}
}
if (missingFields.length > 0) {
return {
success: false,
error: {
code: "XMCP_I_EHANDSHAKE",
message: `Missing required field(s): ${missingFields.join(", ")}`,
details: {
missingFields,
requiredFields: ["nonce", "audience", "timestamp"],
},
},
};
}
// At this point, timestamp is guaranteed to be a valid positive integer
const timestamp = args.timestamp;
// Build handshake request from validated tool arguments
const handshakeRequest = {
nonce: String(args.nonce).trim(),
audience: String(args.audience).trim(),
timestamp,
agentDid: typeof args.agentDid === "string" && args.agentDid.trim().length > 0
? args.agentDid.trim()
: undefined,
clientInfo: typeof args.clientInfo === "object" && args.clientInfo !== null
? args.clientInfo
: undefined,
clientProtocolVersion: typeof args.clientProtocolVersion === "string" &&
args.clientProtocolVersion.trim().length > 0
? args.clientProtocolVersion.trim()
: undefined,
clientCapabilities: typeof args.clientCapabilities === "object" &&
args.clientCapabilities !== null
? args.clientCapabilities
: undefined,
};
// Double-check format (should always pass at this point, but defensive)
if (!(0, session_1.validateHandshakeFormat)(handshakeRequest)) {
return {
success: false,
error: {
code: "XMCP_I_EHANDSHAKE",
message: "Invalid handshake request format. Required: nonce, audience, timestamp",
},
};
}
// Validate handshake and create session
const result = await sessionManager.validateHandshake(handshakeRequest);
// Log clientInfo in development mode
if (identityConfig?.debug &&
result.success &&
result.session?.clientInfo) {
console.error("[MCPI] Client connected:", {
name: result.session.clientInfo.name,
version: result.session.clientInfo.version,
platform: result.session.clientInfo.platform,
vendor: result.session.clientInfo.vendor,
clientId: result.session.clientInfo.clientId,
});
}
if (result.success && result.session) {
return {
success: true,
sessionId: result.session.sessionId,
serverDid: result.session.serverDid,
ttlMinutes: result.session.ttlMinutes,
...(result.session.clientInfo && {
clientInfo: result.session.clientInfo,
}),
};
}
return {
success: false,
error: result.error,
};
});
// Register tools/list handler
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async (request) => {
return {
tools: tools,
};
});
// Register tools/call handler
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
// Extract sessionId from request metadata (XMCP-I extension)
const sessionId = request._meta?.sessionId;
let session = null;
if (sessionId) {
session = await sessionManager.getSession(sessionId);
if (!session && identityConfig?.debug) {
console.error(`[MCPI] Session not found or expired: ${sessionId}`);
}
}
// Wrap tool execution with request context for AsyncLocalStorage
return (0, request_context_1.runWithContext)({
session: session || undefined,
requestId: request._meta?.requestId,
startTime: Date.now(),
}, async () => {
const { name, arguments: args } = request.params;
const handler = toolHandlers.get(name);
if (!handler) {
return {
content: [
{
type: "text",
text: `Tool "${name}" not found`,
},
],
isError: true,
};
}
// PHASE 1.5: Check if tool is protected BEFORE executing
const toolProtection = tool_protection_registry_1.toolProtectionRegistry.get(name);
if (toolProtection?.requiresDelegation) {
const authConfig = tool_protection_registry_1.toolProtectionRegistry.getAuthConfig();
const delegationVerifier = tool_protection_registry_1.toolProtectionRegistry.getDelegationVerifier();
if (!delegationVerifier || !authConfig) {
if (identityConfig?.debug) {
console.error(`[MCPI] Tool "${name}" requires delegation but verifier not configured`);
}
return {
content: [
{
type: "text",
text: `❌ Tool "${name}" requires delegation verification, but the delegation system is not configured.\n\n` +
`To fix this:\n` +
`1. Configure a delegation verifier:\n` +
` - MemoryDelegationVerifier (development/testing)\n` +
` - KVDelegationVerifier (production with KV store)\n` +
` - AgentShieldVerifier (production with AgentShield service)\n\n` +
`2. Enable delegation in your runtime config:\n` +
` const runtime = new MCPIRuntime({\n` +
` delegation: { enabled: true },\n` +
` // ... other config\n` +
` });\n\n` +
`3. Ensure the verifier is initialized before tool execution.\n\n` +
`📚 Documentation: https://docs.mcp-i.dev/delegation/setup`,
},
],
isError: true,
};
}
// Extract agent DID from current request context (AsyncLocalStorage)
const agentDid = (0, request_context_1.getCurrentAgentDid)();
if (!agentDid) {
if (identityConfig?.debug) {
console.error(`[MCPI] Tool "${name}" requires delegation but no agent DID in session`);
}
return {
content: [
{
type: "text",
text: `❌ Tool "${name}" requires delegation, but no agent identity was found in the current session.\n\n` +
`This usually means one of the following:\n\n` +
`1. **No handshake was performed**: The MCP client must send a handshake request before calling protected tools.\n` +
` Example handshake:\n` +
` {\n` +
` "agentDid": "did:key:z6Mk...",\n` +
` "nonce": "unique-value",\n` +
` "audience": "server-did",\n` +
` "timestamp": ${Math.floor(Date.now() / 1000)}\n` +
` }\n\n` +
`2. **Handshake missing agentDid**: Ensure the handshake request includes the 'agentDid' field.\n\n` +
`3. **Session expired**: The session may have expired. Try performing a new handshake.\n\n` +
`4. **Request missing sessionId**: Ensure tool call requests include _meta.sessionId from the handshake response.\n\n` +
`📚 Learn more: https://docs.mcp-i.dev/delegation/handshake`,
},
],
isError: true,
};
}
if (identityConfig?.debug) {
console.error(`[MCPI] Tool "${name}" requires delegation - verifying scopes: ${toolProtection.requiredScopes?.join(", ")}`);
}
// Verify delegation
const verifyResult = await (0, auth_handshake_1.verifyOrHints)(agentDid, toolProtection.requiredScopes || [], authConfig);
// If not authorized, return needs_authorization error
if (!verifyResult.authorized) {
if (identityConfig?.debug) {
console.error(`[MCPI] Tool "${name}" blocked - authorization required`);
}
// Return MCP error format with authorization hints
return {
content: [
{
type: "text",
text: JSON.stringify({
error: "needs_authorization",
message: `Tool "${name}" requires user authorization`,
authorizationUrl: verifyResult.authError?.authorizationUrl,
resumeToken: verifyResult.authError?.resumeToken,
scopes: verifyResult.authError?.scopes,
display: verifyResult.authError?.display,
}),
},
],
isError: true,
};
}
if (identityConfig?.debug) {
console.error(`[MCPI] Tool "${name}" authorized - executing handler`);
}
}
try {
// Execute the tool handler
const result = await handler(args || {});
// Build base response
const baseResponse = {
content: [
{
type: "text",
text: typeof result === "string"
? result
: JSON.stringify(result),
},
],
};
// If identity is enabled, generate cryptographic proof
if (identityManager) {
try {
// Ensure identity exists
const identity = await identityManager.ensureIdentity();
// Create a session context for this request
const toolRequest = {
method: name,
params: args || {},
};
const toolResponse = {
data: result,
};
// Reuse the active session if available, otherwise synthesize one
const timestamp = Math.floor(Date.now() / 1000);
const proofSession = session ?? {
sessionId: `tool-${Date.now()}`,
nonce: `${Date.now()}`,
audience: "client",
createdAt: timestamp,
timestamp,
lastActivity: timestamp,
ttlMinutes: 30,
identityState: "anonymous", // Phase 5: Synthesized sessions are anonymous
};
// Determine scopeId from tool protection configuration
// This enables AgentShield tool auto-discovery
let scopeId;
const toolProtection = tool_protection_registry_1.toolProtectionRegistry.get(name);
if (toolProtection?.requiredScopes &&
toolProtection.requiredScopes.length > 0) {
// Use the first required scope as the scopeId (e.g., "files:read")
scopeId = toolProtection.requiredScopes[0];
}
else {
// Fallback: Use tool name with "execute" action for unprotected tools
scopeId = `${name}:execute`;
}
if (identityConfig?.debug) {
console.error(`[MCPI] Proof scopeId for tool "${name}": ${scopeId}`);
}
// Generate proof using the proof generator with scopeId
const proofGen = new proof_1.ProofGenerator(identity);
const proof = await proofGen.generateProof(toolRequest, toolResponse, proofSession, {
scopeId, // Pass scopeId for tool auto-discovery
clientDid: session?.clientDid,
});
if (identityConfig?.debug) {
console.error(`[MCPI] Generated proof for tool "${name}" - DID: ${proof.meta.did}`);
}
// Submit proof to ProofBatchQueue (if initialized)
if (globalProofQueue) {
try {
globalProofQueue.enqueue(proof);
if (identityConfig?.debug) {
console.error(`[MCPI] Proof enqueued for submission to AgentShield`);
}
}
catch (queueError) {
if (identityConfig?.debug) {
console.error(`[MCPI] Failed to enqueue proof:`, queueError);
}
// Continue even if queueing fails - proof is still in response
}
}
// Return response with proof metadata
return {
...baseResponse,
_meta: {
proof,
},
};
}
catch (proofError) {
if (identityConfig?.debug) {
console.error(`[MCPI] Failed to generate proof for tool "${name}":`, proofError);
}
// Return base response without proof if generation fails
return baseResponse;
}
}
return baseResponse;
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error executing tool "${name}": ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
});
if (identityConfig?.debug) {
console.error("[MCPI] Handlers registered successfully");
}
return server;
}