converse-mcp-server 2.9.7 → 2.10.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.
package/bin/converse.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "converse-mcp-server",
3
- "version": "2.9.7",
3
+ "version": "2.10.0",
4
4
  "description": "Converse MCP Server - Converse with other LLMs with chat and consensus tools",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -11,6 +11,60 @@
11
11
  "engines": {
12
12
  "node": ">=20.0.0"
13
13
  },
14
+ "keywords": [
15
+ "mcp",
16
+ "server",
17
+ "ai",
18
+ "chat",
19
+ "consensus",
20
+ "openai",
21
+ "google",
22
+ "gemini",
23
+ "grok"
24
+ ],
25
+ "author": "Converse MCP Server",
26
+ "license": "MIT",
27
+ "homepage": "https://github.com/FallDownTheSystem/converse#readme",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/FallDownTheSystem/converse.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/FallDownTheSystem/converse/issues"
34
+ },
35
+ "files": [
36
+ "src/",
37
+ "bin/",
38
+ "docs/",
39
+ "README.md",
40
+ ".env.example"
41
+ ],
42
+ "dependencies": {
43
+ "@anthropic-ai/claude-agent-sdk": "^0.2.23",
44
+ "@anthropic-ai/sdk": "^0.72.0",
45
+ "@google/genai": "^1.38.0",
46
+ "@mistralai/mistralai": "^1.13.0",
47
+ "@modelcontextprotocol/sdk": "^1.25.3",
48
+ "@openai/codex-sdk": "^0.92.0",
49
+ "ai": "^6.0.62",
50
+ "ai-sdk-provider-gemini-cli": "^2.0.1",
51
+ "cors": "^2.8.6",
52
+ "dotenv": "^17.2.3",
53
+ "express": "^5.2.1",
54
+ "lru-cache": "^11.2.5",
55
+ "nanoid": "^5.1.6",
56
+ "openai": "^6.17.0",
57
+ "p-limit": "^7.2.0",
58
+ "vite": "^7.3.1"
59
+ },
60
+ "devDependencies": {
61
+ "@vitest/coverage-v8": "^4.0.18",
62
+ "cross-env": "^10.1.0",
63
+ "eslint": "^9.39.2",
64
+ "prettier": "^3.8.1",
65
+ "rimraf": "^6.1.2",
66
+ "vitest": "^4.0.18"
67
+ },
14
68
  "scripts": {
15
69
  "kill-server": "node scripts/kill-server.js",
16
70
  "start": "npm run kill-server && node src/index.js",
@@ -64,59 +118,5 @@
64
118
  "validate:fix": "node scripts/validate.js --fix",
65
119
  "validate:fast": "node scripts/validate.js --skip-tests --skip-lint",
66
120
  "precommit": "npm run validate"
67
- },
68
- "keywords": [
69
- "mcp",
70
- "server",
71
- "ai",
72
- "chat",
73
- "consensus",
74
- "openai",
75
- "google",
76
- "gemini",
77
- "grok"
78
- ],
79
- "author": "Converse MCP Server",
80
- "license": "MIT",
81
- "homepage": "https://github.com/FallDownTheSystem/converse#readme",
82
- "repository": {
83
- "type": "git",
84
- "url": "git+https://github.com/FallDownTheSystem/converse.git"
85
- },
86
- "bugs": {
87
- "url": "https://github.com/FallDownTheSystem/converse/issues"
88
- },
89
- "files": [
90
- "src/",
91
- "bin/",
92
- "docs/",
93
- "README.md",
94
- ".env.example"
95
- ],
96
- "dependencies": {
97
- "@anthropic-ai/claude-agent-sdk": "^0.1.51",
98
- "@anthropic-ai/sdk": "^0.70.0",
99
- "@google/genai": "^1.30.0",
100
- "@mistralai/mistralai": "^1.10.0",
101
- "@modelcontextprotocol/sdk": "^1.22.0",
102
- "@openai/codex-sdk": "^0.63.0",
103
- "ai": "^5.0.101",
104
- "ai-sdk-provider-gemini-cli": "^1.4.0",
105
- "cors": "^2.8.5",
106
- "dotenv": "^17.2.3",
107
- "express": "^5.1.0",
108
- "lru-cache": "^11.2.2",
109
- "nanoid": "^5.1.6",
110
- "openai": "^6.9.1",
111
- "p-limit": "^7.2.0",
112
- "vite": "^7.2.2"
113
- },
114
- "devDependencies": {
115
- "@vitest/coverage-v8": "^4.0.10",
116
- "cross-env": "^10.1.0",
117
- "eslint": "^9.39.1",
118
- "prettier": "^3.6.2",
119
- "rimraf": "^6.1.0",
120
- "vitest": "^4.0.10"
121
121
  }
122
- }
122
+ }
@@ -11,6 +11,7 @@ import { promises as fs } from 'fs';
11
11
  import path from 'path';
12
12
  import { debugLog, debugError } from '../utils/console.js';
13
13
  import { ConverseMCPError, ERROR_CODES } from '../utils/errorHandler.js';
14
+ import { isSafeIdSegment } from '../utils/idValidation.js';
14
15
 
15
16
  /**
16
17
  * File cache specific error class
@@ -149,6 +150,14 @@ export class FileCache extends FileCacheInterface {
149
150
  * @private
150
151
  */
151
152
  getJobDir(jobId) {
153
+ if (!isSafeIdSegment(jobId)) {
154
+ throw new FileCacheError(
155
+ 'Job ID contains unsafe characters',
156
+ ERROR_CODES.VALIDATION_ERROR,
157
+ { jobId },
158
+ );
159
+ }
160
+
152
161
  const today = new Date().toISOString().split('T')[0]; // yyyy-mm-dd
153
162
  return path.join(this.baseDir, today, jobId);
154
163
  }
@@ -201,7 +210,15 @@ export class FileCache extends FileCacheInterface {
201
210
  if (!jobId || typeof jobId !== 'string') {
202
211
  throw new FileCacheError(
203
212
  'Job ID must be a non-empty string',
204
- ERROR_CODES.CACHE_WRITE_FAILED,
213
+ ERROR_CODES.VALIDATION_ERROR,
214
+ { jobId },
215
+ );
216
+ }
217
+
218
+ if (!isSafeIdSegment(jobId)) {
219
+ throw new FileCacheError(
220
+ 'Job ID contains unsafe characters',
221
+ ERROR_CODES.VALIDATION_ERROR,
205
222
  { jobId },
206
223
  );
207
224
  }
@@ -265,7 +282,15 @@ export class FileCache extends FileCacheInterface {
265
282
  if (!jobId || typeof jobId !== 'string') {
266
283
  throw new FileCacheError(
267
284
  'Job ID must be a non-empty string',
268
- ERROR_CODES.CACHE_WRITE_FAILED,
285
+ ERROR_CODES.VALIDATION_ERROR,
286
+ { jobId },
287
+ );
288
+ }
289
+
290
+ if (!isSafeIdSegment(jobId)) {
291
+ throw new FileCacheError(
292
+ 'Job ID contains unsafe characters',
293
+ ERROR_CODES.VALIDATION_ERROR,
269
294
  { jobId },
270
295
  );
271
296
  }
@@ -324,7 +349,15 @@ export class FileCache extends FileCacheInterface {
324
349
  if (!jobId || typeof jobId !== 'string') {
325
350
  throw new FileCacheError(
326
351
  'Job ID must be a non-empty string',
327
- ERROR_CODES.CACHE_READ_FAILED,
352
+ ERROR_CODES.VALIDATION_ERROR,
353
+ { jobId },
354
+ );
355
+ }
356
+
357
+ if (!isSafeIdSegment(jobId)) {
358
+ throw new FileCacheError(
359
+ 'Job ID contains unsafe characters',
360
+ ERROR_CODES.VALIDATION_ERROR,
328
361
  { jobId },
329
362
  );
330
363
  }
@@ -7,7 +7,7 @@
7
7
  * Key features:
8
8
  * - Uses OAuth authentication from Gemini CLI (no API keys needed)
9
9
  * - Supports gemini-3-pro-preview model via Google Cloud Code endpoints
10
- * - Uses AI SDK v5 standard interfaces (generateText/streamText)
10
+ * - Uses AI SDK v6 standard interfaces (generateText/streamText)
11
11
  * - Compatible with both chat and consensus tools
12
12
  *
13
13
  * Authentication:
@@ -156,12 +156,13 @@ async function* createStreamingGenerator(
156
156
 
157
157
  // Yield usage event
158
158
  if (usage) {
159
+ const tokens = extractUsageTokens(usage);
159
160
  yield {
160
161
  type: 'usage',
161
162
  usage: {
162
- input_tokens: usage.promptTokens || 0,
163
- output_tokens: usage.completionTokens || 0,
164
- total_tokens: usage.totalTokens || 0,
163
+ input_tokens: tokens.input,
164
+ output_tokens: tokens.output,
165
+ total_tokens: tokens.total,
165
166
  cached_input_tokens: 0,
166
167
  },
167
168
  };
@@ -171,7 +172,7 @@ async function* createStreamingGenerator(
171
172
  yield {
172
173
  type: 'end',
173
174
  stop_reason: mapFinishReason(finishReason),
174
- finish_reason: finishReason,
175
+ finish_reason: getRawFinishReason(finishReason),
175
176
  };
176
177
  } catch (error) {
177
178
  if (signal?.aborted) {
@@ -182,10 +183,16 @@ async function* createStreamingGenerator(
182
183
  }
183
184
 
184
185
  /**
185
- * Map AI SDK finish reasons to our StopReasons enum
186
+ * Map AI SDK v6 finish reasons to our StopReasons enum
187
+ * AI SDK v6 returns finishReason as { unified: 'stop', raw: 'STOP' }
188
+ * @param {Object|string} finishReason - The finish reason (object in v6, string in v5)
186
189
  */
187
190
  function mapFinishReason(finishReason) {
188
- switch (finishReason) {
191
+ // AI SDK v6 returns an object with 'unified' property
192
+ const reason =
193
+ typeof finishReason === 'object' ? finishReason?.unified : finishReason;
194
+
195
+ switch (reason) {
189
196
  case 'stop':
190
197
  return StopReasons.STOP;
191
198
  case 'length':
@@ -203,12 +210,51 @@ function mapFinishReason(finishReason) {
203
210
  }
204
211
 
205
212
  /**
206
- * Convert messages from Converse internal format to AI SDK v5 ModelMessage format
213
+ * Extract raw finish reason string for metadata
214
+ * AI SDK v6 returns finishReason as { unified: 'stop', raw: 'STOP' }
215
+ * @param {Object|string} finishReason - The finish reason
216
+ * @returns {string} The raw finish reason string
217
+ */
218
+ function getRawFinishReason(finishReason) {
219
+ if (typeof finishReason === 'object') {
220
+ return finishReason?.unified || finishReason?.raw || 'stop';
221
+ }
222
+ return finishReason || 'stop';
223
+ }
224
+
225
+ /**
226
+ * Extract usage tokens from AI SDK v6 hierarchical structure
227
+ * AI SDK v6 usage: { inputTokens: { total: N }, outputTokens: { total: N } }
228
+ * AI SDK v5 usage: { promptTokens: N, completionTokens: N, totalTokens: N }
229
+ * @param {Object} usage - The usage object from AI SDK
230
+ * @returns {Object} Normalized token counts
231
+ */
232
+ function extractUsageTokens(usage) {
233
+ if (!usage) {
234
+ return { input: 0, output: 0, total: 0 };
235
+ }
236
+
237
+ // AI SDK v6 hierarchical structure
238
+ if (usage.inputTokens && typeof usage.inputTokens === 'object') {
239
+ const input = usage.inputTokens.total || 0;
240
+ const output = usage.outputTokens?.total || 0;
241
+ return { input, output, total: input + output };
242
+ }
243
+
244
+ // AI SDK flat structure (backwards compatibility)
245
+ const input = usage.promptTokens || usage.inputTokens || 0;
246
+ const output = usage.completionTokens || usage.outputTokens || 0;
247
+ const total = usage.totalTokens || input + output;
248
+ return { input, output, total };
249
+ }
250
+
251
+ /**
252
+ * Convert messages from Converse internal format to AI SDK ModelMessage format
207
253
  *
208
254
  * Converse format (used by other providers like Anthropic):
209
255
  * - Images: { type: 'image', source: { type: 'base64', media_type: '...', data: '...' } }
210
256
  *
211
- * AI SDK v5 ModelMessage format (required by generateText/streamText):
257
+ * AI SDK ModelMessage format (required by generateText/streamText):
212
258
  * - Images: { type: 'image', image: '...' } (base64 string, Buffer, or URL)
213
259
  * - Text: { type: 'text', text: '...' }
214
260
  *
@@ -216,7 +262,7 @@ function mapFinishReason(finishReason) {
216
262
  * We must use 'image' property (not 'data') for the AI SDK to accept the message.
217
263
  *
218
264
  * @param {Array} messages - Messages in Converse internal format
219
- * @returns {Array} Messages in AI SDK v5 ModelMessage format
265
+ * @returns {Array} Messages in AI SDK ModelMessage format
220
266
  */
221
267
  function convertToModelMessages(messages) {
222
268
  return messages.map((message) => {
@@ -233,11 +279,11 @@ function convertToModelMessages(messages) {
233
279
  return part;
234
280
  }
235
281
 
236
- // Convert image from Converse format to AI SDK v5 ModelMessage format
282
+ // Convert image from Converse format to AI SDK ModelMessage format
237
283
  if (part.type === 'image' && part.source) {
238
284
  return {
239
285
  type: 'image',
240
- image: part.source.data, // AI SDK v5 expects 'image' property (not 'data')
286
+ image: part.source.data, // AI SDK expects 'image' property (not 'data')
241
287
  };
242
288
  }
243
289
 
@@ -329,7 +375,7 @@ export const geminiCliProvider = {
329
375
  // Create model instance with SDK model name
330
376
  const modelInstance = gemini(sdkModelName);
331
377
 
332
- // Convert messages from Converse format to AI SDK v5 ModelMessage format
378
+ // Convert messages from Converse format to AI SDK ModelMessage format
333
379
  const convertedMessages = convertToModelMessages(messages);
334
380
 
335
381
  // Build AI SDK options
@@ -377,9 +423,12 @@ export const geminiCliProvider = {
377
423
 
378
424
  const responseTime = Date.now() - startTime;
379
425
 
380
- // Extract content from AI SDK v5 response format
426
+ // Extract content from AI SDK v6 response format
381
427
  const content = result.content?.[0]?.text || result.text || '';
382
428
 
429
+ // Extract usage tokens with AI SDK v6 compatibility
430
+ const tokens = extractUsageTokens(result.usage);
431
+
383
432
  return {
384
433
  content,
385
434
  stop_reason: mapFinishReason(result.finishReason),
@@ -389,14 +438,14 @@ export const geminiCliProvider = {
389
438
  model,
390
439
  usage: result.usage
391
440
  ? {
392
- input_tokens: result.usage.promptTokens || 0,
393
- output_tokens: result.usage.completionTokens || 0,
394
- total_tokens: result.usage.totalTokens || 0,
441
+ input_tokens: tokens.input,
442
+ output_tokens: tokens.output,
443
+ total_tokens: tokens.total,
395
444
  cached_input_tokens: 0,
396
445
  }
397
446
  : null,
398
447
  response_time_ms: responseTime,
399
- finish_reason: result.finishReason || 'stop',
448
+ finish_reason: getRawFinishReason(result.finishReason),
400
449
  },
401
450
  };
402
451
  } catch (error) {
package/src/tools/chat.js CHANGED
@@ -21,6 +21,7 @@ import { applyTokenLimit, getTokenLimit } from '../utils/tokenLimiter.js';
21
21
  import { validateAllPaths } from '../utils/fileValidator.js';
22
22
  import { SummarizationService } from '../services/summarizationService.js';
23
23
  import { exportConversation } from '../utils/conversationExporter.js';
24
+ import { isRecoverableError, retryWithBackoff } from '../utils/errorHandler.js';
24
25
 
25
26
  const logger = createLogger('chat');
26
27
 
@@ -265,12 +266,14 @@ export async function chatTool(args, dependencies) {
265
266
 
266
267
  messages.push(userMessage);
267
268
 
268
- // Select provider
269
+ // Select provider(s)
269
270
  let selectedProvider;
270
271
  let providerName;
271
272
 
273
+ const providerCandidates = [];
274
+
272
275
  if (model === 'auto') {
273
- // Auto-select first available provider in priority order
276
+ // Auto-select providers in priority order
274
277
  // Prioritize subscription-based providers (codex, gemini-cli, claude) over API-key providers
275
278
  const providerOrder = [
276
279
  'codex',
@@ -288,20 +291,17 @@ export async function chatTool(args, dependencies) {
288
291
  for (const name of providerOrder) {
289
292
  const provider = providers[name];
290
293
  if (provider && provider.isAvailable && provider.isAvailable(config)) {
291
- providerName = name;
292
- selectedProvider = provider;
293
- break;
294
+ providerCandidates.push({ name, provider });
294
295
  }
295
296
  }
296
297
 
297
- if (!providerName) {
298
+ if (providerCandidates.length === 0) {
298
299
  return createToolError(
299
300
  'No providers available. Please configure at least one API key.',
300
301
  );
301
302
  }
302
303
  } else {
303
304
  // Use specified provider/model
304
- // Try to map model to provider
305
305
  providerName = mapModelToProvider(model, providers);
306
306
  selectedProvider = providers[providerName];
307
307
 
@@ -314,32 +314,60 @@ export async function chatTool(args, dependencies) {
314
314
  `Provider ${providerName} is not available. Check API key configuration.`,
315
315
  );
316
316
  }
317
- }
318
317
 
319
- // Resolve model name and prepare provider options
320
- const resolvedModel = resolveAutoModel(model, providerName);
321
- const providerOptions = {
322
- model: resolvedModel,
323
- temperature,
324
- reasoning_effort,
325
- verbosity,
326
- use_websearch,
327
- config,
328
- continuation_id, // Pass for thread resumption
329
- continuationStore, // Pass store for state management
330
- };
318
+ providerCandidates.push({ name: providerName, provider: selectedProvider });
319
+ }
331
320
 
332
- // Call provider
321
+ // Call provider with recovery (retry and, for auto, failover)
333
322
  let response;
334
323
  const startTime = Date.now();
335
- try {
336
- response = await selectedProvider.invoke(messages, providerOptions);
337
- } catch (error) {
338
- logger.error('Provider error', {
339
- error,
340
- data: { provider: providerName },
341
- });
342
- return createToolError(`Provider error: ${error.message}`);
324
+ let lastError;
325
+ let resolvedModel;
326
+
327
+ for (const candidate of providerCandidates) {
328
+ providerName = candidate.name;
329
+ selectedProvider = candidate.provider;
330
+
331
+ // Resolve model name and prepare provider options
332
+ resolvedModel = resolveAutoModel(model, providerName);
333
+ const providerOptions = {
334
+ model: resolvedModel,
335
+ temperature,
336
+ reasoning_effort,
337
+ verbosity,
338
+ use_websearch,
339
+ config,
340
+ continuation_id, // Pass for thread resumption
341
+ continuationStore, // Pass store for state management
342
+ };
343
+
344
+ try {
345
+ response = await retryWithBackoff(
346
+ () => selectedProvider.invoke(messages, providerOptions),
347
+ getProviderRetryOptions(config, providerName),
348
+ );
349
+ break;
350
+ } catch (error) {
351
+ lastError = error;
352
+ logger.error('Provider error', {
353
+ error,
354
+ data: { provider: providerName },
355
+ });
356
+
357
+ if (
358
+ model !== 'auto' ||
359
+ !shouldFailoverToNextProvider(error) ||
360
+ candidate === providerCandidates[providerCandidates.length - 1]
361
+ ) {
362
+ return createToolError(`Provider error: ${error.message}`);
363
+ }
364
+ }
365
+ }
366
+
367
+ if (!response) {
368
+ return createToolError(
369
+ `Provider error: ${(lastError && lastError.message) || 'Unknown error'}`,
370
+ );
343
371
  }
344
372
  const executionTime = (Date.now() - startTime) / 1000; // Convert to seconds
345
373
 
@@ -472,6 +500,29 @@ function resolveAutoModel(model, providerName) {
472
500
  return defaults[providerName] || 'gpt-5';
473
501
  }
474
502
 
503
+ function getProviderRetryOptions(config, providerName) {
504
+ const nodeEnv = config?.environment?.nodeEnv || process.env.NODE_ENV;
505
+ const isTest = nodeEnv === 'test';
506
+
507
+ return {
508
+ retries: isTest ? 1 : 3,
509
+ delay: isTest ? 0 : 500,
510
+ maxDelay: isTest ? 0 : 10000,
511
+ operation: `provider-invoke:${providerName}`,
512
+ };
513
+ }
514
+
515
+ function shouldFailoverToNextProvider(error) {
516
+ if (isRecoverableError(error)) {
517
+ return true;
518
+ }
519
+
520
+ const message = (error && error.message) || '';
521
+ return /(api key|authentication|unauthorized|forbidden|invalid|not available)/i.test(
522
+ message,
523
+ );
524
+ }
525
+
475
526
  export function mapModelToProvider(model, providers) {
476
527
  const modelLower = model.toLowerCase();
477
528
 
@@ -17,6 +17,7 @@ import {
17
17
  formatJobListHumanReadable,
18
18
  formatConversationHistory,
19
19
  } from '../utils/formatStatus.js';
20
+ import { isSafeIdSegment } from '../utils/idValidation.js';
20
21
 
21
22
  const logger = createLogger('check-status');
22
23
 
@@ -36,8 +37,22 @@ export async function checkStatusTool(args, dependencies) {
36
37
  const { continuation_id, full_history = false } = args;
37
38
 
38
39
  // Validate arguments
39
- if (continuation_id && typeof continuation_id !== 'string') {
40
- return createToolError('continuation_id must be a string');
40
+ if (continuation_id !== undefined) {
41
+ if (typeof continuation_id !== 'string') {
42
+ return createToolError('continuation_id must be a string');
43
+ }
44
+
45
+ if (continuation_id.length === 0) {
46
+ return createToolError(
47
+ 'Invalid continuation_id: must be a non-empty string',
48
+ );
49
+ }
50
+
51
+ if (!isSafeIdSegment(continuation_id)) {
52
+ return createToolError(
53
+ 'Invalid continuation_id: contains unsafe characters',
54
+ );
55
+ }
41
56
  }
42
57
 
43
58
  const asyncJobStore = getAsyncJobStore();
@@ -0,0 +1,32 @@
1
+ /**
2
+ * ID validation helpers
3
+ *
4
+ * These are intentionally conservative and are primarily used to ensure IDs that
5
+ * are used as filesystem path segments cannot escape their intended directory.
6
+ */
7
+
8
+ /**
9
+ * Check whether an ID is safe to use as a single filesystem path segment.
10
+ *
11
+ * Allowed characters: A–Z a–z 0–9 _ -
12
+ * Disallowed: path separators, dots, whitespace, and other punctuation.
13
+ *
14
+ * @param {unknown} id
15
+ * @param {object} [options]
16
+ * @param {number} [options.maxLength]
17
+ * @returns {boolean}
18
+ */
19
+ export function isSafeIdSegment(id, options = {}) {
20
+ const { maxLength = 128 } = options;
21
+
22
+ if (typeof id !== 'string') {
23
+ return false;
24
+ }
25
+
26
+ if (id.length === 0 || id.length > maxLength) {
27
+ return false;
28
+ }
29
+
30
+ return /^[A-Za-z0-9_-]+$/.test(id);
31
+ }
32
+