UNPKG

@kya-os/mcp-i

Version:

COMING SOON:Production-ready MCP Identity with automatic registration, key rotation, and optimized performance

853 lines 33.2 kB
/** * @kya-os/mcp-i - Optimized MCP Identity with production features * * Enable any MCP server to get a verifiable identity with just 2 lines of code: * * ```typescript * import "@kya-os/mcp-i/auto"; // That's it! Your server now has identity * ``` */ import * as crypto from './crypto.js'; import { StorageFactory } from './storage.js'; import { generateClientInfo } from './platform-info.js'; import { TransportFactory } from './transport.js'; import { LoggerFactory, getLogger } from './logger.js'; import { KeyRotationManager } from './rotation.js'; import { loadIdentityFromEnv, showVercelDeveloperInstructions } from './vercel-adapter.js'; import { pollRegistrationStatus, isAsyncRegistrationResponse } from './polling.js'; // Re-export types and utilities export * from './types.js'; export * from './vercel-adapter.js'; export { RegistryFactory, REGISTRY_TIERS, resolveRegistries } from './registry/index.js'; export { LoggerFactory, ConsoleLogger, SilentLogger } from './logger.js'; export { StorageFactory, MemoryStorage, FileStorage } from './storage.js'; export { TransportFactory, RuntimeDetector } from './transport.js'; export { KeyRotationManager } from './rotation.js'; export { initWithDevExperience, showAgentStatus } from './dev-helper.js'; export { pollRegistrationStatus, isAsyncRegistrationResponse } from './polling.js'; // Global identity instance let globalIdentity = null; export class MCPIdentity { did; publicKey; privateKey; timestampTolerance; enableNonceTracking; usedNonces = new Set(); nonceCleanupInterval; encryptionPassword; decryptedPrivateKey; // Directory preferences directories; // Optimized storage storage; transport; logger; // Key rotation rotationManager; // Precomputed values precomputed; constructor(identity, options = {}) { // Initialize logger first if (options.logger) { LoggerFactory.setLogger(options.logger); } else if (options.logLevel && options.logLevel !== 'silent') { LoggerFactory.setLogger(LoggerFactory.createConsoleLogger(options.logLevel)); } this.logger = getLogger(); // Core identity this.did = identity.did; this.publicKey = identity.publicKey; this.privateKey = identity.privateKey; // Store encryption password for later decryption this.encryptionPassword = options.encryptionPassword; // Security options this.timestampTolerance = options.timestampTolerance || 60000; // 60 seconds this.enableNonceTracking = options.enableNonceTracking !== false; // Directory preferences this.directories = identity.directories || 'verified'; // Initialize storage and transport - allow passing instances for testing this.storage = (typeof options?.storage === 'object' && 'load' in options.storage) ? options.storage : StorageFactory.create({ storage: options.storage, customPath: options.persistencePath, memoryKey: options.memoryKey || this.did, encryptionPassword: options.encryptionPassword }); this.transport = (typeof options?.transport === 'object' && 'post' in options.transport) ? options.transport : TransportFactory.create({ transport: options.transport }); // Precompute values for performance this.precomputed = { did: this.did, publicKey: this.publicKey, didBytes: new TextEncoder().encode(this.did), signatureCache: new Map() }; // Initialize key rotation if not in memory storage if (options.storage !== 'memory') { this.rotationManager = new KeyRotationManager(identity, this.transport, {}); } // Start nonce cleanup if tracking is enabled if (this.enableNonceTracking) { this.startNonceCleanup(); } } /** * Initialize MCP Identity - the main entry point */ static async init(options) { // Configure logger based on logLevel before any logging const logger = options?.logger || getLogger(options?.logLevel); // Return existing global identity if already initialized if (globalIdentity) { return globalIdentity; } // Check for Vercel/serverless environment const isVercel = process.env.VERCEL || process.env.VERCEL_ENV; const isServerless = isVercel || process.env.AWS_LAMBDA_FUNCTION_NAME || process.env.FUNCTIONS_WORKER_RUNTIME; // Emit progress event options?.onProgress?.({ stage: 'checking_existing', progress: 10, message: 'Checking for existing identity...' }); // Try to load from environment variables first (for serverless) let identity = null; if (isServerless || options?.storage === 'memory') { identity = loadIdentityFromEnv(); if (identity) { logger.info('✅ Loaded existing identity from environment variables'); options?.onProgress?.({ stage: 'complete', progress: 100, message: 'Loaded existing identity from environment', data: { did: identity.did } }); } } // Initialize storage - allow passing storage instance for testing const storage = (typeof options?.storage === 'object' && 'load' in options.storage) ? options.storage : StorageFactory.create({ storage: options?.storage, customPath: options?.persistencePath, memoryKey: options?.memoryKey, encryptionPassword: options?.encryptionPassword }); // Try to load from storage if not found in env if (!identity) { identity = await storage.load(); } // Handle existing identity if (identity) { logger.info('Loaded existing identity:', identity.did); options?.onProgress?.({ stage: 'complete', progress: 100, message: 'Loaded existing identity', data: { did: identity.did } }); globalIdentity = new MCPIdentity(identity, options); return globalIdentity; } // No existing identity - need to create new one logger.info('No existing identity found, creating new identity...'); // Initialize transport for registration - allow passing transport instance for testing const transport = (typeof options?.transport === 'object' && 'post' in options.transport) ? options.transport : TransportFactory.create({ transport: options?.transport }); // Always use knowthat.ai as the registry const apiEndpoint = options?.apiEndpoint || 'https://knowthat.ai'; logger.info('Registering with knowthat.ai...'); // Generate keys first logger.info('Generating cryptographic keys...'); options?.onProgress?.({ stage: 'generating_keys', progress: 30, message: 'Generating cryptographic keys...' }); const keyPair = await crypto.generateKeyPair(); options?.onProgress?.({ stage: 'generating_keys', progress: 50, message: 'Keys generated successfully' }); // Prepare registration data with public key const registrationData = { name: options?.name || process.env.MCP_SERVER_NAME || 'Unnamed MCP Server', description: options?.description, repository: options?.repository, publicKey: keyPair.publicKey, directories: options?.directories, isDraft: options?.mode !== 'production' // Default to draft mode for safety }; logger.debug('Registration data:', { name: registrationData.name, hasDescription: !!registrationData.description, hasRepository: !!registrationData.repository, hasPublicKey: !!registrationData.publicKey, directories: registrationData.directories, isDraft: registrationData.isDraft }); let response; try { // Register with knowthat.ai options?.onProgress?.({ stage: 'registering', progress: 60, message: 'Registering with KYA-OS network...' }); response = await autoRegister(transport, { ...registrationData, apiEndpoint, directories: options?.directories, processingMode: options?.processingMode, registryEndpoint: options?.registryEndpoint, onProgress: options?.onProgress }); options?.onProgress?.({ stage: 'registering', progress: 80, message: 'Registration successful' }); } catch (registrationError) { logger.error('Failed to register with knowthat.ai:', registrationError.message); // For development/testing, allow offline mode with a temporary DID if (options?.mode === 'development' || process.env.NODE_ENV === 'development') { logger.warn('Running in offline development mode with temporary identity'); // Generate a temporary development DID const tempSlug = `temp-${Date.now()}-${Math.random().toString(36).substring(7)}`; response = { did: `did:web:localhost:agents:${tempSlug}`, agent: { id: tempSlug, slug: tempSlug, name: registrationData.name, url: `http://localhost:3000/agents/${tempSlug}`, claimUrl: `http://localhost:3000/agents/claim?did=${tempSlug}` }, keys: { publicKey: keyPair.publicKey, privateKey: keyPair.privateKey } }; logger.warn('⚠️ Using temporary development identity. This will not persist across restarts.'); logger.warn('⚠️ To use a permanent identity, ensure you can connect to knowthat.ai'); } else { // In production, registration is required throw registrationError; } } // Create persisted identity identity = { did: response.did, publicKey: keyPair.publicKey, privateKey: keyPair.privateKey, agentId: response.agent.id, agentSlug: response.agent.slug, registeredAt: new Date().toISOString(), directories: options?.directories || 'verified' }; // Save identity options?.onProgress?.({ stage: 'saving', progress: 90, message: 'Saving identity...' }); await storage.save(identity); // Emit completion event with full data options?.onProgress?.({ stage: 'complete', progress: 100, message: 'Identity created successfully', data: { did: identity.did, publicKey: identity.publicKey, agentId: identity.agentId, agentSlug: identity.agentSlug, claimUrl: response.agent.claimUrl } }); // Show enhanced developer instructions for Vercel/serverless if (isServerless) { showVercelDeveloperInstructions(identity, response.agent.claimUrl); } else { logger.info('✅ Success! Your agent has been registered.'); logger.info(`DID: ${response.did}`); logger.info(`Profile: ${response.agent.url}`); // Show claim URL if in draft mode if (response.agent.claimUrl) { logger.info(`Claim your agent: ${response.agent.claimUrl}`); } // Show directory submission info if (options?.directories && options.directories !== 'none') { const dirMessage = options.directories === 'verified' ? 'Your agent will be submitted to all verified directories' : `Your agent will be submitted to: ${options.directories.join(', ')}`; logger.info(dirMessage); } } // Create MCPIdentity instance globalIdentity = new MCPIdentity(identity, options); return globalIdentity; } /** * Enable automatic key rotation */ async enableAutoRotation(policy) { if (!this.rotationManager) { throw new Error('Key rotation not available in memory storage mode'); } this.rotationManager.setupAutoRotation((result) => { if (result.success) { this.logger.info('Keys rotated successfully'); // Update storage with new keys this.persistIdentity(); } else { this.logger.error('Key rotation failed:', result.error); } }); } /** * Manually rotate keys */ async rotateKeys(reason) { if (!this.rotationManager) { throw new Error('Key rotation not available in memory storage mode'); } const result = await this.rotationManager.rotateKeys(reason); if (result.success) { await this.persistIdentity(); } return result; } /** * Check key health */ checkKeyHealth() { if (!this.rotationManager) { return null; } return this.rotationManager.checkKeyHealth(); } /** * Get decrypted private key (lazy decryption) */ async getPrivateKey() { if (this.decryptedPrivateKey) { return this.decryptedPrivateKey; } if (this.encryptionPassword && this.privateKey.startsWith('enc:')) { try { this.decryptedPrivateKey = await crypto.decrypt(this.privateKey, this.encryptionPassword); this.logger.debug('Private key decrypted successfully'); return this.decryptedPrivateKey; } catch (error) { this.logger.error('Failed to decrypt private key:', error); throw new Error('Invalid encryption password'); } } return this.privateKey; } /** * Sign a message with caching */ async sign(message) { const messageStr = typeof message === 'string' ? message : message.toString('base64'); // Check cache const cached = this.precomputed.signatureCache.get(messageStr); if (cached) { return cached; } // Get decrypted private key const privateKey = await this.getPrivateKey(); // Sign and cache const signature = await crypto.sign(message, privateKey); // Limit cache size if (this.precomputed.signatureCache.size > 100) { const firstKey = this.precomputed.signatureCache.keys().next().value; if (firstKey) { this.precomputed.signatureCache.delete(firstKey); } } this.precomputed.signatureCache.set(messageStr, signature); // Track signature count for rotation if (this.rotationManager) { this.rotationManager.incrementSignatureCount(); } return signature; } /** * Request edit access with claim URL support */ async requestEditAccess() { const timestamp = Date.now(); const message = `edit-request:${this.did}:knowthat.ai:${timestamp}`; const signature = await this.sign(message); // Construct edit URL with proof of ownership const baseUrl = 'https://knowthat.ai'; const editUrl = new URL(`${baseUrl}/agents/edit`); editUrl.searchParams.set('did', this.did); editUrl.searchParams.set('timestamp', timestamp.toString()); editUrl.searchParams.set('signature', signature); // Also generate claim URL if agent is not yet claimed const claimUrl = new URL(`${baseUrl}/agents/claim`); claimUrl.searchParams.set('did', this.did); claimUrl.searchParams.set('timestamp', timestamp.toString()); claimUrl.searchParams.set('signature', signature); return { editUrl: editUrl.toString(), claimUrl: claimUrl.toString() }; } /** * Verify a signature */ async verify(message, signature, publicKey) { return crypto.verify(message, signature, publicKey || this.publicKey); } /** * Respond to an MCP-I challenge */ async respondToChallenge(challenge) { // Validate timestamp to prevent replay attacks const now = Date.now(); const challengeAge = now - challenge.timestamp; if (challengeAge > this.timestampTolerance) { throw new Error('Challenge expired'); } if (challengeAge < 0) { throw new Error('Challenge timestamp is in the future'); } // Check for nonce reuse if tracking is enabled if (this.enableNonceTracking) { if (this.usedNonces.has(challenge.nonce)) { throw new Error('Nonce already used'); } this.usedNonces.add(challenge.nonce); } // Create the message to sign const messageComponents = [ challenge.nonce, challenge.timestamp.toString(), this.did, challenge.verifier_did || '', (challenge.scope || []).join(',') ]; const message = messageComponents.join(':'); // Sign the challenge const signature = await this.sign(message); // Return the response return { did: this.did, signature, timestamp: now, nonce: challenge.nonce, publicKey: this.publicKey }; } /** * Get MCP-I capabilities for advertisement */ getCapabilities() { return { version: '1.0', did: this.did, publicKey: this.publicKey, conformanceLevel: 2, // Level 2: Full crypto with challenge-response handshakeSupported: true, handshakeEndpoint: '/_mcp-i/handshake', verificationEndpoint: `https://knowthat.ai/api/agents/${this.did}/verify`, registry: 'knowthat.ai' }; } /** * Sign an MCP response with identity metadata */ async signResponse(response) { const timestamp = new Date().toISOString(); const responseWithIdentity = { ...response, _mcp_identity: { did: this.did, signature: '', // Will be filled below timestamp, conformanceLevel: 2 } }; // Sign the response content (excluding the signature field) const contentToSign = JSON.stringify({ ...response, _mcp_identity: { did: this.did, timestamp, conformanceLevel: 2 } }); responseWithIdentity._mcp_identity.signature = await this.sign(contentToSign); return responseWithIdentity; } /** * Generate a new nonce for challenges */ static generateNonce() { return crypto.generateNonceSync(); } /** * Get directory preferences */ getDirectories() { return this.directories; } /** * Clean up old nonces periodically to prevent memory leaks */ startNonceCleanup() { // Clean up nonces older than 2x the timestamp tolerance this.nonceCleanupInterval = setInterval(() => { // In a production system, you'd track nonce timestamps // For now, we'll clear all nonces periodically if (this.usedNonces.size > 10000) { this.usedNonces.clear(); } }, this.timestampTolerance * 2); } /** * Clean up resources */ destroy() { if (this.nonceCleanupInterval) { clearInterval(this.nonceCleanupInterval); } this.usedNonces.clear(); this.precomputed.signatureCache.clear(); } /** * Helper to extract agent name from DID */ extractAgentName() { // Try to get from persisted data or environment return process.env.MCP_SERVER_NAME || 'Unknown Agent'; } /** * Helper to extract agent ID */ extractAgentId() { return process.env.AGENT_ID || ''; } /** * Helper to extract agent slug */ extractAgentSlug() { const parts = this.did.split(':'); return parts[parts.length - 1]; } /** * Persist full identity (for key rotation) */ async persistIdentity() { try { const identity = { did: this.did, publicKey: this.publicKey, privateKey: this.privateKey, agentId: this.extractAgentId(), agentSlug: this.extractAgentSlug(), registeredAt: new Date().toISOString(), directories: this.directories }; await this.storage.save(identity); } catch (error) { this.logger.error('Failed to persist identity:', error); } } } /** * Enable MCP Identity for any MCP server */ export async function enableMCPIdentity(options) { const identity = await MCPIdentity.init(options); // Try to patch MCP Server if available try { patchMCPServer(identity); } catch (error) { const logger = getLogger(); logger.debug('MCP Server not found, identity initialized for manual use'); } return identity; } /** * Create MCP middleware for manual integration */ export function createMCPMiddleware(identity) { return (server) => { // Validate server object if (!server || typeof server !== 'object') { const logger = getLogger(); logger.warn('Invalid MCP Server object passed to middleware'); return; } // Check if methods exist before patching if (!server.setRequestHandler || typeof server.setRequestHandler !== 'function') { const logger = getLogger(); logger.warn('MCP Server missing setRequestHandler method'); return; } // Store original methods const originalSetRequestHandler = server.setRequestHandler.bind(server); const originalConnect = server.connect ? server.connect.bind(server) : null; // Patch setRequestHandler to wrap all responses server.setRequestHandler = function (method, handler) { // Add defensive check for undefined method if (!method || typeof method !== 'string') { const logger = getLogger(); logger.warn('setRequestHandler called with invalid method:', method); return originalSetRequestHandler.call(this, method, handler); } const wrappedHandler = async (...args) => { const result = await handler(...args); // If result has content, sign it if (result && typeof result === 'object' && 'content' in result) { return await identity.signResponse(result); } return result; }; return originalSetRequestHandler(method, wrappedHandler); }; // Patch connect to advertise MCP-I capabilities if (originalConnect) { server.connect = async function (transport) { // Add MCP-I capabilities to server info if (this.serverInfo && this.serverInfo.capabilities) { this.serverInfo.capabilities['mcp-i'] = identity.getCapabilities(); } // Set up MCP-I handshake handler this.setRequestHandler('mcp-i/challenge', async (request) => { return identity.respondToChallenge(request.params); }); // Call original connect return originalConnect.call(this, transport); }; } }; } /** * Patch the MCP Server to automatically add identity */ function patchMCPServer(identity) { try { // Try to import the MCP SDK const MCPModule = require('@modelcontextprotocol/sdk/server/index.js'); const OriginalServer = MCPModule.Server; if (!OriginalServer) { return; } // Apply middleware const middleware = createMCPMiddleware(identity); // Patch the constructor const OriginalConstructor = OriginalServer; MCPModule.Server = function (...args) { const instance = new OriginalConstructor(...args); try { middleware(instance, identity); } catch (error) { const logger = getLogger(); logger.warn('Failed to apply MCP-I middleware:', error); } return instance; }; // Copy static properties Object.setPrototypeOf(MCPModule.Server, OriginalConstructor); Object.setPrototypeOf(MCPModule.Server.prototype, OriginalConstructor.prototype); const logger = getLogger(); logger.info('✨ MCP Server patched - all responses will be automatically signed'); } catch (error) { // MCP SDK not available, that's okay } } // Helper function to determine which endpoint to use function determineEndpoint(options) { // Explicit configuration takes precedence if (options.registryEndpoint === 'cli') return true; if (options.registryEndpoint === 'auto-register') return false; // Auto mode: use CLI endpoint if we're in a context that suggests CLI usage if (options.registryEndpoint === 'auto' || !options.registryEndpoint) { // Check if we have a progress callback (indicates CLI usage) if (options.onProgress) return true; // Check if processingMode is explicitly set to sync (CLI preference) if (options.processingMode === 'sync') return true; // Check if we're in a TTY environment (interactive terminal) if (process.stdout?.isTTY) return true; } return false; } // Helper function for CLI registration async function registerViaCLI(transport, options) { const logger = getLogger(); logger.debug('Using fast CLI registration endpoint'); const response = await transport.post(`${options.apiEndpoint}/api/agents/cli-register`, { name: options.name, description: options.description, repository: options.repository, publicKey: options.publicKey }, { timeout: 5000, // 5 second timeout for CLI endpoint headers: { 'Content-Type': 'application/json', 'User-Agent': `@kya-os/mcp-i/${generateClientInfo().sdkVersion}` } }); logger.debug(`CLI registration completed in ${response.data.responseTime}ms`); // Report progress options.onProgress?.({ stage: 'registering', progress: 80, message: 'Registration successful', data: { did: response.data.did, claimUrl: response.data.claimUrl } }); // Transform to match AutoRegisterResponse format return { success: response.data.success, did: response.data.did, agent: { id: response.data.agent.id, slug: response.data.agent.slug, name: response.data.agent.name, url: response.data.agent.url, claimUrl: response.data.claimUrl }, keys: response.data.keys || { publicKey: options.publicKey || '', privateKey: undefined } }; } // Helper function for auto-registration (now supports async v2.0 API and CLI endpoint) async function autoRegister(transport, options) { const logger = getLogger(); // Determine which endpoint to use const shouldUseCLI = determineEndpoint(options); // Try CLI endpoint first if appropriate if (shouldUseCLI) { try { return await registerViaCLI(transport, options); } catch (error) { logger.debug(`CLI registration failed: ${error.message}, falling back to auto-register`); // Fall through to auto-register } } // Use auto-register endpoint try { const requestBody = { metadata: { name: options.name, description: options.description, repository: options.repository, publicKey: options.publicKey, version: '1.0.0', isDraft: options.isDraft, directories: options.directories || 'verified' // Default to all verified directories }, clientInfo: generateClientInfo({ processingMode: options.processingMode === 'auto' ? undefined : options.processingMode, customMetadata: { source: 'auto-register' } }) }; logger.debug('Sending registration request to:', `${options.apiEndpoint}/api/agents/auto-register`); const response = await transport.post(`${options.apiEndpoint}/api/agents/auto-register`, requestBody, { timeout: 30000, headers: { 'Content-Type': 'application/json', 'User-Agent': `@kya-os/mcp-i/${requestBody.clientInfo.sdkVersion}` } }); logger.debug('Registration response received:', { status: response.status, hasData: !!response.data, isAsync: response.status === 202 }); // Check if we got an async response (v2.0 API) if (response.status === 202 && isAsyncRegistrationResponse(response.data)) { logger.info('Registration submitted, waiting for completion...'); // Report progress options.onProgress?.({ stage: 'registering', progress: 70, message: 'Registration submitted, processing...', data: { jobId: response.data.jobId } }); // Poll for completion using simple 2-second intervals const result = await pollRegistrationStatus(response.data.status, // Status URL transport, { pollInterval: 2000, // Fixed 2-second intervals maxPollingTime: 60000, // 60 seconds max logger, onProgress: (message, progress) => { // Update progress during polling options.onProgress?.({ stage: 'registering', progress: 70 + (progress || 0) * 0.1, // 70-80% during polling message }); } }); logger.debug('Registration completed after polling:', { hasDid: !!result.did, hasAgent: !!result.agent }); return result; } // Handle sync response (backward compatibility) logger.debug('Got synchronous registration response'); return response.data; } catch (error) { logger.error('Registration failed:', { message: error.message, status: error.status, response: error.response }); if (error.message?.includes('429')) { throw new Error('Rate limit exceeded. Please try again later.'); } // Provide more helpful error messages if (error.message?.includes('500')) { throw new Error('Server error during registration. This might be a temporary issue.\n' + 'Please try again in a few moments, or register manually at https://knowthat.ai/submit-agent'); } if (error.message?.includes('400')) { throw new Error('Invalid registration data. Please ensure all required fields are provided:\n' + '- name: A unique name for your agent\n' + '- publicKey: Generated automatically (check if generation succeeded)'); } throw new Error(error.message || 'Failed to auto-register agent'); } } //# sourceMappingURL=index.js.map