@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
JavaScript
"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