@adversity/coding-tool-x 2.5.1 → 2.6.0

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.
@@ -11,6 +11,7 @@ const toml = require('@iarna/toml');
11
11
  const { spawn } = require('child_process');
12
12
  const http = require('http');
13
13
  const https = require('https');
14
+ const { McpClient } = require('./mcp-client');
14
15
 
15
16
  // MCP 配置文件路径
16
17
  const CC_TOOL_DIR = path.join(os.homedir(), '.claude', 'cc-tool');
@@ -21,6 +22,11 @@ const CLAUDE_CONFIG_PATH = path.join(os.homedir(), '.claude.json');
21
22
  const CODEX_CONFIG_PATH = path.join(os.homedir(), '.codex', 'config.toml');
22
23
  const GEMINI_CONFIG_PATH = path.join(os.homedir(), '.gemini', 'settings.json');
23
24
 
25
+ // MCP 客户端连接池
26
+ // serverId -> { client, timestamp }
27
+ const mcpClientPool = new Map();
28
+ const POOL_TTL = 5 * 60 * 1000; // 5 minutes
29
+
24
30
  // MCP 预设模板
25
31
  const MCP_PRESETS = [
26
32
  {
@@ -1053,6 +1059,201 @@ async function testHttpServer(spec) {
1053
1059
  });
1054
1060
  }
1055
1061
 
1062
+ /**
1063
+ * Get tools list from MCP server
1064
+ * @param {string} serverId - Server ID from config
1065
+ * @returns {Promise<{tools: Array, duration: number, status: string}>}
1066
+ */
1067
+ async function getServerTools(serverId) {
1068
+ const server = getServer(serverId);
1069
+ if (!server) {
1070
+ throw new Error(`MCP 服务器 "${serverId}" 不存在`);
1071
+ }
1072
+
1073
+ const startTime = Date.now();
1074
+ const spec = server.server;
1075
+
1076
+ try {
1077
+ // Check if we have a cached connection
1078
+ const cached = mcpClientPool.get(serverId);
1079
+ const now = Date.now();
1080
+
1081
+ let client;
1082
+ let needsInitialization = false;
1083
+
1084
+ if (cached && now - cached.timestamp < POOL_TTL && cached.client.connected) {
1085
+ // Reuse existing connection
1086
+ client = cached.client;
1087
+ console.log(`[MCP] Reusing pooled connection for "${serverId}"`);
1088
+ } else {
1089
+ // Create new connection
1090
+ if (cached) {
1091
+ // Clean up expired connection
1092
+ try {
1093
+ await cached.client.disconnect();
1094
+ } catch (err) {
1095
+ console.error(`[MCP] Error disconnecting expired client: ${err.message}`);
1096
+ }
1097
+ mcpClientPool.delete(serverId);
1098
+ }
1099
+
1100
+ // Create new client with 10s timeout
1101
+ client = new McpClient(spec, { timeout: 10000 });
1102
+ needsInitialization = true;
1103
+ console.log(`[MCP] Creating new connection for "${serverId}"`);
1104
+ }
1105
+
1106
+ // Connect and initialize if needed
1107
+ if (needsInitialization) {
1108
+ await client.connect();
1109
+ await client.initialize();
1110
+
1111
+ // Cache the connection
1112
+ mcpClientPool.set(serverId, {
1113
+ client,
1114
+ timestamp: Date.now()
1115
+ });
1116
+ }
1117
+
1118
+ // Get tools list
1119
+ const tools = await client.listTools();
1120
+
1121
+ return {
1122
+ tools,
1123
+ duration: Date.now() - startTime,
1124
+ status: 'online'
1125
+ };
1126
+
1127
+ } catch (err) {
1128
+ // Clean up failed connection from pool
1129
+ const cached = mcpClientPool.get(serverId);
1130
+ if (cached) {
1131
+ try {
1132
+ await cached.client.disconnect();
1133
+ } catch (e) {
1134
+ // ignore
1135
+ }
1136
+ mcpClientPool.delete(serverId);
1137
+ }
1138
+
1139
+ return {
1140
+ tools: [],
1141
+ duration: Date.now() - startTime,
1142
+ status: 'error',
1143
+ error: err.message
1144
+ };
1145
+ }
1146
+ }
1147
+
1148
+ /**
1149
+ * Execute a tool on MCP server
1150
+ * @param {string} serverId - Server ID
1151
+ * @param {string} toolName - Tool name
1152
+ * @param {Object} arguments - Tool arguments
1153
+ * @returns {Promise<{result: Object, duration: number, isError: boolean, truncated?: boolean, truncatedSize?: number}>}
1154
+ */
1155
+ async function callServerTool(serverId, toolName, arguments = {}) {
1156
+ const server = getServer(serverId);
1157
+ if (!server) {
1158
+ throw new Error(`MCP 服务器 "${serverId}" 不存在`);
1159
+ }
1160
+
1161
+ const startTime = Date.now();
1162
+ const spec = server.server;
1163
+
1164
+ try {
1165
+ // Check if we have a cached connection
1166
+ const cached = mcpClientPool.get(serverId);
1167
+ const now = Date.now();
1168
+
1169
+ let client;
1170
+ let needsInitialization = false;
1171
+
1172
+ if (cached && now - cached.timestamp < POOL_TTL && cached.client.connected) {
1173
+ // Reuse existing connection
1174
+ client = cached.client;
1175
+ // Update timestamp
1176
+ cached.timestamp = now;
1177
+ console.log(`[MCP] Reusing pooled connection for "${serverId}"`);
1178
+ } else {
1179
+ // Create new connection
1180
+ if (cached) {
1181
+ // Clean up expired connection
1182
+ try {
1183
+ await cached.client.disconnect();
1184
+ } catch (err) {
1185
+ console.error(`[MCP] Error disconnecting expired client: ${err.message}`);
1186
+ }
1187
+ mcpClientPool.delete(serverId);
1188
+ }
1189
+
1190
+ // Create new client with 30s timeout
1191
+ client = new McpClient(spec, { timeout: 30000 });
1192
+ needsInitialization = true;
1193
+ console.log(`[MCP] Creating new connection for "${serverId}"`);
1194
+ }
1195
+
1196
+ // Connect and initialize if needed
1197
+ if (needsInitialization) {
1198
+ await client.connect();
1199
+ await client.initialize();
1200
+
1201
+ // Cache the connection
1202
+ mcpClientPool.set(serverId, {
1203
+ client,
1204
+ timestamp: Date.now()
1205
+ });
1206
+ }
1207
+
1208
+ // Call the tool
1209
+ const result = await client.callTool(toolName, arguments);
1210
+
1211
+ const duration = Date.now() - startTime;
1212
+
1213
+ // Check result size, truncate if > 10KB
1214
+ const resultStr = JSON.stringify(result);
1215
+ if (resultStr.length > 10 * 1024) {
1216
+ return {
1217
+ result: {
1218
+ ...result,
1219
+ truncated: true
1220
+ },
1221
+ truncatedSize: resultStr.length,
1222
+ duration,
1223
+ isError: result.isError || false
1224
+ };
1225
+ }
1226
+
1227
+ return {
1228
+ result,
1229
+ duration,
1230
+ isError: result.isError || false
1231
+ };
1232
+
1233
+ } catch (err) {
1234
+ // Clean up failed connection from pool
1235
+ const cached = mcpClientPool.get(serverId);
1236
+ if (cached) {
1237
+ try {
1238
+ await cached.client.disconnect();
1239
+ } catch (e) {
1240
+ // ignore
1241
+ }
1242
+ mcpClientPool.delete(serverId);
1243
+ }
1244
+
1245
+ return {
1246
+ result: {
1247
+ error: err.message,
1248
+ code: err.code,
1249
+ data: err.data
1250
+ },
1251
+ duration: Date.now() - startTime,
1252
+ isError: true
1253
+ };
1254
+ }
1255
+ }
1256
+
1056
1257
  /**
1057
1258
  * 更新服务器状态
1058
1259
  */
@@ -1182,6 +1383,8 @@ module.exports = {
1182
1383
  validateServerSpec,
1183
1384
  // 新增功能
1184
1385
  testServer,
1386
+ getServerTools,
1387
+ callServerTool,
1185
1388
  updateServerStatus,
1186
1389
  updateServerOrder,
1187
1390
  exportServers
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Model Detector Service
3
+ * Probes model availability for channels and caches results
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+ const https = require('https');
10
+ const http = require('http');
11
+ const { URL } = require('url');
12
+
13
+ // Model priority by channel type
14
+ const MODEL_PRIORITY = {
15
+ claude: [
16
+ 'claude-haiku-3-5-20241022',
17
+ 'claude-3-5-haiku-20241022',
18
+ 'claude-sonnet-4-20250514',
19
+ 'claude-sonnet-4-5-20250929',
20
+ 'claude-opus-4-20250514'
21
+ ],
22
+ codex: ['gpt-4o-mini', 'gpt-4o', 'gpt-5-codex', 'o3'],
23
+ gemini: ['gemini-2.5-flash', 'gemini-2.5-pro']
24
+ };
25
+
26
+ // Model name normalization mapping
27
+ const MODEL_ALIASES = {
28
+ // Claude variants
29
+ 'claude-3-5-haiku': 'claude-3-5-haiku-20241022',
30
+ 'claude-haiku-3-5': 'claude-haiku-3-5-20241022',
31
+ 'claude-3-haiku': 'claude-3-5-haiku-20241022',
32
+ 'claude-sonnet-4': 'claude-sonnet-4-20250514',
33
+ 'claude-4-sonnet': 'claude-sonnet-4-20250514',
34
+ 'claude-sonnet-4-5': 'claude-sonnet-4-5-20250929',
35
+ 'claude-4-5-sonnet': 'claude-sonnet-4-5-20250929',
36
+ 'claude-opus-4': 'claude-opus-4-20250514',
37
+ 'claude-4-opus': 'claude-opus-4-20250514',
38
+
39
+ // Codex variants
40
+ 'gpt-4o': 'gpt-4o',
41
+ 'gpt4o': 'gpt-4o',
42
+ 'gpt-4-o': 'gpt-4o',
43
+ 'gpt-4o-mini': 'gpt-4o-mini',
44
+ 'gpt4o-mini': 'gpt-4o-mini',
45
+ 'gpt-5': 'gpt-5-codex',
46
+ 'gpt5': 'gpt-5-codex',
47
+ 'o3': 'o3',
48
+
49
+ // Gemini variants
50
+ 'gemini-2.5-flash': 'gemini-2.5-flash',
51
+ 'gemini-flash': 'gemini-2.5-flash',
52
+ 'gemini-2-5-flash': 'gemini-2.5-flash',
53
+ 'gemini-2.5-pro': 'gemini-2.5-pro',
54
+ 'gemini-pro': 'gemini-2.5-pro',
55
+ 'gemini-2-5-pro': 'gemini-2.5-pro'
56
+ };
57
+
58
+ const CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes
59
+ const TEST_TIMEOUT_MS = 10000; // 10 seconds per model test
60
+
61
+ /**
62
+ * Get cache file path
63
+ */
64
+ function getCacheFilePath() {
65
+ const dir = path.join(os.homedir(), '.claude', 'cc-tool');
66
+ if (!fs.existsSync(dir)) {
67
+ fs.mkdirSync(dir, { recursive: true });
68
+ }
69
+ return path.join(dir, 'channel-models.json');
70
+ }
71
+
72
+ /**
73
+ * Load model cache from disk
74
+ */
75
+ function loadModelCache() {
76
+ const cachePath = getCacheFilePath();
77
+ try {
78
+ if (fs.existsSync(cachePath)) {
79
+ const content = fs.readFileSync(cachePath, 'utf8');
80
+ return JSON.parse(content);
81
+ }
82
+ } catch (error) {
83
+ console.error('[ModelDetector] Error loading cache:', error.message);
84
+ }
85
+ return {};
86
+ }
87
+
88
+ /**
89
+ * Save model cache to disk
90
+ */
91
+ function saveModelCache(cache) {
92
+ const cachePath = getCacheFilePath();
93
+ try {
94
+ fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf8');
95
+ } catch (error) {
96
+ console.error('[ModelDetector] Error saving cache:', error.message);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Normalize model name to canonical form
102
+ * @param {string} model - Raw model name
103
+ * @returns {string} Normalized model name
104
+ */
105
+ function normalizeModelName(model) {
106
+ if (!model) return null;
107
+
108
+ const normalized = model.toLowerCase().trim();
109
+ return MODEL_ALIASES[normalized] || model;
110
+ }
111
+
112
+ /**
113
+ * Check if cache entry is still valid
114
+ * @param {Object} cacheEntry - Cache entry with lastChecked timestamp
115
+ * @returns {boolean}
116
+ */
117
+ function isCacheValid(cacheEntry) {
118
+ if (!cacheEntry || !cacheEntry.lastChecked) {
119
+ return false;
120
+ }
121
+
122
+ const age = Date.now() - new Date(cacheEntry.lastChecked).getTime();
123
+ return age < CACHE_DURATION_MS;
124
+ }
125
+
126
+ /**
127
+ * Test if a specific model is available for a channel
128
+ * @param {Object} channel - Channel configuration
129
+ * @param {string} channelType - 'claude' | 'codex' | 'gemini'
130
+ * @param {string} model - Model name to test
131
+ * @returns {Promise<boolean>}
132
+ */
133
+ async function testModelAvailability(channel, channelType, model) {
134
+ return new Promise((resolve) => {
135
+ try {
136
+ const baseUrl = channel.baseUrl.trim().replace(/\/+$/, '');
137
+ let testUrl;
138
+ let requestBody;
139
+ let headers = {
140
+ 'Content-Type': 'application/json',
141
+ 'User-Agent': 'Coding-Tool-ModelDetector/1.0'
142
+ };
143
+
144
+ // Construct API endpoint and request based on channel type
145
+ if (channelType === 'claude') {
146
+ testUrl = `${baseUrl}/v1/messages`;
147
+ headers['x-api-key'] = channel.apiKey;
148
+ headers['anthropic-version'] = '2023-06-01';
149
+ requestBody = JSON.stringify({
150
+ model: model,
151
+ max_tokens: 1,
152
+ messages: [{ role: 'user', content: 'test' }]
153
+ });
154
+ } else if (channelType === 'codex') {
155
+ testUrl = `${baseUrl}/v1/chat/completions`;
156
+ headers['Authorization'] = `Bearer ${channel.apiKey}`;
157
+ requestBody = JSON.stringify({
158
+ model: model,
159
+ max_tokens: 1,
160
+ messages: [{ role: 'user', content: 'test' }]
161
+ });
162
+ } else if (channelType === 'gemini') {
163
+ // Gemini uses API key in URL
164
+ testUrl = `${baseUrl}/v1beta/models/${model}:generateContent?key=${channel.apiKey}`;
165
+ requestBody = JSON.stringify({
166
+ contents: [{ parts: [{ text: 'test' }] }],
167
+ generationConfig: { maxOutputTokens: 1 }
168
+ });
169
+ } else {
170
+ return resolve(false);
171
+ }
172
+
173
+ const parsedUrl = new URL(testUrl);
174
+ const isHttps = parsedUrl.protocol === 'https:';
175
+ const httpModule = isHttps ? https : http;
176
+
177
+ const options = {
178
+ hostname: parsedUrl.hostname,
179
+ port: parsedUrl.port || (isHttps ? 443 : 80),
180
+ path: parsedUrl.pathname + parsedUrl.search,
181
+ method: 'POST',
182
+ timeout: TEST_TIMEOUT_MS,
183
+ headers: {
184
+ ...headers,
185
+ 'Content-Length': Buffer.byteLength(requestBody)
186
+ }
187
+ };
188
+
189
+ const req = httpModule.request(options, (res) => {
190
+ let data = '';
191
+ res.on('data', chunk => { data += chunk; });
192
+ res.on('end', () => {
193
+ // Success: 200-299 status codes
194
+ if (res.statusCode >= 200 && res.statusCode < 300) {
195
+ resolve(true);
196
+ } else if (res.statusCode === 400 || res.statusCode === 404) {
197
+ // 400/404 often means model not found or invalid
198
+ try {
199
+ const response = JSON.parse(data);
200
+ const errorMsg = (response.error?.message || '').toLowerCase();
201
+
202
+ // Check for model-specific errors
203
+ if (errorMsg.includes('model') &&
204
+ (errorMsg.includes('not found') || errorMsg.includes('invalid') || errorMsg.includes('does not exist'))) {
205
+ resolve(false);
206
+ } else {
207
+ // Other 400 errors might be auth/validation issues, not model issues
208
+ resolve(true);
209
+ }
210
+ } catch {
211
+ resolve(false);
212
+ }
213
+ } else {
214
+ // Other errors (401, 403, 500, etc.) are inconclusive
215
+ resolve(false);
216
+ }
217
+ });
218
+ });
219
+
220
+ req.on('error', () => resolve(false));
221
+ req.on('timeout', () => {
222
+ req.destroy();
223
+ resolve(false);
224
+ });
225
+
226
+ req.write(requestBody);
227
+ req.end();
228
+
229
+ } catch (error) {
230
+ resolve(false);
231
+ }
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Probe model availability for a channel
237
+ * Tests models in priority order and returns first available
238
+ * Uses 5-minute cache to avoid repeated testing
239
+ *
240
+ * @param {Object} channel - Channel configuration
241
+ * @param {string} channelType - 'claude' | 'codex' | 'gemini'
242
+ * @returns {Promise<Object>} { availableModels: string[], preferredTestModel: string|null, cached: boolean }
243
+ */
244
+ async function probeModelAvailability(channel, channelType) {
245
+ const cache = loadModelCache();
246
+ const cacheKey = channel.id;
247
+
248
+ // Return cached result if valid
249
+ if (cache[cacheKey] && isCacheValid(cache[cacheKey])) {
250
+ return {
251
+ availableModels: cache[cacheKey].availableModels || [],
252
+ preferredTestModel: cache[cacheKey].preferredTestModel || null,
253
+ cached: true,
254
+ lastChecked: cache[cacheKey].lastChecked
255
+ };
256
+ }
257
+
258
+ // Get model priority list for this channel type
259
+ const modelsToTest = MODEL_PRIORITY[channelType] || [];
260
+ if (modelsToTest.length === 0) {
261
+ console.warn(`[ModelDetector] No models defined for channel type: ${channelType}`);
262
+ return {
263
+ availableModels: [],
264
+ preferredTestModel: null,
265
+ cached: false,
266
+ lastChecked: new Date().toISOString()
267
+ };
268
+ }
269
+
270
+ console.log(`[ModelDetector] Testing models for channel ${channel.name} (${channelType})...`);
271
+
272
+ const availableModels = [];
273
+
274
+ // Test models in priority order
275
+ for (const model of modelsToTest) {
276
+ const isAvailable = await testModelAvailability(channel, channelType, model);
277
+
278
+ if (isAvailable) {
279
+ availableModels.push(model);
280
+ console.log(`[ModelDetector] ✓ ${model} available`);
281
+ } else {
282
+ console.log(`[ModelDetector] ✗ ${model} not available`);
283
+ }
284
+ }
285
+
286
+ const preferredTestModel = availableModels.length > 0 ? availableModels[0] : null;
287
+
288
+ // Update cache
289
+ const cacheEntry = {
290
+ lastChecked: new Date().toISOString(),
291
+ availableModels,
292
+ preferredTestModel
293
+ };
294
+
295
+ cache[cacheKey] = cacheEntry;
296
+ saveModelCache(cache);
297
+
298
+ console.log(`[ModelDetector] Found ${availableModels.length} available model(s) for ${channel.name}`);
299
+
300
+ return {
301
+ availableModels,
302
+ preferredTestModel,
303
+ cached: false,
304
+ lastChecked: cacheEntry.lastChecked
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Clear cache for specific channel or all channels
310
+ * @param {string|null} channelId - Channel ID to clear, or null for all
311
+ */
312
+ function clearCache(channelId = null) {
313
+ const cache = loadModelCache();
314
+
315
+ if (channelId) {
316
+ delete cache[channelId];
317
+ console.log(`[ModelDetector] Cleared cache for channel: ${channelId}`);
318
+ } else {
319
+ // Clear all
320
+ Object.keys(cache).forEach(key => delete cache[key]);
321
+ console.log('[ModelDetector] Cleared all model cache');
322
+ }
323
+
324
+ saveModelCache(cache);
325
+ }
326
+
327
+ /**
328
+ * Get cached model info without probing
329
+ * @param {string} channelId - Channel ID
330
+ * @returns {Object|null} Cache entry or null if not found/expired
331
+ */
332
+ function getCachedModelInfo(channelId) {
333
+ const cache = loadModelCache();
334
+ const entry = cache[channelId];
335
+
336
+ if (entry && isCacheValid(entry)) {
337
+ return entry;
338
+ }
339
+
340
+ return null;
341
+ }
342
+
343
+ module.exports = {
344
+ probeModelAvailability,
345
+ testModelAvailability,
346
+ normalizeModelName,
347
+ clearCache,
348
+ getCachedModelInfo,
349
+ MODEL_PRIORITY
350
+ };