converse-mcp-server 4.5.2 → 4.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.
- package/.env.example +7 -0
- package/README.md +5 -3
- package/docs/API.md +43 -19
- package/docs/PROVIDERS.md +6 -4
- package/package.json +10 -10
- package/src/config.js +16 -1
- package/src/decisionProviders/formats.js +229 -0
- package/src/decisionProviders/{systemOne.js → http.js} +56 -25
- package/src/decisionProviders/index.js +167 -73
- package/src/providers/anthropic.js +32 -3
- package/src/providers/claude.js +19 -0
- package/src/providers/copilot.js +25 -1
- package/src/tools/decide.js +138 -29
package/src/tools/decide.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Decide Tool - System One decision models
|
|
3
3
|
*
|
|
4
|
-
* Asks a decision model (TypeSafe's Jev
|
|
5
|
-
*
|
|
6
|
-
* probability
|
|
7
|
-
* never generate text, so this is a
|
|
4
|
+
* Asks a decision model (OpenAI's gpt-6-luna, TypeSafe's Jev, Cloudflare's
|
|
5
|
+
* Clef) typed questions about a state and returns calibrated answers: a
|
|
6
|
+
* probability for yes/no questions, a probability distribution for choices
|
|
7
|
+
* and rubric scores. Decision models never generate text, so this is a
|
|
8
|
+
* separate tool rather than a chat mode. Questions and answers use the System
|
|
9
|
+
* One schema whatever the host; providers translate.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
import { createToolResponse, createToolError } from './index.js';
|
|
11
|
-
import { resolveDecisionModel } from '../decisionProviders/index.js';
|
|
12
|
-
import {
|
|
13
|
+
import { askDecisionModel, resolveDecisionModel } from '../decisionProviders/index.js';
|
|
14
|
+
import { isLabeledLevel } from '../decisionProviders/formats.js';
|
|
13
15
|
import { validateAllPaths } from '../utils/fileValidator.js';
|
|
14
16
|
import { createLogger } from '../utils/logger.js';
|
|
15
17
|
|
|
@@ -89,12 +91,43 @@ function validateQuestion(key, question) {
|
|
|
89
91
|
if (criteria.length < MIN_SCORE_LEVELS || criteria.length > MAX_SCORE_LEVELS) {
|
|
90
92
|
return `${at}.criteria must have ${MIN_SCORE_LEVELS} to ${MAX_SCORE_LEVELS} levels (got ${criteria.length}).`;
|
|
91
93
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
for (const [index, level] of criteria.entries()) {
|
|
95
|
+
const error = validateScoreLevel(level);
|
|
96
|
+
if (error) return `${at}.criteria[${index}] ${error}`;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validateScoreLevel(level) {
|
|
102
|
+
if (!isLabeledLevel(level)) {
|
|
103
|
+
return isCriterionValue(level) ? null : 'must be a non-empty description or { "label", "description" }.';
|
|
104
|
+
}
|
|
105
|
+
const unknown = Object.keys(level).filter((f) => f !== 'label' && f !== 'description');
|
|
106
|
+
if (unknown.length) return `has unknown field(s): ${unknown.join(', ')}. A labeled level takes "label" and "description".`;
|
|
107
|
+
if (typeof level.label !== 'string' || !level.label.trim()) return 'needs a non-empty "label" string.';
|
|
108
|
+
if (level.description !== undefined && !isCriterionValue(level.description)) {
|
|
109
|
+
return '"description" must be a non-empty string, object, or array.';
|
|
94
110
|
}
|
|
95
111
|
return null;
|
|
96
112
|
}
|
|
97
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Show each labeled level's short label in the score legend, whatever the
|
|
116
|
+
* host echoed back: System One hosts only see the flattened description.
|
|
117
|
+
*/
|
|
118
|
+
function labelLegends(answers, questions) {
|
|
119
|
+
return Object.fromEntries(
|
|
120
|
+
Object.entries(answers).map(([key, answer]) => {
|
|
121
|
+
const levels = questions[key]?.type === 'score' ? questions[key].criteria : null;
|
|
122
|
+
if (answer?.type !== 'score' || !levels?.some(isLabeledLevel)) return [key, answer];
|
|
123
|
+
const legend = Object.fromEntries(
|
|
124
|
+
levels.map((level, i) => [String(i), isLabeledLevel(level) ? level.label : answer.legend?.[i] ?? level]),
|
|
125
|
+
);
|
|
126
|
+
return [key, { ...answer, legend }];
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
98
131
|
/**
|
|
99
132
|
* Check questions locally: the API reports schema faults as nested validation
|
|
100
133
|
* dumps, and a local message is both clearer and free.
|
|
@@ -135,7 +168,7 @@ async function loadFiles(files, contextProcessor, config) {
|
|
|
135
168
|
if (file.type === 'error') {
|
|
136
169
|
problems.push(`${file.originalPath}: ${file.error}`);
|
|
137
170
|
} else if (file.type !== 'text') {
|
|
138
|
-
problems.push(`${file.originalPath}:
|
|
171
|
+
problems.push(`${file.originalPath}: not a text file (pass images in "images")`);
|
|
139
172
|
} else {
|
|
140
173
|
contents[file.originalPath] = file.content;
|
|
141
174
|
}
|
|
@@ -144,6 +177,52 @@ async function loadFiles(files, contextProcessor, config) {
|
|
|
144
177
|
return problems.length ? { error: problems.join('; ') } : { contents };
|
|
145
178
|
}
|
|
146
179
|
|
|
180
|
+
// The formats every image-capable host accepts (Clef takes no GIF or BMP).
|
|
181
|
+
const IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
|
|
182
|
+
const DATA_URL = /^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/i;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Load images from paths or base64 data URLs into `{ mimeType, base64 }`.
|
|
186
|
+
* Every image must load: judging a partial set would change the answer.
|
|
187
|
+
*/
|
|
188
|
+
async function loadImages(images, contextProcessor, config) {
|
|
189
|
+
const loaded = new Array(images.length);
|
|
190
|
+
const problems = [];
|
|
191
|
+
const paths = [];
|
|
192
|
+
images.forEach((image, i) => {
|
|
193
|
+
if (!image.startsWith('data:')) {
|
|
194
|
+
paths.push(i);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const match = image.match(DATA_URL);
|
|
198
|
+
if (!match) problems.push(`image ${i + 1}: not a base64 data URL`);
|
|
199
|
+
else loaded[i] = { mimeType: match[1].toLowerCase(), base64: match[2].replace(/\s/g, '') };
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (paths.length > 0) {
|
|
203
|
+
const pathList = paths.map((i) => images[i]);
|
|
204
|
+
const validation = await validateAllPaths({ images: pathList }, { clientCwd: config?.server?.client_cwd });
|
|
205
|
+
if (!validation.valid) return { error: validation.errors.join('; ') };
|
|
206
|
+
const result = await contextProcessor.processUnifiedContext(
|
|
207
|
+
{ images: pathList },
|
|
208
|
+
{ enforceSecurityCheck: false, skipSecurityCheck: true, clientCwd: config?.server?.client_cwd },
|
|
209
|
+
);
|
|
210
|
+
result.images.forEach((file, j) => {
|
|
211
|
+
if (file.type === 'error') problems.push(`${file.originalPath}: ${file.error}`);
|
|
212
|
+
else if (file.type !== 'image') problems.push(`${file.originalPath}: not an image file`);
|
|
213
|
+
else loaded[paths[j]] = { mimeType: file.mimeType, base64: file.content };
|
|
214
|
+
});
|
|
215
|
+
if (result.errors?.length) problems.push(...result.errors.map((e) => e.message));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
loaded.forEach((image, i) => {
|
|
219
|
+
if (image && !IMAGE_MIME_TYPES.includes(image.mimeType)) {
|
|
220
|
+
problems.push(`image ${i + 1}: ${image.mimeType} is not supported (use PNG, JPEG, or WebP)`);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
return problems.length ? { error: problems.join('; ') } : { images: loaded };
|
|
224
|
+
}
|
|
225
|
+
|
|
147
226
|
function fixed(n) {
|
|
148
227
|
return typeof n === 'number' ? n.toFixed(2) : String(n);
|
|
149
228
|
}
|
|
@@ -174,6 +253,9 @@ function summarizeAnswer(key, answer) {
|
|
|
174
253
|
const label = (k) => (answer.legend?.[k] ? `${k} ${answer.legend[k]}` : k);
|
|
175
254
|
return `- ${key} (score): ${fixed(answer.score)}${range} · confidence ${fixed(answer.confidence)} · ${byProbability(answer.probabilities, label)}`;
|
|
176
255
|
}
|
|
256
|
+
if (type === 'refusal') {
|
|
257
|
+
return `- ${key} (refusal): the model declined to answer`;
|
|
258
|
+
}
|
|
177
259
|
return `- ${key} (${type}): ${JSON.stringify(answer)}`;
|
|
178
260
|
}
|
|
179
261
|
|
|
@@ -212,18 +294,22 @@ function formatResult(response, candidate, failures) {
|
|
|
212
294
|
* @param {object} args.questions - Named typed questions
|
|
213
295
|
* @param {string} [args.model] - Model spec, default "auto"
|
|
214
296
|
* @param {string[]} [args.files] - Text files added to the state
|
|
297
|
+
* @param {string[]} [args.images] - Image paths or base64 data URLs
|
|
215
298
|
* @param {object} dependencies - Injected dependencies (config, contextProcessor, signal)
|
|
216
299
|
* @returns {Promise<object>} MCP tool response
|
|
217
300
|
*/
|
|
218
301
|
export async function decideTool(args, dependencies) {
|
|
219
302
|
const { config, contextProcessor, signal } = dependencies;
|
|
220
|
-
const { state, questions, model = 'auto', files = [] } = args;
|
|
303
|
+
const { state, questions, model = 'auto', files = [], images = [] } = args;
|
|
221
304
|
|
|
222
305
|
if (!Array.isArray(files) || !files.every((f) => typeof f === 'string' && f.trim())) {
|
|
223
306
|
return createToolError('"files" must be an array of file paths.');
|
|
224
307
|
}
|
|
225
|
-
if (
|
|
226
|
-
return createToolError('
|
|
308
|
+
if (!Array.isArray(images) || !images.every((i) => typeof i === 'string' && i.trim())) {
|
|
309
|
+
return createToolError('"images" must be an array of image paths or base64 data URLs.');
|
|
310
|
+
}
|
|
311
|
+
if (state === undefined && files.length === 0 && images.length === 0) {
|
|
312
|
+
return createToolError('Provide "state", "files", or "images": there is nothing to judge.');
|
|
227
313
|
}
|
|
228
314
|
const stateError = state === undefined ? null : validateState(state);
|
|
229
315
|
if (stateError) return createToolError(stateError);
|
|
@@ -232,6 +318,13 @@ export async function decideTool(args, dependencies) {
|
|
|
232
318
|
|
|
233
319
|
const route = resolveDecisionModel(model, config);
|
|
234
320
|
if (route.status !== 'ok') return createToolError(route.error);
|
|
321
|
+
const candidates = route.candidates.filter((c) => c.maxImages >= images.length);
|
|
322
|
+
if (candidates.length === 0) {
|
|
323
|
+
return createToolError(
|
|
324
|
+
`"${model}" cannot take ${images.length} image(s) on any configured host. ` +
|
|
325
|
+
'Images work with gpt-6-luna (OpenAI) and clef / clef-flash (Cloudflare, up to 4); Jev and OpenRouter routes are text only.',
|
|
326
|
+
);
|
|
327
|
+
}
|
|
235
328
|
|
|
236
329
|
let finalState = state;
|
|
237
330
|
if (files.length > 0) {
|
|
@@ -239,22 +332,28 @@ export async function decideTool(args, dependencies) {
|
|
|
239
332
|
if (loaded.error) return createToolError(`Could not load files: ${loaded.error}`);
|
|
240
333
|
finalState = state === undefined ? { files: loaded.contents } : { input: state, files: loaded.contents };
|
|
241
334
|
}
|
|
335
|
+
let loadedImages = [];
|
|
336
|
+
if (images.length > 0) {
|
|
337
|
+
const loaded = await loadImages(images, contextProcessor, config);
|
|
338
|
+
if (loaded.error) return createToolError(`Could not load images: ${loaded.error}`);
|
|
339
|
+
loadedImages = loaded.images;
|
|
340
|
+
}
|
|
242
341
|
|
|
243
342
|
const failures = [];
|
|
244
|
-
|
|
343
|
+
// A request fault on one host repeats on every host of the same model, but
|
|
344
|
+
// another model behind a different API may still accept the request.
|
|
345
|
+
const rejectedFamilies = new Set();
|
|
346
|
+
for (const candidate of candidates) {
|
|
347
|
+
if (rejectedFamilies.has(candidate.family)) continue;
|
|
245
348
|
try {
|
|
246
|
-
const response = await
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
body: { model: candidate.model, state: finalState, questions },
|
|
250
|
-
signal,
|
|
251
|
-
});
|
|
252
|
-
return createToolResponse(formatResult(response, candidate, failures));
|
|
349
|
+
const response = await askDecisionModel(candidate, { state: finalState, questions, images: loadedImages, config, signal });
|
|
350
|
+
const answers = labelLegends(response.answers, questions);
|
|
351
|
+
return createToolResponse(formatResult({ ...response, answers }, candidate, failures));
|
|
253
352
|
} catch (error) {
|
|
254
353
|
if (signal?.aborted) return createToolError('Decision request cancelled.');
|
|
255
354
|
logger.error('Decision request failed', { provider: candidate.providerName, model: candidate.model, error: error.message });
|
|
256
355
|
failures.push({ provider: candidate.providerName, message: error.message });
|
|
257
|
-
if (error.terminal)
|
|
356
|
+
if (error.terminal) rejectedFamilies.add(candidate.family);
|
|
258
357
|
}
|
|
259
358
|
}
|
|
260
359
|
|
|
@@ -263,15 +362,16 @@ export async function decideTool(args, dependencies) {
|
|
|
263
362
|
}
|
|
264
363
|
|
|
265
364
|
decideTool.description =
|
|
266
|
-
'DECIDE — ask a
|
|
365
|
+
'DECIDE — ask a decision model (OpenAI gpt-6-luna, TypeSafe Jev, Cloudflare Clef) typed questions about a state and get calibrated answers, not text. ' +
|
|
267
366
|
'Question types: "noul" (yes/no → probability 0..1), "choice" (pick one of 2–255 named options → choice, per-option probabilities, confidence), ' +
|
|
268
367
|
'"score" (ordered rubric of 2–10 levels → weighted position, per-level probabilities, confidence). ' +
|
|
269
368
|
'Batch independent questions over the same state into one call: they are judged in parallel and in isolation, so none sees another\'s answer; each extra question adds its own input tokens. ' +
|
|
270
369
|
'Best for fast semantic judgments (classify, route, select, verify, rank). Ask one narrow, coherent judgment per question, with its full meaning in the question; ' +
|
|
271
370
|
'split independently useful dimensions, but a bounded action choice or contextual interpretation is a valid single question. Do counting, arithmetic, and date comparison in code. ' +
|
|
272
371
|
'confidence measures how concentrated the distribution is, not permission to act: take the top option to pick a best, and treat a noul near 0.5 as "yes and no equally likely". ' +
|
|
273
|
-
'
|
|
274
|
-
'
|
|
372
|
+
'No explanations are returned. Images (PNG/JPEG/WebP) work with gpt-6-luna and Clef (up to 4, ~180 KB total); Jev is text only, and "auto" skips models that cannot take the images. ' +
|
|
373
|
+
'gpt-6-luna may answer a question with type "refusal" instead of a value: treat it as unanswered, never as a no. ' +
|
|
374
|
+
'Limits per model: Jev ~64k tokens per request (~32k for state plus the longest question); Clef ~64k tokens and at most 64 questions named with letters, digits, "_", ".", "-".';
|
|
275
375
|
|
|
276
376
|
decideTool.inputSchema = {
|
|
277
377
|
type: 'object',
|
|
@@ -279,7 +379,7 @@ decideTool.inputSchema = {
|
|
|
279
379
|
state: {
|
|
280
380
|
anyOf: [{ type: 'string' }, { type: 'object' }, { type: 'array' }],
|
|
281
381
|
description:
|
|
282
|
-
'The material to judge: plain text, or JSON (an object with descriptively named fields is best; an array for sequences such as messages). Optional when "files"
|
|
382
|
+
'The material to judge: plain text, or JSON (an object with descriptively named fields is best; an array for sequences such as messages). Optional when "files" or "images" are given.',
|
|
283
383
|
},
|
|
284
384
|
questions: {
|
|
285
385
|
type: 'object',
|
|
@@ -287,7 +387,7 @@ decideTool.inputSchema = {
|
|
|
287
387
|
'Named questions, all answered against the same state. The name is your own label and is returned as the answer key. ' +
|
|
288
388
|
'Each question: { "type": "noul"|"choice"|"score", "instructions": string|object|array, "criteria": ... }. ' +
|
|
289
389
|
'criteria — noul: optional { "true": "...", "false": "..." }; choice: required { "<option>": "description" | null } (2–255 options); ' +
|
|
290
|
-
'score: required ordered array of
|
|
390
|
+
'score: required ordered array of levels, lowest first (2–10 levels); each level is a description, or { "label": "Short name", "description": "..." } to keep the legend short. ' +
|
|
291
391
|
'instructions may be an object bundling the question with reference data, referenced by `name` in the text. ' +
|
|
292
392
|
'Example: { "team": { "type": "choice", "instructions": "Which team should handle this?", "criteria": { "billing": "Payments, refunds", "technical": "Bugs, outages" } } }',
|
|
293
393
|
additionalProperties: {
|
|
@@ -304,14 +404,23 @@ decideTool.inputSchema = {
|
|
|
304
404
|
model: {
|
|
305
405
|
type: 'string',
|
|
306
406
|
description:
|
|
307
|
-
'Decision model. "auto" (default):
|
|
308
|
-
'"
|
|
407
|
+
'Decision model. "auto" (default): gpt-6-luna, then jev-latest, then clef, each on every configured host. ' +
|
|
408
|
+
'A model name ("gpt-6-luna"/"luna", "jev-latest"/"jev", "jev-1.13", "jev-preview", "clef", "clef-flash"): its native host first, falling back to OpenRouter. ' +
|
|
409
|
+
'"provider:model" (e.g. "openai:gpt-6-luna", "cloudflare:clef-flash", "openrouter:jev-latest"): that provider only. ' +
|
|
410
|
+
'Providers: openai (OPENAI_API_KEY), typesafe (TYPESAFE_API_KEY), cloudflare (CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN), openrouter (OPENROUTER_API_KEY).',
|
|
309
411
|
},
|
|
310
412
|
files: {
|
|
311
413
|
type: 'array',
|
|
312
414
|
items: { type: 'string' },
|
|
313
415
|
description:
|
|
314
|
-
'Text files added to the state as { "files": { "<path>": "<content>" } }; a given state moves to "input". Supports line ranges: file.txt{10:50}.
|
|
416
|
+
'Text files added to the state as { "files": { "<path>": "<content>" } }; a given state moves to "input". Supports line ranges: file.txt{10:50}. Image files belong in "images".',
|
|
417
|
+
},
|
|
418
|
+
images: {
|
|
419
|
+
type: 'array',
|
|
420
|
+
items: { type: 'string' },
|
|
421
|
+
description:
|
|
422
|
+
'Images judged together with the state: file paths or base64 data URLs, PNG/JPEG/WebP. Refer to them in questions ("Does the image show ..."). ' +
|
|
423
|
+
'Supported by gpt-6-luna and clef/clef-flash (Clef: up to 4, about 180 KB in total); other models are skipped or rejected.',
|
|
315
424
|
},
|
|
316
425
|
},
|
|
317
426
|
required: ['questions'],
|