@toolplex/client
Version:
The official ToolPlex client for AI agent tool discovery and execution
418 lines (417 loc) • 17.4 kB
JavaScript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport, } from "@modelcontextprotocol/sdk/client/stdio.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import * as fs from "fs/promises";
import * as path from "path";
import which from "which";
import { FileLogger } from "../shared/fileLogger.js";
import envPaths from "env-paths";
import { getEnhancedPath } from "../shared/enhancedPath.js";
const logger = FileLogger;
export class ServerManager {
constructor() {
this.config = {};
// Track ongoing installations to prevent race conditions
this.installationPromises = new Map();
// Add a file lock mechanism to prevent concurrent writes
this.configLock = Promise.resolve();
this.sessions = new Map();
this.tools = new Map();
this.serverNames = new Map();
const paths = envPaths("ToolPlex", { suffix: "" });
this.configPath = path.join(paths.data, "server_config.json");
}
async loadConfig() {
try {
const data = await fs.readFile(this.configPath, "utf-8");
await logger.debug(`Loaded config from ${this.configPath}`);
const allConfig = JSON.parse(data);
// Validate the config structure
if (typeof allConfig !== "object" || allConfig === null) {
await logger.warn("Invalid config format, using empty config");
return {};
}
const config = {};
for (const [serverId, serverConfig] of Object.entries(allConfig)) {
if (typeof serverConfig === "object" && serverConfig !== null) {
config[serverId] = serverConfig;
}
else {
await logger.warn(`Invalid server config for ${serverId}, skipping`);
}
}
return config;
}
catch (error) {
let errorMessage = "Unknown error occurred";
if (error instanceof Error)
errorMessage = error.message;
await logger.debug(`No existing config found at ${this.configPath}: ${errorMessage}`);
// If the file exists but is malformed, back it up and start fresh
try {
await fs.access(this.configPath);
const backupPath = this.configPath + ".backup." + Date.now();
await fs.copyFile(this.configPath, backupPath);
await logger.warn(`Malformed config backed up to ${backupPath}`);
}
catch {
// File doesn't exist, which is fine
}
return {};
}
}
async saveConfig(config) {
// Use a lock to prevent concurrent writes
this.configLock = this.configLock.then(async () => {
let existingConfig = {};
try {
const data = await fs.readFile(this.configPath, "utf-8");
existingConfig = JSON.parse(data);
// Validate the existing config structure
if (typeof existingConfig !== "object" || existingConfig === null) {
await logger.warn("Invalid existing config format, using empty config");
existingConfig = {};
}
}
catch (error) {
// Config file doesn't exist or is invalid, use empty config
await logger.debug(`Could not read existing config: ${error}`);
existingConfig = {};
}
const mergedConfig = {
...existingConfig,
...config,
};
// Validate the merged config before writing
try {
const testJson = JSON.stringify(mergedConfig, null, 2);
JSON.parse(testJson); // This will throw if invalid
}
catch (error) {
throw new Error(`Invalid config structure would be written: ${error}`);
}
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
// Write to a temporary file first, then rename (atomic operation)
const tempPath = this.configPath + ".tmp";
try {
await fs.writeFile(tempPath, JSON.stringify(mergedConfig, null, 2));
await fs.rename(tempPath, this.configPath);
await logger.debug(`Saved config to ${this.configPath}`);
}
catch (error) {
// Clean up temp file if it exists
try {
await fs.unlink(tempPath);
}
catch {
// Ignore cleanup errors
}
throw error;
}
});
await this.configLock;
}
async initialize() {
await this.cleanup();
const succeeded = [];
const failures = {};
try {
await logger.info("Initializing ServerManager");
this.config = await this.loadConfig();
await logger.debug(`Loaded ${Object.keys(this.config).length} server configs`);
for (const [serverId, serverConfig] of Object.entries(this.config)) {
succeeded.push({
server_id: serverId,
server_name: serverConfig.server_name ?? serverId,
description: serverConfig.description ?? "",
});
}
}
catch (err) {
const errorMessage = err.message || String(err);
await logger.error(`Failed to initialize: ${errorMessage}`);
}
return { succeeded, failures };
}
async getServerName(serverId) {
await logger.debug(`Getting name for server ${serverId}`);
return this.serverNames.get(serverId) || serverId;
}
async connectWithHandshakeTimeout(client, transport, ms = 30000) {
let connectTimeout;
let listToolsTimeout;
try {
// Race connect() with timeout
await Promise.race([
client.connect(transport),
new Promise((_, reject) => {
connectTimeout = setTimeout(() => reject(new Error(`connect() timed out in ${ms} ms`)), ms);
}),
]);
// Clear the connect timeout since it succeeded
clearTimeout(connectTimeout);
// Race listTools() with timeout
const result = await Promise.race([
client.listTools(),
new Promise((_, reject) => {
listToolsTimeout = setTimeout(() => reject(new Error(`listTools() timed out in ${ms} ms`)), ms);
}),
]);
clearTimeout(listToolsTimeout);
return result;
}
catch (error) {
// Clean up timeouts on error
if (connectTimeout)
clearTimeout(connectTimeout);
if (listToolsTimeout)
clearTimeout(listToolsTimeout);
throw error;
}
}
async install(serverId, serverName, description, config) {
await logger.info(`Installing server ${serverId} (${serverName})`);
await logger.debug(`Server config: ${JSON.stringify(config)}`);
// Check if there's already an ongoing installation for this server
const existingInstall = this.installationPromises.get(serverId);
if (existingInstall) {
await logger.debug(`Installation already in progress for ${serverId}, waiting...`);
await existingInstall;
return;
}
// Create the installation promise
const installPromise = this.performInstall(serverId, serverName, description, config);
this.installationPromises.set(serverId, installPromise);
try {
await installPromise;
}
finally {
// Always clean up the promise from the map
this.installationPromises.delete(serverId);
}
}
async performInstall(serverId, serverName, description, config) {
if (this.sessions.has(serverId)) {
await logger.debug(`Server ${serverId} already exists, removing first`);
await this.removeServer(serverId);
}
let transport;
if (config.transport === "sse") {
if (!config.url)
throw new Error("URL is required for SSE transport");
transport = new SSEClientTransport(new URL(config.url));
}
else if (config.transport === "stdio") {
if (!config.command)
throw new Error("Command is required for stdio transport");
const enhancedPath = getEnhancedPath();
let resolvedCommand = which.sync(config.command, {
path: enhancedPath,
nothrow: true,
});
if (!resolvedCommand) {
// Fallback to supplied command
resolvedCommand = config.command;
}
const serverParams = {
command: resolvedCommand,
args: config.args || [],
env: {
...process.env,
PATH: enhancedPath,
...(config.env || {}),
},
stderr: "pipe",
};
transport = new StdioClientTransport(serverParams);
}
else {
throw new Error(`Invalid transport type: ${config.transport}`);
}
const client = new Client({ name: serverId, version: "1.0.0" }, { capabilities: { prompts: {}, resources: {}, tools: {} } });
try {
const toolsResponse = await this.connectWithHandshakeTimeout(client, transport, 30000);
const tools = toolsResponse.tools || [];
this.sessions.set(serverId, client);
this.tools.set(serverId, tools);
this.serverNames.set(serverId, serverName);
const updatedEntry = {
...config,
server_name: serverName,
description,
};
const currentConfig = await this.loadConfig();
await this.saveConfig({
...currentConfig,
[serverId]: updatedEntry,
});
this.config[serverId] = updatedEntry;
await logger.info(`Successfully installed server ${serverId} with ${tools.length} tools`);
}
catch (err) {
// Clean up on failure
this.sessions.delete(serverId);
this.tools.delete(serverId);
this.serverNames.delete(serverId);
// Close transport if it was created
if (client && client.transport) {
try {
await client.transport.close();
}
catch (closeErr) {
await logger.warn(`Failed to close transport during cleanup: ${closeErr}`);
}
}
throw err;
}
}
async callTool(serverId, toolName,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
arguments_, timeout = 60000) {
// Check for ongoing installation before attempting to install
const existingInstall = this.installationPromises.get(serverId);
if (existingInstall) {
await logger.debug(`Waiting for ongoing installation of ${serverId}...`);
await existingInstall;
}
if (!this.sessions.has(serverId)) {
const config = this.config[serverId];
if (!config)
throw new Error(`No config found for server ${serverId}`);
const name = config.server_name || serverId;
const description = config.description || "";
await this.install(serverId, name, description, config);
}
const client = this.sessions.get(serverId);
if (!client)
throw new Error(`Server ${serverId} is not initialized`);
let watchdogTimer;
let didTimeout = false;
const watchdog = new Promise((_, reject) => {
watchdogTimer = setTimeout(async () => {
didTimeout = true;
await logger.error(`[WATCHDOG] Tool call to ${toolName} on server ${serverId} timed out after ${timeout}ms. Removing server.`);
await this.removeServer(serverId);
reject(new Error(`Tool call timed out after ${timeout}ms`));
}, timeout);
});
try {
const result = (await Promise.race([
client.callTool({ name: toolName, arguments: arguments_ }),
watchdog,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
]));
if (watchdogTimer) {
clearTimeout(watchdogTimer);
watchdogTimer = undefined;
}
return result.content;
}
catch (err) {
if (watchdogTimer) {
clearTimeout(watchdogTimer);
watchdogTimer = undefined;
}
if (!didTimeout) {
await logger.error(`callTool failed for ${toolName} on ${serverId}: ${String(err)}`);
await this.removeServer(serverId);
}
throw err;
}
}
async uninstall(serverId) {
// Wait for any ongoing installation to complete before uninstalling
const existingInstall = this.installationPromises.get(serverId);
if (existingInstall) {
await logger.debug(`Waiting for ongoing installation of ${serverId} before uninstalling...`);
try {
await existingInstall;
}
catch (err) {
// Installation failed, continue with uninstall
await logger.debug(`Installation failed, continuing with uninstall: ${err}`);
}
}
// Remove the server from memory
await this.removeServer(serverId);
// Remove the server from the config file
let config = {};
try {
const data = await fs.readFile(this.configPath, "utf-8");
config = JSON.parse(data);
}
catch (error) {
// If config file doesn't exist, nothing to do
await logger.debug(`Could not read existing config for uninstall: ${error}`);
return;
}
if (Object.prototype.hasOwnProperty.call(config, serverId)) {
delete config[serverId];
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
await logger.debug(`Removed server ${serverId} from config at ${this.configPath}`);
}
// Remove from in-memory config as well
delete this.config[serverId];
}
async removeServer(serverId) {
const client = this.sessions.get(serverId);
if (client && client.transport) {
try {
await client.transport.close();
}
catch (err) {
await logger.warn(`Failed to close transport for ${serverId}: ${err}`);
}
}
this.sessions.delete(serverId);
this.tools.delete(serverId);
this.serverNames.delete(serverId);
}
async listServers() {
const config = await this.loadConfig();
return Object.entries(config).map(([id, cfg]) => ({
server_id: id,
server_name: cfg.server_name || id,
tool_count: this.tools.get(id)?.length || 0,
description: cfg.description || "",
}));
}
async listTools(serverId) {
// Check for ongoing installation
const existingInstall = this.installationPromises.get(serverId);
if (existingInstall) {
await logger.debug(`Waiting for ongoing installation of ${serverId}...`);
await existingInstall;
}
if (!this.tools.has(serverId)) {
const config = this.config[serverId];
if (!config)
throw new Error(`No config for server ${serverId}`);
await this.install(serverId, config.server_name || serverId, config.description || "", config);
}
return this.tools.get(serverId) || [];
}
async getServerConfig(serverId) {
// Always reload config from disk to ensure up-to-date
const config = await this.loadConfig();
const serverConfig = config[serverId];
if (!serverConfig) {
throw new Error(`No config found for server ${serverId}`);
}
return serverConfig;
}
async cleanup() {
// Wait for all ongoing installations to complete
const ongoingInstalls = Array.from(this.installationPromises.values());
if (ongoingInstalls.length > 0) {
await logger.debug(`Waiting for ${ongoingInstalls.length} ongoing installations to complete...`);
await Promise.allSettled(ongoingInstalls);
}
// Clean up all sessions
for (const serverId of this.sessions.keys()) {
await this.removeServer(serverId);
}
// Clear the installation promises map
this.installationPromises.clear();
}
}