import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { ServerCapabilities, ConnectionStatus } from "../types.js";
import type { ServerConfig } from "../config/schema.js";
import { createTransport } from "./transports.js";
import { ReconnectHandler, type ReconnectOptions } from "./reconnect.js";
import { ConnectionError } from "../utils/errors.js";
import logger from "../utils/logger.js";
export class ClientConnection {
readonly id: string;
private client: Client | null = null;
private transport: Transport | null = null;
private readonly config: ServerConfig;
private readonly reconnectHandler: ReconnectHandler;
private _status: ConnectionStatus;
private _closing = false;
private _connectingPromise: Promise<void> | null = null;
private _lastActivity: number = Date.now();
private _idleTimeoutMs: number;
private _idleTimer: ReturnType<typeof setTimeout> | null = null;
constructor(id: string, config: ServerConfig, reconnectOptions: ReconnectOptions, idleTimeoutMs = 0) {
this.id = id;
this.config = config;
this.reconnectHandler = new ReconnectHandler(reconnectOptions);
this._idleTimeoutMs = idleTimeoutMs;
this._status = {
id,
connected: false,
idle: true,
};
}
get status(): ConnectionStatus {
return { ...this._status };
}
get isConnected(): boolean {
return this._status.connected;
}
get isIdle(): boolean {
return this._status.idle;
}
getClient(): Client {
if (!this.client || !this._status.connected) {
throw new ConnectionError(this.id, `Server "${this.id}" is not connected`);
}
this.markActive();
return this.client;
}
async ensureConnected(): Promise<void> {
if (this._status.connected) {
this.markActive();
return;
}
if (this._closing) {
throw new ConnectionError(this.id, `Server "${this.id}" is closing`);
}
if (this._connectingPromise) {
await this._connectingPromise;
return;
}
this._connectingPromise = this.connect().finally(() => {
this._connectingPromise = null;
});
await this._connectingPromise;
}
async connect(): Promise<void> {
this._status.idle = false;
logger.info(`Connecting to server: ${this.id}`, { transport: this.config.transport });
try {
this.transport = createTransport(this.id, this.config);
this.client = new Client(
{ name: `mcp-server-hub-${this.id}`, version: "0.1.0" },
{ capabilities: {} }
);
// Mark as disconnected on transport close — no auto-reconnect
this.transport.onclose = () => {
if (!this._closing) {
logger.warn(`Connection lost to server: ${this.id}`);
this._status.connected = false;
this._status.error = "Connection lost";
}
};
await this.client.connect(this.transport);
this._status.connected = true;
this._status.lastConnected = new Date();
this._status.error = undefined;
this.reconnectHandler.reset();
// Fetch capabilities
await this.refreshCapabilities();
logger.info(`Connected to server: ${this.id}`, {
tools: this._status.capabilities?.tools.length ?? 0,
resources: this._status.capabilities?.resources.length ?? 0,
prompts: this._status.capabilities?.prompts.length ?? 0,
});
this.startIdleTimer();
} catch (err) {
const message = (err as Error).message;
this._status.connected = false;
this._status.error = message;
logger.error(`Failed to connect to server: ${this.id}`, { error: message });
throw new ConnectionError(this.id, message);
}
}
async refreshCapabilities(): Promise<ServerCapabilities> {
const client = this.getClient();
const [toolsResult, resourcesResult, promptsResult] = await Promise.allSettled([
client.listTools(),
client.listResources(),
client.listPrompts(),
]);
const capabilities: ServerCapabilities = {
tools: toolsResult.status === "fulfilled" ? toolsResult.value.tools : [],
resources: resourcesResult.status === "fulfilled" ? resourcesResult.value.resources : [],
resourceTemplates: [],
prompts: promptsResult.status === "fulfilled" ? promptsResult.value.prompts : [],
};
// Try fetching resource templates
try {
const templatesResult = await client.listResourceTemplates();
capabilities.resourceTemplates = templatesResult.resourceTemplates;
} catch {
// Not all servers support resource templates
}
this._status.capabilities = capabilities;
return capabilities;
}
private markActive(): void {
this._lastActivity = Date.now();
this.startIdleTimer();
}
private startIdleTimer(): void {
if (this._idleTimer) {
clearTimeout(this._idleTimer);
this._idleTimer = null;
}
if (this._idleTimeoutMs <= 0) {
return;
}
this._idleTimer = setTimeout(() => this.disconnectIdle(), this._idleTimeoutMs);
}
private disconnectIdle(): void {
if (!this._status.connected) {
return;
}
logger.info(`Disconnecting idle server: ${this.id}`);
this.close(true);
}
async close(silent = false): Promise<void> {
this._closing = !silent; // only set permanent closing flag if not silent
if (this._idleTimer) {
clearTimeout(this._idleTimer);
this._idleTimer = null;
}
try {
if (this.transport) {
await this.transport.close();
}
} catch (err) {
if (!silent) {
logger.warn(`Error closing transport for ${this.id}`, { error: (err as Error).message });
}
}
this.client = null;
this.transport = null;
this._status.connected = false;
if (!silent) {
this._closing = true;
}
}
}