UNPKG

@ankimcp/anki-mcp-server

Version:

Model Context Protocol server for Anki - enables AI assistants to interact with your Anki flashcards

460 lines • 19.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.TunnelClient = exports.TunnelClientError = void 0; const events_1 = require("events"); const ws_1 = __importDefault(require("ws")); const common_1 = require("@nestjs/common"); const ky_1 = require("ky"); const tunnel_protocol_1 = require("./tunnel.protocol"); const device_flow_service_1 = require("./device-flow.service"); const version_1 = require("../version"); class TunnelClientError extends Error { code; originalError; constructor(message, code, originalError) { super(message); this.code = code; this.originalError = originalError; this.name = "TunnelClientError"; } } exports.TunnelClientError = TunnelClientError; const PRE_ESTABLISHED_AUTH_FAILURE_CODE = "tunnel_auth_failed"; class TunnelClient extends events_1.EventEmitter { mcpHandler; credentialsService; deviceFlowService; tunnelUrl; logger = new common_1.Logger(TunnelClient.name); ws = null; reconnectAttempts = 0; reconnectTimer = null; isConnecting = false; isManualDisconnect = false; isReconnecting = false; currentTunnelUrl = null; credentials = null; connectionTimeout = null; lastServerPingTime = 0; healthCheckInterval = null; pongTimeout = null; constructor(mcpHandler, credentialsService, deviceFlowService, tunnelUrl) { super(); this.mcpHandler = mcpHandler; this.credentialsService = credentialsService; this.deviceFlowService = deviceFlowService; this.tunnelUrl = tunnelUrl; } async connect(initialCredentials) { if (this.isConnecting) { throw new TunnelClientError("Connection already in progress", "connection_in_progress"); } if (this.ws?.readyState === ws_1.default.OPEN) { throw new TunnelClientError("Already connected", "already_connected"); } this.isManualDisconnect = false; this.isConnecting = true; try { this.credentials = initialCredentials ?? (await this.credentialsService.loadCredentials()); if (!this.credentials) { throw new TunnelClientError("No credentials found. Please run login first.", "no_credentials"); } if (this.credentialsService.isTokenExpired(this.credentials)) { this.logger.log("Access token expired, refreshing..."); await this.refreshTokenAndSave(); } const tunnelUrl = await this.establishWithAuthRetry(this.tunnelUrl); this.reconnectAttempts = 0; this.isConnecting = false; return tunnelUrl; } catch (error) { this.isConnecting = false; throw this.wrapError(error, "connect"); } } disconnect() { this.logger.log("Disconnecting from tunnel (manual)"); this.isManualDisconnect = true; this.clearReconnectTimer(); this.clearConnectionTimeout(); this.stopHealthCheck(); if (this.ws) { this.ws.close(tunnel_protocol_1.TunnelCloseCodes.NORMAL, "Client disconnect"); this.ws = null; } this.currentTunnelUrl = null; } isConnected() { return this.ws?.readyState === ws_1.default.OPEN; } getTunnelUrl() { return this.currentTunnelUrl; } async establishConnection(url) { return new Promise((resolve, reject) => { if (!this.credentials) { return reject(new TunnelClientError("No credentials available", "no_credentials")); } this.logger.log(`Connecting to tunnel server: ${url}`); this.ws = new ws_1.default(url, { headers: { Authorization: `Bearer ${this.credentials.access_token}`, "x-ankimcp-client-type": "cli", "x-ankimcp-client-version": (0, version_1.getVersion)(), }, }); this.connectionTimeout = setTimeout(() => { if (this.ws?.readyState !== ws_1.default.OPEN) { this.ws?.terminate(); reject(new TunnelClientError("Connection timeout", "connection_timeout")); } }, tunnel_protocol_1.TUNNEL_DEFAULTS.CONNECTION_TIMEOUT); this.ws.once("open", () => { this.clearConnectionTimeout(); this.logger.log("WebSocket connected, waiting for tunnel_established"); }); this.ws.on("pong", () => this.handlePong()); this.ws.on("message", (data) => { try { const message = JSON.parse(data.toString()); if (message.type === "tunnel_established" && this.currentTunnelUrl === null) { const establishedMsg = message; this.currentTunnelUrl = establishedMsg.url; this.logger.log(`Tunnel established: ${establishedMsg.url}`); this.startHealthCheck(); this.emit("connected"); this.emit("tunnel_url", establishedMsg.url); resolve(establishedMsg.url); } else { this.handleMessage(message).catch((error) => { this.logger.error("Error handling message:", error); this.emit("error", new TunnelClientError("Message handling failed", "message_error", error)); }); } } catch (error) { this.logger.error("Failed to parse message:", error); this.emit("error", new TunnelClientError("Invalid message format", "parse_error", error)); } }); this.ws.on("close", (code, reason) => { this.clearConnectionTimeout(); this.stopHealthCheck(); const reasonStr = reason.toString(); this.logger.log(`WebSocket closed: ${code} - ${reasonStr}`); const wasEstablished = this.currentTunnelUrl !== null; this.currentTunnelUrl = null; const willReconnect = !this.isManualDisconnect && !this.isPermanentCloseCode(code) && this.reconnectAttempts < tunnel_protocol_1.TUNNEL_DEFAULTS.RECONNECT_MAX_ATTEMPTS; this.emit("disconnected", code, reasonStr, willReconnect); const isAuthClose = code === tunnel_protocol_1.TunnelCloseCodes.AUTH_FAILED || code === tunnel_protocol_1.TunnelCloseCodes.TUNNEL_AUTH_FAILED; if (!wasEstablished) { reject(new TunnelClientError(`Connection closed before tunnel established (code ${code})${reasonStr ? `: ${reasonStr}` : ""}`, isAuthClose ? PRE_ESTABLISHED_AUTH_FAILURE_CODE : "connection_closed")); } const isFirstConnectPreEstablish = !wasEstablished && !this.isReconnecting; if (!this.isManualDisconnect && !isFirstConnectPreEstablish) { this.handleReconnection(code); } }); this.ws.on("error", (error) => { this.logger.error("WebSocket error:", error); this.emit("error", new TunnelClientError("WebSocket error", "websocket_error", error)); if (this.currentTunnelUrl === null) { reject(new TunnelClientError(`Failed to connect: ${error.message}`, "connection_failed", error)); } }); }); } async establishWithAuthRetry(url) { if (this.isReconnecting) { return this.establishConnection(url); } try { return await this.establishConnection(url); } catch (error) { if (!this.isAuthFailure(error) || this.isManualDisconnect) { throw error; } this.logger.log("Auth failed before tunnel established — refreshing token and retrying once"); await this.refreshTokenAndSave(); if (this.isManualDisconnect) { throw error; } try { return await this.establishConnection(url); } catch (retryError) { if (this.isAuthFailure(retryError)) { throw new TunnelClientError("Session expired. Please run `ankimcp --login` to re-authenticate.", "session_expired", retryError); } throw retryError; } } } isAuthFailure(error) { return (error instanceof TunnelClientError && error.code === PRE_ESTABLISHED_AUTH_FAILURE_CODE); } async handleMessage(message) { switch (message.type) { case "request": await this.handleRequest(message); break; case "ping": this.handlePing(message); break; case "error": this.handleError(message); break; default: this.logger.warn(`Unknown message type: ${message.type}`); } } async handleRequest(message) { this.logger.debug(`Handling request ${message.requestId}: ${message.method} ${message.path}`); this.emit("request", message.requestId, message); try { const bodyStr = message.body ?? ""; const response = await this.mcpHandler.handle({ method: message.method, path: message.path, headers: message.headers, body: bodyStr, }); this.sendResponse({ type: "response", requestId: message.requestId, statusCode: response.status, headers: response.headers || {}, body: response.body, }); } catch (error) { this.logger.error(`Error handling request ${message.requestId}:`, error); const { status, code, message: errorMessage, } = this.mapHandlerError(error); this.sendResponse({ type: "response", requestId: message.requestId, statusCode: status, headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: null, error: { code, message: errorMessage }, }), }); } } mapHandlerError(error) { const raw = error instanceof Error ? error.message : String(error); if (/timeout/i.test(raw)) { return { status: 504, code: -32004, message: "Request to CLI timed out" }; } if (/closed|not connected/i.test(raw)) { return { status: 503, code: -32005, message: "Tunnel connection is not available", }; } return { status: 500, code: -32006, message: "Failed to forward request to CLI", }; } handlePing(message) { this.lastServerPingTime = Date.now(); this.logger.debug("Received ping, sending pong"); this.sendPong({ type: "pong", timestamp: message.timestamp }); } handleError(message) { this.logger.warn(`Server error: ${message.code} - ${message.message}`); this.emit("error", new TunnelClientError(message.message, message.code)); } sendResponse(message) { if (this.ws?.readyState === ws_1.default.OPEN) { this.ws.send(JSON.stringify(message)); } else { this.logger.error("Cannot send response: WebSocket not open"); } } sendPong(message) { if (this.ws?.readyState === ws_1.default.OPEN) { this.ws.send(JSON.stringify(message)); } else { this.logger.error("Cannot send pong: WebSocket not open"); } } isPermanentCloseCode(code) { return (code === tunnel_protocol_1.TunnelCloseCodes.NORMAL || code === tunnel_protocol_1.TunnelCloseCodes.ACCOUNT_DELETED || code === tunnel_protocol_1.TunnelCloseCodes.SESSION_REPLACED || code === tunnel_protocol_1.TunnelCloseCodes.TOKEN_REVOKED || code === tunnel_protocol_1.TunnelCloseCodes.IDLE_TIMEOUT); } handleReconnection(closeCode) { this.clearReconnectTimer(); if (this.isPermanentCloseCode(closeCode)) { switch (closeCode) { case tunnel_protocol_1.TunnelCloseCodes.ACCOUNT_DELETED: this.logger.warn("Account deleted, not reconnecting"); break; case tunnel_protocol_1.TunnelCloseCodes.SESSION_REPLACED: this.logger.warn("Disconnected: another device connected. Run `ankimcp --tunnel` to reconnect."); break; case tunnel_protocol_1.TunnelCloseCodes.IDLE_TIMEOUT: this.logger.warn("Disconnected after inactivity (free tier). Run `ankimcp --tunnel` to reconnect."); break; case tunnel_protocol_1.TunnelCloseCodes.TOKEN_REVOKED: this.emit("error", new TunnelClientError("Token was revoked. Please run `ankimcp --login` to re-authenticate.", "session_expired")); break; default: this.logger.log("Normal closure, not reconnecting"); } return; } if (this.reconnectAttempts >= tunnel_protocol_1.TUNNEL_DEFAULTS.RECONNECT_MAX_ATTEMPTS) { this.logger.error(`Max reconnection attempts (${tunnel_protocol_1.TUNNEL_DEFAULTS.RECONNECT_MAX_ATTEMPTS}) reached, giving up`); this.emit("error", new TunnelClientError("Max reconnection attempts reached", "max_reconnect_attempts")); return; } const baseDelay = tunnel_protocol_1.TUNNEL_DEFAULTS.RECONNECT_INITIAL_DELAY; const maxDelay = tunnel_protocol_1.TUNNEL_DEFAULTS.RECONNECT_MAX_DELAY; const exponentialDelay = baseDelay * Math.pow(2, this.reconnectAttempts); const jitter = Math.random() * 0.3 * exponentialDelay; const delay = Math.min(exponentialDelay + jitter, maxDelay); this.reconnectAttempts++; this.logger.log(`Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${tunnel_protocol_1.TUNNEL_DEFAULTS.RECONNECT_MAX_ATTEMPTS})`); const isAuthError = closeCode === tunnel_protocol_1.TunnelCloseCodes.AUTH_FAILED || closeCode === tunnel_protocol_1.TunnelCloseCodes.TUNNEL_AUTH_FAILED; this.reconnectTimer = setTimeout(async () => { this.isReconnecting = true; try { if (isAuthError) { this.logger.log("Auth error detected, refreshing token before reconnect"); await this.refreshTokenAndSave(); } await this.connect(); } catch (error) { this.logger.error("Reconnection failed:", error); if (error instanceof TunnelClientError && error.code === "session_expired") { this.emit("error", error); } else { this.emit("error", new TunnelClientError("Reconnection failed", "reconnect_failed", error)); } } finally { this.isReconnecting = false; } }, delay); } startHealthCheck() { this.stopHealthCheck(); this.lastServerPingTime = Date.now(); this.healthCheckInterval = setInterval(() => { this.checkConnectionHealth(); }, tunnel_protocol_1.TUNNEL_DEFAULTS.HEARTBEAT_INTERVAL); } stopHealthCheck() { if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval); this.healthCheckInterval = null; } if (this.pongTimeout) { clearTimeout(this.pongTimeout); this.pongTimeout = null; } } checkConnectionHealth() { if (this.ws?.readyState !== ws_1.default.OPEN) { return; } const timeSinceServerPing = Date.now() - this.lastServerPingTime; const maxSilence = tunnel_protocol_1.TUNNEL_DEFAULTS.HEARTBEAT_INTERVAL * 2; if (timeSinceServerPing > maxSilence) { this.logger.error(`Connection appears dead: no server ping in ${Math.round(timeSinceServerPing / 1000)}s`); this.ws.terminate(); return; } this.logger.debug("Sending health check ping"); this.ws.ping(); if (this.pongTimeout) { clearTimeout(this.pongTimeout); } this.pongTimeout = setTimeout(() => { this.logger.error(`Connection dead: no pong received in ${tunnel_protocol_1.TUNNEL_DEFAULTS.HEARTBEAT_TIMEOUT}ms`); this.ws?.terminate(); }, tunnel_protocol_1.TUNNEL_DEFAULTS.HEARTBEAT_TIMEOUT); } handlePong() { this.logger.debug("Received pong (health check)"); if (this.pongTimeout) { clearTimeout(this.pongTimeout); this.pongTimeout = null; } } async refreshTokenAndSave() { if (!this.credentials) { throw new TunnelClientError("No credentials to refresh", "no_credentials"); } try { const tokenResponse = await this.deviceFlowService.refreshToken(this.credentials.refresh_token); const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(); this.credentials = { ...this.credentials, access_token: tokenResponse.access_token, refresh_token: tokenResponse.refresh_token, expires_at: expiresAt, }; await this.credentialsService.saveCredentials(this.credentials); this.logger.log("Access token refreshed and saved"); } catch (error) { if (error instanceof device_flow_service_1.DeviceFlowError || error instanceof ky_1.HTTPError) { throw new TunnelClientError("Session expired. Please run `ankimcp --login` to re-authenticate.", "session_expired", error); } throw error; } } clearReconnectTimer() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } clearConnectionTimeout() { if (this.connectionTimeout) { clearTimeout(this.connectionTimeout); this.connectionTimeout = null; } } wrapError(error, operation) { if (error instanceof TunnelClientError) { return error; } if (error instanceof device_flow_service_1.DeviceFlowError) { return new TunnelClientError(`${operation} failed: ${error.message}`, error.code, error); } return new TunnelClientError(`${operation} failed: ${error instanceof Error ? error.message : String(error)}`, "unknown_error", error); } } exports.TunnelClient = TunnelClient; //# sourceMappingURL=tunnel.client.js.map