UNPKG

@kya-os/mcp-i

Version:

The TypeScript MCP framework with identity features built-in

630 lines (629 loc) 29.2 kB
"use strict"; 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; }