@toolplex/client 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +98 -0
- package/README.md +112 -0
- package/dist/mcp-server/clientContext.d.ts +35 -0
- package/dist/mcp-server/clientContext.js +107 -0
- package/dist/mcp-server/index.d.ts +1 -0
- package/dist/mcp-server/index.js +22 -0
- package/dist/mcp-server/logging/telemetryLogger.d.ts +18 -0
- package/dist/mcp-server/logging/telemetryLogger.js +54 -0
- package/dist/mcp-server/policy/callToolObserver.d.ts +9 -0
- package/dist/mcp-server/policy/callToolObserver.js +25 -0
- package/dist/mcp-server/policy/feedbackPolicy.d.ts +27 -0
- package/dist/mcp-server/policy/feedbackPolicy.js +39 -0
- package/dist/mcp-server/policy/installObserver.d.ts +11 -0
- package/dist/mcp-server/policy/installObserver.js +35 -0
- package/dist/mcp-server/policy/playbookPolicy.d.ts +29 -0
- package/dist/mcp-server/policy/playbookPolicy.js +81 -0
- package/dist/mcp-server/policy/policyEnforcer.d.ts +57 -0
- package/dist/mcp-server/policy/policyEnforcer.js +105 -0
- package/dist/mcp-server/policy/serverPolicy.d.ts +39 -0
- package/dist/mcp-server/policy/serverPolicy.js +61 -0
- package/dist/mcp-server/promptsCache.d.ts +25 -0
- package/dist/mcp-server/promptsCache.js +51 -0
- package/dist/mcp-server/registry.d.ts +34 -0
- package/dist/mcp-server/registry.js +109 -0
- package/dist/mcp-server/serversCache.d.ts +53 -0
- package/dist/mcp-server/serversCache.js +100 -0
- package/dist/mcp-server/staticPrompts.d.ts +6 -0
- package/dist/mcp-server/staticPrompts.js +6 -0
- package/dist/mcp-server/toolDefinitionsCache.d.ts +33 -0
- package/dist/mcp-server/toolDefinitionsCache.js +67 -0
- package/dist/mcp-server/toolHandlers/callToolHandler.d.ts +3 -0
- package/dist/mcp-server/toolHandlers/callToolHandler.js +79 -0
- package/dist/mcp-server/toolHandlers/getServerConfigHandler.d.ts +3 -0
- package/dist/mcp-server/toolHandlers/getServerConfigHandler.js +69 -0
- package/dist/mcp-server/toolHandlers/initHandler.d.ts +3 -0
- package/dist/mcp-server/toolHandlers/initHandler.js +117 -0
- package/dist/mcp-server/toolHandlers/installServerHandler.d.ts +3 -0
- package/dist/mcp-server/toolHandlers/installServerHandler.js +151 -0
- package/dist/mcp-server/toolHandlers/listServersHandler.d.ts +2 -0
- package/dist/mcp-server/toolHandlers/listServersHandler.js +81 -0
- package/dist/mcp-server/toolHandlers/listToolsHandler.d.ts +3 -0
- package/dist/mcp-server/toolHandlers/listToolsHandler.js +112 -0
- package/dist/mcp-server/toolHandlers/logPlaybookUsageHandler.d.ts +3 -0
- package/dist/mcp-server/toolHandlers/logPlaybookUsageHandler.js +65 -0
- package/dist/mcp-server/toolHandlers/lookupEntityHandler.d.ts +3 -0
- package/dist/mcp-server/toolHandlers/lookupEntityHandler.js +112 -0
- package/dist/mcp-server/toolHandlers/savePlaybookHandler.d.ts +3 -0
- package/dist/mcp-server/toolHandlers/savePlaybookHandler.js +65 -0
- package/dist/mcp-server/toolHandlers/searchHandler.d.ts +3 -0
- package/dist/mcp-server/toolHandlers/searchHandler.js +114 -0
- package/dist/mcp-server/toolHandlers/serverManagerUtils.d.ts +2 -0
- package/dist/mcp-server/toolHandlers/serverManagerUtils.js +20 -0
- package/dist/mcp-server/toolHandlers/submitFeedbackHandler.d.ts +3 -0
- package/dist/mcp-server/toolHandlers/submitFeedbackHandler.js +70 -0
- package/dist/mcp-server/toolHandlers/uninstallServerHandler.d.ts +3 -0
- package/dist/mcp-server/toolHandlers/uninstallServerHandler.js +83 -0
- package/dist/mcp-server/toolplexApi/service.d.ts +32 -0
- package/dist/mcp-server/toolplexApi/service.js +222 -0
- package/dist/mcp-server/toolplexApi/types.d.ts +124 -0
- package/dist/mcp-server/toolplexApi/types.js +1 -0
- package/dist/mcp-server/toolplexServer.d.ts +3 -0
- package/dist/mcp-server/toolplexServer.js +249 -0
- package/dist/mcp-server/tools.d.ts +2 -0
- package/dist/mcp-server/tools.js +13 -0
- package/dist/mcp-server/utils/initServerManagers.d.ts +6 -0
- package/dist/mcp-server/utils/initServerManagers.js +31 -0
- package/dist/mcp-server/utils/resultAnnotators.d.ts +23 -0
- package/dist/mcp-server/utils/resultAnnotators.js +50 -0
- package/dist/mcp-server/utils/runtimeCheck.d.ts +4 -0
- package/dist/mcp-server/utils/runtimeCheck.js +30 -0
- package/dist/server-manager/index.d.ts +1 -0
- package/dist/server-manager/index.js +8 -0
- package/dist/server-manager/serverManager.d.ts +37 -0
- package/dist/server-manager/serverManager.js +419 -0
- package/dist/server-manager/stdioServer.d.ts +9 -0
- package/dist/server-manager/stdioServer.js +136 -0
- package/dist/server-manager/stdioTransportProtocol.d.ts +31 -0
- package/dist/server-manager/stdioTransportProtocol.js +67 -0
- package/dist/shared/enhancedPath.d.ts +7 -0
- package/dist/shared/enhancedPath.js +52 -0
- package/dist/shared/fileLogger.d.ts +13 -0
- package/dist/shared/fileLogger.js +66 -0
- package/dist/shared/mcpServerTypes.d.ts +398 -0
- package/dist/shared/mcpServerTypes.js +148 -0
- package/dist/shared/serverManagerTypes.d.ts +179 -0
- package/dist/shared/serverManagerTypes.js +73 -0
- package/dist/shared/stdioServerManagerClient.d.ts +12 -0
- package/dist/shared/stdioServerManagerClient.js +96 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import { StdioClientTransport, } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
3
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
4
|
+
import * as fs from 'fs/promises';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import which from 'which';
|
|
7
|
+
import { FileLogger } from '../shared/fileLogger.js';
|
|
8
|
+
import envPaths from 'env-paths';
|
|
9
|
+
import { getEnhancedPath } from '../shared/enhancedPath.js';
|
|
10
|
+
const logger = FileLogger;
|
|
11
|
+
export class ServerManager {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.config = {};
|
|
14
|
+
// Track ongoing installations to prevent race conditions
|
|
15
|
+
this.installationPromises = new Map();
|
|
16
|
+
// Add a file lock mechanism to prevent concurrent writes
|
|
17
|
+
this.configLock = Promise.resolve();
|
|
18
|
+
this.sessions = new Map();
|
|
19
|
+
this.tools = new Map();
|
|
20
|
+
this.serverNames = new Map();
|
|
21
|
+
const paths = envPaths('ToolPlex', { suffix: '' });
|
|
22
|
+
this.configPath = path.join(paths.data, 'server_config.json');
|
|
23
|
+
}
|
|
24
|
+
async loadConfig() {
|
|
25
|
+
try {
|
|
26
|
+
const data = await fs.readFile(this.configPath, 'utf-8');
|
|
27
|
+
await logger.debug(`Loaded config from ${this.configPath}`);
|
|
28
|
+
const allConfig = JSON.parse(data);
|
|
29
|
+
// Validate the config structure
|
|
30
|
+
if (typeof allConfig !== 'object' || allConfig === null) {
|
|
31
|
+
await logger.warn('Invalid config format, using empty config');
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
const config = {};
|
|
35
|
+
for (const [serverId, serverConfig] of Object.entries(allConfig)) {
|
|
36
|
+
if (typeof serverConfig === 'object' && serverConfig !== null) {
|
|
37
|
+
config[serverId] = serverConfig;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
await logger.warn(`Invalid server config for ${serverId}, skipping`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return config;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
let errorMessage = 'Unknown error occurred';
|
|
47
|
+
if (error instanceof Error)
|
|
48
|
+
errorMessage = error.message;
|
|
49
|
+
await logger.debug(`No existing config found at ${this.configPath}: ${errorMessage}`);
|
|
50
|
+
// If the file exists but is malformed, back it up and start fresh
|
|
51
|
+
try {
|
|
52
|
+
await fs.access(this.configPath);
|
|
53
|
+
const backupPath = this.configPath + '.backup.' + Date.now();
|
|
54
|
+
await fs.copyFile(this.configPath, backupPath);
|
|
55
|
+
await logger.warn(`Malformed config backed up to ${backupPath}`);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// File doesn't exist, which is fine
|
|
59
|
+
}
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async saveConfig(config) {
|
|
64
|
+
// Use a lock to prevent concurrent writes
|
|
65
|
+
this.configLock = this.configLock.then(async () => {
|
|
66
|
+
let existingConfig = {};
|
|
67
|
+
try {
|
|
68
|
+
const data = await fs.readFile(this.configPath, 'utf-8');
|
|
69
|
+
existingConfig = JSON.parse(data);
|
|
70
|
+
// Validate the existing config structure
|
|
71
|
+
if (typeof existingConfig !== 'object' || existingConfig === null) {
|
|
72
|
+
await logger.warn('Invalid existing config format, using empty config');
|
|
73
|
+
existingConfig = {};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// Config file doesn't exist or is invalid, use empty config
|
|
78
|
+
await logger.debug(`Could not read existing config: ${error}`);
|
|
79
|
+
existingConfig = {};
|
|
80
|
+
}
|
|
81
|
+
const mergedConfig = {
|
|
82
|
+
...existingConfig,
|
|
83
|
+
...config,
|
|
84
|
+
};
|
|
85
|
+
// Validate the merged config before writing
|
|
86
|
+
try {
|
|
87
|
+
const testJson = JSON.stringify(mergedConfig, null, 2);
|
|
88
|
+
JSON.parse(testJson); // This will throw if invalid
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
throw new Error(`Invalid config structure would be written: ${error}`);
|
|
92
|
+
}
|
|
93
|
+
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
|
|
94
|
+
// Write to a temporary file first, then rename (atomic operation)
|
|
95
|
+
const tempPath = this.configPath + '.tmp';
|
|
96
|
+
try {
|
|
97
|
+
await fs.writeFile(tempPath, JSON.stringify(mergedConfig, null, 2));
|
|
98
|
+
await fs.rename(tempPath, this.configPath);
|
|
99
|
+
await logger.debug(`Saved config to ${this.configPath}`);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
// Clean up temp file if it exists
|
|
103
|
+
try {
|
|
104
|
+
await fs.unlink(tempPath);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Ignore cleanup errors
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
await this.configLock;
|
|
113
|
+
}
|
|
114
|
+
async initialize() {
|
|
115
|
+
await this.cleanup();
|
|
116
|
+
const succeeded = [];
|
|
117
|
+
const failures = {};
|
|
118
|
+
try {
|
|
119
|
+
await logger.info('Initializing ServerManager');
|
|
120
|
+
this.config = await this.loadConfig();
|
|
121
|
+
await logger.debug(`Loaded ${Object.keys(this.config).length} server configs`);
|
|
122
|
+
for (const [serverId, serverConfig] of Object.entries(this.config)) {
|
|
123
|
+
succeeded.push({
|
|
124
|
+
server_id: serverId,
|
|
125
|
+
server_name: serverConfig.server_name ?? serverId,
|
|
126
|
+
description: serverConfig.description ?? '',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
const errorMessage = err.message || String(err);
|
|
132
|
+
await logger.error(`Failed to initialize: ${errorMessage}`);
|
|
133
|
+
}
|
|
134
|
+
return { succeeded, failures };
|
|
135
|
+
}
|
|
136
|
+
async getServerName(serverId) {
|
|
137
|
+
await logger.debug(`Getting name for server ${serverId}`);
|
|
138
|
+
return this.serverNames.get(serverId) || serverId;
|
|
139
|
+
}
|
|
140
|
+
async connectWithHandshakeTimeout(client, transport, ms = 30000) {
|
|
141
|
+
let connectTimeout;
|
|
142
|
+
let listToolsTimeout;
|
|
143
|
+
try {
|
|
144
|
+
// Race connect() with timeout
|
|
145
|
+
await Promise.race([
|
|
146
|
+
client.connect(transport),
|
|
147
|
+
new Promise((_, reject) => {
|
|
148
|
+
connectTimeout = setTimeout(() => reject(new Error(`connect() timed out in ${ms} ms`)), ms);
|
|
149
|
+
}),
|
|
150
|
+
]);
|
|
151
|
+
// Clear the connect timeout since it succeeded
|
|
152
|
+
clearTimeout(connectTimeout);
|
|
153
|
+
// Race listTools() with timeout
|
|
154
|
+
const result = await Promise.race([
|
|
155
|
+
client.listTools(),
|
|
156
|
+
new Promise((_, reject) => {
|
|
157
|
+
listToolsTimeout = setTimeout(() => reject(new Error(`listTools() timed out in ${ms} ms`)), ms);
|
|
158
|
+
}),
|
|
159
|
+
]);
|
|
160
|
+
clearTimeout(listToolsTimeout);
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
// Clean up timeouts on error
|
|
165
|
+
if (connectTimeout)
|
|
166
|
+
clearTimeout(connectTimeout);
|
|
167
|
+
if (listToolsTimeout)
|
|
168
|
+
clearTimeout(listToolsTimeout);
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async install(serverId, serverName, description, config) {
|
|
173
|
+
await logger.info(`Installing server ${serverId} (${serverName})`);
|
|
174
|
+
await logger.debug(`Server config: ${JSON.stringify(config)}`);
|
|
175
|
+
// Check if there's already an ongoing installation for this server
|
|
176
|
+
const existingInstall = this.installationPromises.get(serverId);
|
|
177
|
+
if (existingInstall) {
|
|
178
|
+
await logger.debug(`Installation already in progress for ${serverId}, waiting...`);
|
|
179
|
+
await existingInstall;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Create the installation promise
|
|
183
|
+
const installPromise = this.performInstall(serverId, serverName, description, config);
|
|
184
|
+
this.installationPromises.set(serverId, installPromise);
|
|
185
|
+
try {
|
|
186
|
+
await installPromise;
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
// Always clean up the promise from the map
|
|
190
|
+
this.installationPromises.delete(serverId);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async performInstall(serverId, serverName, description, config) {
|
|
194
|
+
if (this.sessions.has(serverId)) {
|
|
195
|
+
await logger.debug(`Server ${serverId} already exists, removing first`);
|
|
196
|
+
await this.removeServer(serverId);
|
|
197
|
+
}
|
|
198
|
+
let transport;
|
|
199
|
+
if (config.transport === 'sse') {
|
|
200
|
+
if (!config.url)
|
|
201
|
+
throw new Error('URL is required for SSE transport');
|
|
202
|
+
transport = new SSEClientTransport(new URL(config.url));
|
|
203
|
+
}
|
|
204
|
+
else if (config.transport === 'stdio') {
|
|
205
|
+
if (!config.command)
|
|
206
|
+
throw new Error('Command is required for stdio transport');
|
|
207
|
+
const enhancedPath = getEnhancedPath();
|
|
208
|
+
let resolvedCommand = which.sync(config.command, {
|
|
209
|
+
path: enhancedPath,
|
|
210
|
+
nothrow: true,
|
|
211
|
+
});
|
|
212
|
+
if (!resolvedCommand) {
|
|
213
|
+
// Fallback to supplied command
|
|
214
|
+
resolvedCommand = config.command;
|
|
215
|
+
}
|
|
216
|
+
const serverParams = {
|
|
217
|
+
command: resolvedCommand,
|
|
218
|
+
args: config.args || [],
|
|
219
|
+
env: {
|
|
220
|
+
...process.env,
|
|
221
|
+
PATH: enhancedPath,
|
|
222
|
+
...(config.env || {}),
|
|
223
|
+
},
|
|
224
|
+
stderr: 'pipe',
|
|
225
|
+
};
|
|
226
|
+
transport = new StdioClientTransport(serverParams);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
throw new Error(`Invalid transport type: ${config.transport}`);
|
|
230
|
+
}
|
|
231
|
+
const client = new Client({ name: serverId, version: '1.0.0' }, { capabilities: { prompts: {}, resources: {}, tools: {} } });
|
|
232
|
+
try {
|
|
233
|
+
const toolsResponse = await this.connectWithHandshakeTimeout(client, transport, 30000);
|
|
234
|
+
const tools = toolsResponse.tools || [];
|
|
235
|
+
this.sessions.set(serverId, client);
|
|
236
|
+
this.tools.set(serverId, tools);
|
|
237
|
+
this.serverNames.set(serverId, serverName);
|
|
238
|
+
const updatedEntry = {
|
|
239
|
+
...config,
|
|
240
|
+
server_name: serverName,
|
|
241
|
+
description,
|
|
242
|
+
};
|
|
243
|
+
const currentConfig = await this.loadConfig();
|
|
244
|
+
await this.saveConfig({
|
|
245
|
+
...currentConfig,
|
|
246
|
+
[serverId]: updatedEntry,
|
|
247
|
+
});
|
|
248
|
+
this.config[serverId] = updatedEntry;
|
|
249
|
+
await logger.info(`Successfully installed server ${serverId} with ${tools.length} tools`);
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
// Clean up on failure
|
|
253
|
+
this.sessions.delete(serverId);
|
|
254
|
+
this.tools.delete(serverId);
|
|
255
|
+
this.serverNames.delete(serverId);
|
|
256
|
+
// Close transport if it was created
|
|
257
|
+
if (client && client.transport) {
|
|
258
|
+
try {
|
|
259
|
+
await client.transport.close();
|
|
260
|
+
}
|
|
261
|
+
catch (closeErr) {
|
|
262
|
+
await logger.warn(`Failed to close transport during cleanup: ${closeErr}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async callTool(serverId, toolName,
|
|
269
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
270
|
+
arguments_, timeout = 60000
|
|
271
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
272
|
+
) {
|
|
273
|
+
// Check for ongoing installation before attempting to install
|
|
274
|
+
const existingInstall = this.installationPromises.get(serverId);
|
|
275
|
+
if (existingInstall) {
|
|
276
|
+
await logger.debug(`Waiting for ongoing installation of ${serverId}...`);
|
|
277
|
+
await existingInstall;
|
|
278
|
+
}
|
|
279
|
+
if (!this.sessions.has(serverId)) {
|
|
280
|
+
const config = this.config[serverId];
|
|
281
|
+
if (!config)
|
|
282
|
+
throw new Error(`No config found for server ${serverId}`);
|
|
283
|
+
const name = config.server_name || serverId;
|
|
284
|
+
const description = config.description || '';
|
|
285
|
+
await this.install(serverId, name, description, config);
|
|
286
|
+
}
|
|
287
|
+
const client = this.sessions.get(serverId);
|
|
288
|
+
if (!client)
|
|
289
|
+
throw new Error(`Server ${serverId} is not initialized`);
|
|
290
|
+
let watchdogTimer;
|
|
291
|
+
let didTimeout = false;
|
|
292
|
+
const watchdog = new Promise((_, reject) => {
|
|
293
|
+
watchdogTimer = setTimeout(async () => {
|
|
294
|
+
didTimeout = true;
|
|
295
|
+
await logger.error(`[WATCHDOG] Tool call to ${toolName} on server ${serverId} timed out after ${timeout}ms. Removing server.`);
|
|
296
|
+
await this.removeServer(serverId);
|
|
297
|
+
reject(new Error(`Tool call timed out after ${timeout}ms`));
|
|
298
|
+
}, timeout);
|
|
299
|
+
});
|
|
300
|
+
try {
|
|
301
|
+
const result = (await Promise.race([
|
|
302
|
+
client.callTool({ name: toolName, arguments: arguments_ }),
|
|
303
|
+
watchdog,
|
|
304
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
305
|
+
]));
|
|
306
|
+
if (watchdogTimer) {
|
|
307
|
+
clearTimeout(watchdogTimer);
|
|
308
|
+
watchdogTimer = undefined;
|
|
309
|
+
}
|
|
310
|
+
return result.content;
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
if (watchdogTimer) {
|
|
314
|
+
clearTimeout(watchdogTimer);
|
|
315
|
+
watchdogTimer = undefined;
|
|
316
|
+
}
|
|
317
|
+
if (!didTimeout) {
|
|
318
|
+
await logger.error(`callTool failed for ${toolName} on ${serverId}: ${String(err)}`);
|
|
319
|
+
await this.removeServer(serverId);
|
|
320
|
+
}
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async uninstall(serverId) {
|
|
325
|
+
// Wait for any ongoing installation to complete before uninstalling
|
|
326
|
+
const existingInstall = this.installationPromises.get(serverId);
|
|
327
|
+
if (existingInstall) {
|
|
328
|
+
await logger.debug(`Waiting for ongoing installation of ${serverId} before uninstalling...`);
|
|
329
|
+
try {
|
|
330
|
+
await existingInstall;
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
// Installation failed, continue with uninstall
|
|
334
|
+
await logger.debug(`Installation failed, continuing with uninstall: ${err}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Remove the server from memory
|
|
338
|
+
await this.removeServer(serverId);
|
|
339
|
+
// Remove the server from the config file
|
|
340
|
+
let config = {};
|
|
341
|
+
try {
|
|
342
|
+
const data = await fs.readFile(this.configPath, 'utf-8');
|
|
343
|
+
config = JSON.parse(data);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
// If config file doesn't exist, nothing to do
|
|
347
|
+
await logger.debug(`Could not read existing config for uninstall: ${error}`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (Object.prototype.hasOwnProperty.call(config, serverId)) {
|
|
351
|
+
delete config[serverId];
|
|
352
|
+
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
|
|
353
|
+
await logger.debug(`Removed server ${serverId} from config at ${this.configPath}`);
|
|
354
|
+
}
|
|
355
|
+
// Remove from in-memory config as well
|
|
356
|
+
delete this.config[serverId];
|
|
357
|
+
}
|
|
358
|
+
async removeServer(serverId) {
|
|
359
|
+
const client = this.sessions.get(serverId);
|
|
360
|
+
if (client && client.transport) {
|
|
361
|
+
try {
|
|
362
|
+
await client.transport.close();
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
await logger.warn(`Failed to close transport for ${serverId}: ${err}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
this.sessions.delete(serverId);
|
|
369
|
+
this.tools.delete(serverId);
|
|
370
|
+
this.serverNames.delete(serverId);
|
|
371
|
+
}
|
|
372
|
+
async listServers() {
|
|
373
|
+
const config = await this.loadConfig();
|
|
374
|
+
return Object.entries(config).map(([id, cfg]) => ({
|
|
375
|
+
server_id: id,
|
|
376
|
+
server_name: cfg.server_name || id,
|
|
377
|
+
tool_count: this.tools.get(id)?.length || 0,
|
|
378
|
+
description: cfg.description || '',
|
|
379
|
+
}));
|
|
380
|
+
}
|
|
381
|
+
async listTools(serverId) {
|
|
382
|
+
// Check for ongoing installation
|
|
383
|
+
const existingInstall = this.installationPromises.get(serverId);
|
|
384
|
+
if (existingInstall) {
|
|
385
|
+
await logger.debug(`Waiting for ongoing installation of ${serverId}...`);
|
|
386
|
+
await existingInstall;
|
|
387
|
+
}
|
|
388
|
+
if (!this.tools.has(serverId)) {
|
|
389
|
+
const config = this.config[serverId];
|
|
390
|
+
if (!config)
|
|
391
|
+
throw new Error(`No config for server ${serverId}`);
|
|
392
|
+
await this.install(serverId, config.server_name || serverId, config.description || '', config);
|
|
393
|
+
}
|
|
394
|
+
return this.tools.get(serverId) || [];
|
|
395
|
+
}
|
|
396
|
+
async getServerConfig(serverId) {
|
|
397
|
+
// Always reload config from disk to ensure up-to-date
|
|
398
|
+
const config = await this.loadConfig();
|
|
399
|
+
const serverConfig = config[serverId];
|
|
400
|
+
if (!serverConfig) {
|
|
401
|
+
throw new Error(`No config found for server ${serverId}`);
|
|
402
|
+
}
|
|
403
|
+
return serverConfig;
|
|
404
|
+
}
|
|
405
|
+
async cleanup() {
|
|
406
|
+
// Wait for all ongoing installations to complete
|
|
407
|
+
const ongoingInstalls = Array.from(this.installationPromises.values());
|
|
408
|
+
if (ongoingInstalls.length > 0) {
|
|
409
|
+
await logger.debug(`Waiting for ${ongoingInstalls.length} ongoing installations to complete...`);
|
|
410
|
+
await Promise.allSettled(ongoingInstalls);
|
|
411
|
+
}
|
|
412
|
+
// Clean up all sessions
|
|
413
|
+
for (const serverId of this.sessions.keys()) {
|
|
414
|
+
await this.removeServer(serverId);
|
|
415
|
+
}
|
|
416
|
+
// Clear the installation promises map
|
|
417
|
+
this.installationPromises.clear();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// stdioServer.ts
|
|
2
|
+
import { ServerManager } from './serverManager.js';
|
|
3
|
+
import { StdioTransport, } from './stdioTransportProtocol.js';
|
|
4
|
+
import { FileLogger } from '../shared/fileLogger.js';
|
|
5
|
+
import { CallToolParamsSchema, InstallParamsSchema, ListToolsParamsSchema, UninstallParamsSchema, } from '../shared/mcpServerTypes.js';
|
|
6
|
+
const logger = FileLogger;
|
|
7
|
+
export class ServerManagerProtocol {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.serverManager = new ServerManager();
|
|
10
|
+
this.transport = new StdioTransport();
|
|
11
|
+
this.transport.setOnMessage(this.handleMessage.bind(this));
|
|
12
|
+
// Clean up on process exit
|
|
13
|
+
process.on('exit', async () => {
|
|
14
|
+
await logger.info('Process exit - cleaning up server manager');
|
|
15
|
+
await logger.flush();
|
|
16
|
+
await this.serverManager.cleanup();
|
|
17
|
+
});
|
|
18
|
+
process.on('SIGINT', async () => {
|
|
19
|
+
await logger.warn('SIGINT received - cleaning up server manager');
|
|
20
|
+
await logger.flush();
|
|
21
|
+
await this.serverManager.cleanup();
|
|
22
|
+
process.exit();
|
|
23
|
+
});
|
|
24
|
+
process.on('SIGTERM', async () => {
|
|
25
|
+
await logger.warn('SIGTERM received - cleaning up server manager');
|
|
26
|
+
await logger.flush();
|
|
27
|
+
await this.serverManager.cleanup();
|
|
28
|
+
process.exit();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async start() {
|
|
32
|
+
await logger.info('Starting ServerManagerProtocol transport');
|
|
33
|
+
await this.transport.start();
|
|
34
|
+
await logger.info('ServerManagerProtocol transport started successfully');
|
|
35
|
+
}
|
|
36
|
+
async safeSend(msg) {
|
|
37
|
+
try {
|
|
38
|
+
await this.transport.send(msg);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
process.stderr.write(`transport.send failed: ${e}\n`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async handleMessage(message) {
|
|
45
|
+
try {
|
|
46
|
+
if (!('id' in message) || !('method' in message)) {
|
|
47
|
+
await this.safeSend({
|
|
48
|
+
jsonrpc: '2.0',
|
|
49
|
+
error: { code: -1001, message: 'Invalid Request' },
|
|
50
|
+
id: null,
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const req = message;
|
|
55
|
+
let result;
|
|
56
|
+
try {
|
|
57
|
+
result = await this.callMethod(req.method, req.params ?? {});
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
await logger.error(`callMethod failed (${req.method}): ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
61
|
+
await this.safeSend({
|
|
62
|
+
jsonrpc: '2.0',
|
|
63
|
+
error: { code: -1000, message: err instanceof Error ? err.message : 'Server error' },
|
|
64
|
+
id: req.id,
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await this.safeSend({ jsonrpc: '2.0', result, id: req.id });
|
|
69
|
+
}
|
|
70
|
+
catch (fatal) {
|
|
71
|
+
process.stderr.write(`handleMessage fatal: ${String(fatal)}\n`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
75
|
+
async callMethod(method, params) {
|
|
76
|
+
switch (method) {
|
|
77
|
+
case 'initialize': {
|
|
78
|
+
await logger.info('Calling ServerManager.initialize()');
|
|
79
|
+
const { succeeded, failures } = await this.serverManager.initialize();
|
|
80
|
+
return { succeeded, failures };
|
|
81
|
+
}
|
|
82
|
+
case 'install': {
|
|
83
|
+
const install_params = InstallParamsSchema.parse(params);
|
|
84
|
+
await logger.info(`Installing server ${install_params.server_id}`);
|
|
85
|
+
await this.serverManager.install(install_params.server_id, install_params.server_name, install_params.description, install_params.config);
|
|
86
|
+
const server_name = await this.serverManager.getServerName(install_params.server_id);
|
|
87
|
+
return { server_id: install_params.server_id, server_name };
|
|
88
|
+
}
|
|
89
|
+
case 'list_servers': {
|
|
90
|
+
await logger.debug('Listing servers');
|
|
91
|
+
const servers = await this.serverManager.listServers();
|
|
92
|
+
return { servers };
|
|
93
|
+
}
|
|
94
|
+
case 'list_tools': {
|
|
95
|
+
const list_tools_params = ListToolsParamsSchema.parse(params);
|
|
96
|
+
if (!list_tools_params.server_id)
|
|
97
|
+
throw new Error('Missing server_id');
|
|
98
|
+
await logger.debug(`Listing tools for server ${list_tools_params.server_id}`);
|
|
99
|
+
const tools = await this.serverManager.listTools(list_tools_params.server_id);
|
|
100
|
+
const server_name = await this.serverManager.getServerName(list_tools_params.server_id);
|
|
101
|
+
return { server_id: list_tools_params.server_id, server_name, tools };
|
|
102
|
+
}
|
|
103
|
+
case 'get_server_config': {
|
|
104
|
+
if (!params || typeof params.server_id !== 'string') {
|
|
105
|
+
throw new Error('Missing or invalid server_id');
|
|
106
|
+
}
|
|
107
|
+
await logger.debug(`Getting config for server ${params.server_id}`);
|
|
108
|
+
// Just return the config directly
|
|
109
|
+
const config = await this.serverManager.getServerConfig(params.server_id);
|
|
110
|
+
return config;
|
|
111
|
+
}
|
|
112
|
+
case 'call_tool': {
|
|
113
|
+
const call_tool_params = CallToolParamsSchema.parse(params);
|
|
114
|
+
await logger.debug(`Calling tool ${call_tool_params.tool_name} on server ${call_tool_params.server_id}`);
|
|
115
|
+
const result = await this.serverManager.callTool(call_tool_params.server_id, call_tool_params.tool_name, call_tool_params.arguments, 60000 // 60s timeout
|
|
116
|
+
);
|
|
117
|
+
return { result };
|
|
118
|
+
}
|
|
119
|
+
case 'uninstall': {
|
|
120
|
+
const uninstall_params = UninstallParamsSchema.parse(params);
|
|
121
|
+
await logger.info(`Uninstalling server ${uninstall_params.server_id}`);
|
|
122
|
+
const server_name = await this.serverManager.getServerName(uninstall_params.server_id);
|
|
123
|
+
await this.serverManager.uninstall(uninstall_params.server_id);
|
|
124
|
+
return { server_id: uninstall_params.server_id, server_name };
|
|
125
|
+
}
|
|
126
|
+
case 'cleanup': {
|
|
127
|
+
await logger.info('Cleaning up server manager');
|
|
128
|
+
await this.serverManager.cleanup();
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
default:
|
|
132
|
+
await logger.error(`Method ${method} not found`);
|
|
133
|
+
throw new Error(`Method ${method} not found`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Readable, Writable } from 'node:stream';
|
|
2
|
+
export interface JSONRPCMessage {
|
|
3
|
+
jsonrpc: '2.0';
|
|
4
|
+
error?: {
|
|
5
|
+
code: number;
|
|
6
|
+
message: string;
|
|
7
|
+
};
|
|
8
|
+
id: string | number | null;
|
|
9
|
+
}
|
|
10
|
+
export interface JSONRPCRequest extends JSONRPCMessage {
|
|
11
|
+
method: string;
|
|
12
|
+
params?: any;
|
|
13
|
+
}
|
|
14
|
+
export interface JSONRPCResponse extends JSONRPCMessage {
|
|
15
|
+
result?: any;
|
|
16
|
+
}
|
|
17
|
+
export declare class StdioTransport {
|
|
18
|
+
private _stdin;
|
|
19
|
+
private _stdout;
|
|
20
|
+
private rl;
|
|
21
|
+
private onmessage?;
|
|
22
|
+
private bufferChunks;
|
|
23
|
+
private isStarted;
|
|
24
|
+
private readonly dataHandler;
|
|
25
|
+
constructor(_stdin?: Readable, _stdout?: Writable);
|
|
26
|
+
start(): Promise<void>;
|
|
27
|
+
private processBuffer;
|
|
28
|
+
send(message: JSONRPCRequest | JSONRPCResponse): Promise<void>;
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
setOnMessage(handler: (message: JSONRPCMessage) => void): void;
|
|
31
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as readline from 'readline';
|
|
2
|
+
export class StdioTransport {
|
|
3
|
+
constructor(_stdin = process.stdin, _stdout = process.stdout) {
|
|
4
|
+
this._stdin = _stdin;
|
|
5
|
+
this._stdout = _stdout;
|
|
6
|
+
this.bufferChunks = [];
|
|
7
|
+
this.isStarted = false;
|
|
8
|
+
// Store bound methods to ensure proper cleanup
|
|
9
|
+
this.dataHandler = (chunk) => {
|
|
10
|
+
this.bufferChunks.push(chunk.toString());
|
|
11
|
+
this.processBuffer();
|
|
12
|
+
};
|
|
13
|
+
this.rl = readline.createInterface({
|
|
14
|
+
input: this._stdin,
|
|
15
|
+
output: this._stdout,
|
|
16
|
+
terminal: false,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
async start() {
|
|
20
|
+
if (this.isStarted) {
|
|
21
|
+
return; // Prevent double initialization
|
|
22
|
+
}
|
|
23
|
+
this.isStarted = true;
|
|
24
|
+
this._stdin.on('data', this.dataHandler);
|
|
25
|
+
}
|
|
26
|
+
processBuffer() {
|
|
27
|
+
// Join all chunks and split by lines - more efficient than string concatenation
|
|
28
|
+
const fullBuffer = this.bufferChunks.join('');
|
|
29
|
+
const lines = fullBuffer.split('\n');
|
|
30
|
+
// Keep the last line in buffer if it's incomplete
|
|
31
|
+
const incompleteLine = lines.pop() || '';
|
|
32
|
+
// Clear chunks and store incomplete line
|
|
33
|
+
this.bufferChunks = incompleteLine ? [incompleteLine] : [];
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
if (!line.trim())
|
|
36
|
+
continue;
|
|
37
|
+
try {
|
|
38
|
+
const message = JSON.parse(line.trim());
|
|
39
|
+
if (message.error) {
|
|
40
|
+
process.stderr.write(`Server error: ${JSON.stringify(message.error)}\n`);
|
|
41
|
+
}
|
|
42
|
+
this.onmessage?.(message);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
process.stderr.write(`Failed to parse line: ${line}\n`);
|
|
46
|
+
process.stderr.write(`Parse error: ${error}\n`);
|
|
47
|
+
process.stderr.write(`Current buffer chunks: ${this.bufferChunks.length}\n`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async send(message) {
|
|
52
|
+
const messageStr = JSON.stringify(message) + '\n';
|
|
53
|
+
this._stdout.write(messageStr);
|
|
54
|
+
}
|
|
55
|
+
async close() {
|
|
56
|
+
if (this.isStarted) {
|
|
57
|
+
this._stdin.removeListener('data', this.dataHandler);
|
|
58
|
+
this.isStarted = false;
|
|
59
|
+
}
|
|
60
|
+
this.bufferChunks = [];
|
|
61
|
+
this.onmessage = undefined;
|
|
62
|
+
this.rl.close();
|
|
63
|
+
}
|
|
64
|
+
setOnMessage(handler) {
|
|
65
|
+
this.onmessage = handler;
|
|
66
|
+
}
|
|
67
|
+
}
|