@ganju/utils 0.0.10 → 0.0.12
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/dist/constants.d.ts +12 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +75 -0
- package/dist/index.d.ts +153 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -0
- package/dist/schema.d.ts +67 -2
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +30 -4
- package/dist/syncableResource.d.ts +5 -0
- package/dist/syncableResource.d.ts.map +1 -1
- package/dist/syncableResource.js +13 -1
- package/dist/toolCatalog.d.ts +69 -0
- package/dist/toolCatalog.d.ts.map +1 -1
- package/dist/toolCatalog.js +150 -62
- package/dist/toolConfirmation.d.ts +58 -0
- package/dist/toolConfirmation.d.ts.map +1 -0
- package/dist/toolConfirmation.js +239 -0
- package/dist/toolLint.d.ts +35 -0
- package/dist/toolLint.d.ts.map +1 -0
- package/dist/toolLint.js +211 -0
- package/package.json +6 -1
- package/src/constants.ts +81 -0
- package/src/index.ts +58 -1
- package/src/schema.ts +41 -6
- package/src/syncableResource.ts +20 -0
- package/src/toolCatalog.ts +169 -62
- package/src/toolConfirmation.ts +291 -0
- package/src/toolLint.ts +277 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// When an organization turns on "confirm sensitive actions", a channel bot that
|
|
2
|
+
// wants to run a sensitive tool asks first, and the call runs only if the
|
|
3
|
+
// participant's next message is a plain yes. These are the pieces of that rule
|
|
4
|
+
// that aren't the runner's own bookkeeping.
|
|
5
|
+
|
|
6
|
+
// A call waiting on the participant's answer, stored on the conversation. The
|
|
7
|
+
// exact calls the model made are kept, so what runs after a yes is what the
|
|
8
|
+
// question described — the model never gets to re-derive the arguments.
|
|
9
|
+
export interface PendingToolConfirmation {
|
|
10
|
+
// Claimed by id, so a re-sent batch can't run the same calls twice.
|
|
11
|
+
id: string;
|
|
12
|
+
// Only the participant who was asked can answer — in a group, anyone else's
|
|
13
|
+
// message leaves the question open.
|
|
14
|
+
participantId: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
calls: Array<{
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
arguments: Record<string, unknown>;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// What a conversation holds: one open question per participant, keyed by
|
|
24
|
+
// participant id, so in a group one person's question never replaces
|
|
25
|
+
// another's.
|
|
26
|
+
export type PendingToolConfirmations = Record<string, PendingToolConfirmation>;
|
|
27
|
+
|
|
28
|
+
// What an owner says a tool they wrote does, in the words the dashboard asks
|
|
29
|
+
// it in, and the MCP annotations each answer stands for: `read` only looks
|
|
30
|
+
// things up, `write` changes things that can be undone, `sensitive` sends,
|
|
31
|
+
// deletes, charges or can't be undone — the kind a confirming channel asks
|
|
32
|
+
// about.
|
|
33
|
+
export const TOOL_EFFECTS = ['read', 'write', 'sensitive'] as const;
|
|
34
|
+
|
|
35
|
+
export type ToolEffect = (typeof TOOL_EFFECTS)[number];
|
|
36
|
+
|
|
37
|
+
export const annotationsForEffect = (
|
|
38
|
+
effect: ToolEffect
|
|
39
|
+
): { readOnlyHint: boolean; destructiveHint: boolean } =>
|
|
40
|
+
effect === 'read'
|
|
41
|
+
? { readOnlyHint: true, destructiveHint: false }
|
|
42
|
+
: effect === 'write'
|
|
43
|
+
? { readOnlyHint: false, destructiveHint: false }
|
|
44
|
+
: { readOnlyHint: false, destructiveHint: true };
|
|
45
|
+
|
|
46
|
+
// Whole replies that mean yes, lowercased with accents and punctuation
|
|
47
|
+
// removed. English, Spanish and Portuguese, the languages bots here answer in.
|
|
48
|
+
const AFFIRMATIVE = new Set([
|
|
49
|
+
'y',
|
|
50
|
+
'yes',
|
|
51
|
+
'yeah',
|
|
52
|
+
'yep',
|
|
53
|
+
'yup',
|
|
54
|
+
'sure',
|
|
55
|
+
'ok',
|
|
56
|
+
'okay',
|
|
57
|
+
'confirm',
|
|
58
|
+
'confirmed',
|
|
59
|
+
'approve',
|
|
60
|
+
'approved',
|
|
61
|
+
'go',
|
|
62
|
+
'go ahead',
|
|
63
|
+
'do it',
|
|
64
|
+
'send',
|
|
65
|
+
'send it',
|
|
66
|
+
'proceed',
|
|
67
|
+
'yes please',
|
|
68
|
+
'yes send it',
|
|
69
|
+
'yes do it',
|
|
70
|
+
'yes go ahead',
|
|
71
|
+
'yes confirm',
|
|
72
|
+
'sure thing',
|
|
73
|
+
'go for it',
|
|
74
|
+
'sounds good',
|
|
75
|
+
'looks good',
|
|
76
|
+
'all good',
|
|
77
|
+
'correct',
|
|
78
|
+
'si hazlo',
|
|
79
|
+
'si adelante',
|
|
80
|
+
'si envialo ya',
|
|
81
|
+
'perfecto',
|
|
82
|
+
'correcto',
|
|
83
|
+
'esta bien',
|
|
84
|
+
'ok dale',
|
|
85
|
+
'ok envialo',
|
|
86
|
+
'si',
|
|
87
|
+
'sip',
|
|
88
|
+
'claro',
|
|
89
|
+
'dale',
|
|
90
|
+
'va',
|
|
91
|
+
'vale',
|
|
92
|
+
'listo',
|
|
93
|
+
'confirmo',
|
|
94
|
+
'confirmado',
|
|
95
|
+
'confirmar',
|
|
96
|
+
'adelante',
|
|
97
|
+
'hazlo',
|
|
98
|
+
'envialo',
|
|
99
|
+
'enviar',
|
|
100
|
+
'de acuerdo',
|
|
101
|
+
'si por favor',
|
|
102
|
+
'si claro',
|
|
103
|
+
'si dale',
|
|
104
|
+
'si envialo',
|
|
105
|
+
'si confirmo',
|
|
106
|
+
'sim',
|
|
107
|
+
'pode',
|
|
108
|
+
'pode enviar'
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const AFFIRMATIVE_EMOJI = new Set(['👍', '✅', '👌', '🆗']);
|
|
112
|
+
|
|
113
|
+
// The Yes/No buttons offered under a confirmation question, in the
|
|
114
|
+
// participant's language when the platform reports it. The labels are also
|
|
115
|
+
// what a tap sends where a button is just a canned reply (Telegram's reply
|
|
116
|
+
// keyboard), so "yes" must read as a yes to isConfirmationReply — and it does,
|
|
117
|
+
// in every language here.
|
|
118
|
+
const BUTTON_LABELS: Record<string, { yes: string; no: string }> = {
|
|
119
|
+
en: { yes: '✅ Yes', no: '❌ No' },
|
|
120
|
+
es: { yes: '✅ Sí', no: '❌ No' },
|
|
121
|
+
pt: { yes: '✅ Sim', no: '❌ Não' }
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// WhatsApp reports no locale, so a button's language comes from the calling
|
|
125
|
+
// code of the participant's number: the Spanish- and Portuguese-speaking
|
|
126
|
+
// countries, English otherwise. Longest prefix wins.
|
|
127
|
+
const CALLING_CODE_LANGUAGE: Record<string, string> = {
|
|
128
|
+
'34': 'es',
|
|
129
|
+
'51': 'es',
|
|
130
|
+
'52': 'es',
|
|
131
|
+
'53': 'es',
|
|
132
|
+
'54': 'es',
|
|
133
|
+
'56': 'es',
|
|
134
|
+
'57': 'es',
|
|
135
|
+
'58': 'es',
|
|
136
|
+
'240': 'es',
|
|
137
|
+
'502': 'es',
|
|
138
|
+
'503': 'es',
|
|
139
|
+
'504': 'es',
|
|
140
|
+
'505': 'es',
|
|
141
|
+
'506': 'es',
|
|
142
|
+
'507': 'es',
|
|
143
|
+
'591': 'es',
|
|
144
|
+
'593': 'es',
|
|
145
|
+
'595': 'es',
|
|
146
|
+
'598': 'es',
|
|
147
|
+
'1787': 'es',
|
|
148
|
+
'1809': 'es',
|
|
149
|
+
'1829': 'es',
|
|
150
|
+
'1849': 'es',
|
|
151
|
+
'55': 'pt',
|
|
152
|
+
'238': 'pt',
|
|
153
|
+
'244': 'pt',
|
|
154
|
+
'245': 'pt',
|
|
155
|
+
'258': 'pt',
|
|
156
|
+
'351': 'pt'
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const languageFromPhoneNumber = (phone: string): string | null => {
|
|
160
|
+
const digits = phone.replace(/\D/g, '');
|
|
161
|
+
for (let length = 4; length >= 2; length--) {
|
|
162
|
+
const language = CALLING_CODE_LANGUAGE[digits.slice(0, length)];
|
|
163
|
+
if (language) return language;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const confirmationButtonLabels = (
|
|
169
|
+
locale?: string | null
|
|
170
|
+
): { yes: string; no: string } => {
|
|
171
|
+
const language = (locale || '').toLowerCase().split(/[-_]/)[0];
|
|
172
|
+
return BUTTON_LABELS[language] ?? BUTTON_LABELS.en;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Where the platform sends back a button's id rather than its label (Discord,
|
|
176
|
+
// WhatsApp), the id says which answer it is and who was asked: in a shared
|
|
177
|
+
// channel everyone sees the buttons, and only that participant's tap counts.
|
|
178
|
+
// The runner never sees the id — a tap reaches it as the text "yes" or "no".
|
|
179
|
+
const CONFIRMATION_BUTTON_PREFIX = 'ganju-confirm';
|
|
180
|
+
|
|
181
|
+
export const confirmationButtonId = (
|
|
182
|
+
answer: 'yes' | 'no',
|
|
183
|
+
externalParticipantId: string
|
|
184
|
+
): string => `${CONFIRMATION_BUTTON_PREFIX}:${answer}:${externalParticipantId}`;
|
|
185
|
+
|
|
186
|
+
export const parseConfirmationButton = (
|
|
187
|
+
id: string
|
|
188
|
+
): { text: 'yes' | 'no'; externalParticipantId: string } | null => {
|
|
189
|
+
const [prefix, answer, ...rest] = id.split(':');
|
|
190
|
+
const externalParticipantId = rest.join(':');
|
|
191
|
+
if (prefix !== CONFIRMATION_BUTTON_PREFIX || !externalParticipantId) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (answer !== 'yes' && answer !== 'no') return null;
|
|
195
|
+
return { text: answer, externalParticipantId };
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const normalize = (text: string): string =>
|
|
199
|
+
text
|
|
200
|
+
.toLowerCase()
|
|
201
|
+
.normalize('NFKD')
|
|
202
|
+
.replace(/[̀-ͯ]/g, '')
|
|
203
|
+
.replace(/[^a-z0-9\s]+/g, ' ')
|
|
204
|
+
.replace(/\s+/g, ' ')
|
|
205
|
+
.trim();
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Whether a reply confirms the pending action. Deliberately narrow: the whole
|
|
209
|
+
* message has to be a yes. "Yes, but send it to Ana instead" is not one — it
|
|
210
|
+
* asks for a different action, and running the original would do exactly
|
|
211
|
+
* what the person just said not to. Anything that isn't a clear yes cancels,
|
|
212
|
+
* so the worst a misread costs is asking again.
|
|
213
|
+
*/
|
|
214
|
+
export const isConfirmationReply = (text: string): boolean => {
|
|
215
|
+
const trimmed = text.trim();
|
|
216
|
+
if (AFFIRMATIVE_EMOJI.has(trimmed)) return true;
|
|
217
|
+
return AFFIRMATIVE.has(normalize(trimmed));
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// How much of each argument the summary shows, and how many arguments.
|
|
221
|
+
const SUMMARY_VALUE_MAX = 200;
|
|
222
|
+
const SUMMARY_ARGUMENTS_MAX = 10;
|
|
223
|
+
|
|
224
|
+
const summaryValue = (value: unknown): string => {
|
|
225
|
+
const text =
|
|
226
|
+
typeof value === 'string'
|
|
227
|
+
? value
|
|
228
|
+
: (JSON.stringify(value) ?? String(value));
|
|
229
|
+
// One line, and no backticks, so a value can neither fake another line of
|
|
230
|
+
// the summary nor break out of its code span into formatting or links.
|
|
231
|
+
const flat = text.replace(/\s+/g, ' ').replace(/`/g, "'").trim();
|
|
232
|
+
return flat.length > SUMMARY_VALUE_MAX
|
|
233
|
+
? `${flat.slice(0, SUMMARY_VALUE_MAX)}…`
|
|
234
|
+
: flat;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* What the held calls will do, written from the calls themselves and appended
|
|
239
|
+
* under the model's question. The model words the question, but a yes runs the
|
|
240
|
+
* stored calls — so the participant has to see those, not only the model's
|
|
241
|
+
* account of them, which a prompt injection earlier in the turn could have
|
|
242
|
+
* bent.
|
|
243
|
+
*/
|
|
244
|
+
export const formatConfirmationSummary = (
|
|
245
|
+
calls: Array<{ name: string; arguments: Record<string, unknown> }>,
|
|
246
|
+
titleOf: (name: string) => string
|
|
247
|
+
): string =>
|
|
248
|
+
calls
|
|
249
|
+
.map(call => {
|
|
250
|
+
const entries = Object.entries(call.arguments ?? {}).filter(
|
|
251
|
+
([, value]) => value !== undefined && value !== null && value !== ''
|
|
252
|
+
);
|
|
253
|
+
const lines = entries
|
|
254
|
+
.slice(0, SUMMARY_ARGUMENTS_MAX)
|
|
255
|
+
.map(([key, value]) => `- ${key}: \`${summaryValue(value)}\``);
|
|
256
|
+
if (entries.length > SUMMARY_ARGUMENTS_MAX) lines.push('- …');
|
|
257
|
+
// The hourglass says "not done yet" in every language, whatever the
|
|
258
|
+
// model's own words above it claim.
|
|
259
|
+
return [`⏳ **${titleOf(call.name)}**`, ...lines].join('\n');
|
|
260
|
+
})
|
|
261
|
+
.join('\n\n');
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Whether a tool needs a yes before it runs, by the MCP defaults: read-only
|
|
265
|
+
* only if it says so, and a tool that changes things is destructive unless it
|
|
266
|
+
* says it isn't. So an unannotated custom or proxied tool is confirmed — the
|
|
267
|
+
* safe reading for an organization that asked to be asked.
|
|
268
|
+
*/
|
|
269
|
+
export const isSensitiveTool = (
|
|
270
|
+
annotations:
|
|
271
|
+
| { readOnlyHint?: boolean; destructiveHint?: boolean }
|
|
272
|
+
| null
|
|
273
|
+
| undefined
|
|
274
|
+
): boolean =>
|
|
275
|
+
annotations?.readOnlyHint !== true && annotations?.destructiveHint !== false;
|
|
276
|
+
|
|
277
|
+
// JSON with keys sorted at every depth, so two argument objects that say the
|
|
278
|
+
// same thing serialize the same whatever order the model emitted them in.
|
|
279
|
+
export const stableJson = (value: unknown): string => {
|
|
280
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
281
|
+
if (value && typeof value === 'object') {
|
|
282
|
+
return `{${Object.keys(value)
|
|
283
|
+
.sort()
|
|
284
|
+
.map(
|
|
285
|
+
key =>
|
|
286
|
+
`${JSON.stringify(key)}:${stableJson((value as Record<string, unknown>)[key])}`
|
|
287
|
+
)
|
|
288
|
+
.join(',')}}`;
|
|
289
|
+
}
|
|
290
|
+
return JSON.stringify(value) ?? 'null';
|
|
291
|
+
};
|
package/src/toolLint.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Checks on how a tool presents itself to a model: the things that make a
|
|
2
|
+
// model pick the wrong tool, pass the wrong arguments, or pay for tools it never
|
|
3
|
+
// uses. Every finding is a warning — a tool that fails one still works, it is
|
|
4
|
+
// just harder for a model to use well.
|
|
5
|
+
//
|
|
6
|
+
// One rule set, run in three places: `ganju build` (with `--strict` to fail on
|
|
7
|
+
// any finding), the API when a custom-code version is created (returned beside
|
|
8
|
+
// the version, with the artifact's other tools as context), and the dashboard's
|
|
9
|
+
// function dialog while it is being filled in.
|
|
10
|
+
//
|
|
11
|
+
// The rule for this file: it imports nothing. The published CLI bundles it, and
|
|
12
|
+
// anything it imported would ship inside the CLI with it (see cliConstants.ts).
|
|
13
|
+
|
|
14
|
+
// The shape all three callers can produce: a manifest entry, an http-endpoint's
|
|
15
|
+
// config, a proxied tool from discovery, a catalog tool.
|
|
16
|
+
export interface LintableTool {
|
|
17
|
+
name: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
inputSchema?: unknown;
|
|
21
|
+
annotations?: {
|
|
22
|
+
readOnlyHint?: boolean;
|
|
23
|
+
destructiveHint?: boolean;
|
|
24
|
+
idempotentHint?: boolean;
|
|
25
|
+
openWorldHint?: boolean;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const TOOL_LINT_RULES = [
|
|
30
|
+
'missing-description',
|
|
31
|
+
'short-description',
|
|
32
|
+
'no-usage-guidance',
|
|
33
|
+
'missing-annotations',
|
|
34
|
+
'undescribed-input',
|
|
35
|
+
'overlapping-tools',
|
|
36
|
+
'too-many-tools'
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
export type ToolLintRule = (typeof TOOL_LINT_RULES)[number];
|
|
40
|
+
|
|
41
|
+
export interface ToolLintFinding {
|
|
42
|
+
rule: ToolLintRule;
|
|
43
|
+
// The tool the finding is about. Absent only for `too-many-tools`, which is
|
|
44
|
+
// about the server as a whole.
|
|
45
|
+
tool?: string;
|
|
46
|
+
// `overlapping-tools`: the tool it is confused with.
|
|
47
|
+
other?: string;
|
|
48
|
+
// `undescribed-input`: the properties with no description, as dotted paths.
|
|
49
|
+
paths?: string[];
|
|
50
|
+
// `too-many-tools`: how many are enabled, and the number past which it warns.
|
|
51
|
+
count?: number;
|
|
52
|
+
limit?: number;
|
|
53
|
+
// The same finding in English, for the CLI and the API. The dashboard builds
|
|
54
|
+
// its own from the fields above, in the reader's language.
|
|
55
|
+
message: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ToolLintOptions {
|
|
59
|
+
// The other tools on the same server — natives, http endpoints, proxied
|
|
60
|
+
// tools. They are checked against for overlap and counted, but never
|
|
61
|
+
// reported on themselves: the author of these tools can't change them.
|
|
62
|
+
others?: LintableTool[];
|
|
63
|
+
// How many tools the server exposes beside `tools`, when `others` doesn't
|
|
64
|
+
// list them all. Defaults to `others.length`.
|
|
65
|
+
otherCount?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Shorter than this and a description rarely says both what a tool does and
|
|
69
|
+
// when to call it.
|
|
70
|
+
export const TOOL_LINT_MIN_DESCRIPTION_LENGTH = 40;
|
|
71
|
+
|
|
72
|
+
// Past this many tools every call carries a long list of schemas, and models
|
|
73
|
+
// start choosing among them badly. A channel turn hard-caps its list at 40
|
|
74
|
+
// (CHANNEL_MAX_TOOLS); this warns well before that.
|
|
75
|
+
export const TOOL_LINT_MAX_TOOLS = 25;
|
|
76
|
+
|
|
77
|
+
// Two descriptions sharing at least this much of their vocabulary (Jaccard,
|
|
78
|
+
// over content words) read as the same tool to a model.
|
|
79
|
+
const DESCRIPTION_OVERLAP = 0.6;
|
|
80
|
+
// Too few content words and two unrelated descriptions can share most of them.
|
|
81
|
+
const MIN_OVERLAP_WORDS = 5;
|
|
82
|
+
|
|
83
|
+
// Lowercased, accents off, so "Úsala cuando" and "usala cuando" are one text.
|
|
84
|
+
const fold = (text: string): string =>
|
|
85
|
+
text.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
|
|
86
|
+
|
|
87
|
+
// Wording that tells a model WHEN to call a tool rather than only what it does
|
|
88
|
+
// — English, Spanish and Portuguese, the languages descriptions here are
|
|
89
|
+
// written in. Loose on purpose: a false "says when" costs nothing, a false
|
|
90
|
+
// "doesn't" nags someone whose description is fine.
|
|
91
|
+
const USAGE_GUIDANCE =
|
|
92
|
+
/\b(use|uses|using|call|calls|when|whenever|before|after|instead|only if|if the user|if you|for questions|usa|usala|usalo|usar|llama|llamala|llamar|cuando|antes de|despues de|en lugar de|si el usuario|quando|use-a|utilize|chame)\b/;
|
|
93
|
+
|
|
94
|
+
const STOPWORDS = new Set([
|
|
95
|
+
'the',
|
|
96
|
+
'and',
|
|
97
|
+
'for',
|
|
98
|
+
'with',
|
|
99
|
+
'from',
|
|
100
|
+
'this',
|
|
101
|
+
'that',
|
|
102
|
+
'into',
|
|
103
|
+
'your',
|
|
104
|
+
'you',
|
|
105
|
+
'use',
|
|
106
|
+
'when',
|
|
107
|
+
'call',
|
|
108
|
+
'tool',
|
|
109
|
+
'returns',
|
|
110
|
+
'return',
|
|
111
|
+
'given',
|
|
112
|
+
'user',
|
|
113
|
+
'about',
|
|
114
|
+
'una',
|
|
115
|
+
'uno',
|
|
116
|
+
'los',
|
|
117
|
+
'las',
|
|
118
|
+
'del',
|
|
119
|
+
'con',
|
|
120
|
+
'por',
|
|
121
|
+
'para',
|
|
122
|
+
'que',
|
|
123
|
+
'este',
|
|
124
|
+
'esta',
|
|
125
|
+
'como',
|
|
126
|
+
'cuando',
|
|
127
|
+
'usa',
|
|
128
|
+
'herramienta',
|
|
129
|
+
'devuelve',
|
|
130
|
+
'usuario'
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
const contentWords = (text: string): Set<string> =>
|
|
134
|
+
new Set(
|
|
135
|
+
fold(text)
|
|
136
|
+
.split(/[^a-z0-9]+/)
|
|
137
|
+
.filter(word => word.length >= 3 && !STOPWORDS.has(word))
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// `lookupOrder`, `lookup-order` and `order_lookup` name the same thing.
|
|
141
|
+
const nameTokens = (name: string): string =>
|
|
142
|
+
name
|
|
143
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
144
|
+
.toLowerCase()
|
|
145
|
+
.split(/[^a-z0-9]+/)
|
|
146
|
+
.filter(Boolean)
|
|
147
|
+
.sort()
|
|
148
|
+
.join(' ');
|
|
149
|
+
|
|
150
|
+
const jaccard = (a: Set<string>, b: Set<string>): number => {
|
|
151
|
+
let shared = 0;
|
|
152
|
+
for (const word of a) if (b.has(word)) shared++;
|
|
153
|
+
const union = a.size + b.size - shared;
|
|
154
|
+
return union === 0 ? 0 : shared / union;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const overlaps = (a: LintableTool, b: LintableTool): boolean => {
|
|
158
|
+
if (nameTokens(a.name) === nameTokens(b.name)) return true;
|
|
159
|
+
const aWords = contentWords(a.description ?? '');
|
|
160
|
+
const bWords = contentWords(b.description ?? '');
|
|
161
|
+
if (aWords.size < MIN_OVERLAP_WORDS || bWords.size < MIN_OVERLAP_WORDS) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
return jaccard(aWords, bWords) >= DESCRIPTION_OVERLAP;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
168
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
169
|
+
|
|
170
|
+
// Every property the model may fill in that doesn't say what it is for, down
|
|
171
|
+
// through nested objects and arrays of objects.
|
|
172
|
+
const undescribedPaths = (schema: unknown, prefix = ''): string[] => {
|
|
173
|
+
if (!isObject(schema)) return [];
|
|
174
|
+
const paths: string[] = [];
|
|
175
|
+
if (isObject(schema.properties)) {
|
|
176
|
+
for (const [key, property] of Object.entries(schema.properties)) {
|
|
177
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
178
|
+
if (!isObject(property)) continue;
|
|
179
|
+
const description = property.description;
|
|
180
|
+
if (typeof description !== 'string' || !description.trim()) {
|
|
181
|
+
paths.push(path);
|
|
182
|
+
}
|
|
183
|
+
paths.push(...undescribedPaths(property, path));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (isObject(schema.items)) {
|
|
187
|
+
paths.push(...undescribedPaths(schema.items, `${prefix}[]`));
|
|
188
|
+
}
|
|
189
|
+
return paths;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const quoted = (name: string) => `"${name}"`;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Check a set of tools for what makes them hard for a model to use. Findings
|
|
196
|
+
* come back in the order of `tools`, then the server-wide one.
|
|
197
|
+
*/
|
|
198
|
+
export const lintTools = (
|
|
199
|
+
tools: LintableTool[],
|
|
200
|
+
options: ToolLintOptions = {}
|
|
201
|
+
): ToolLintFinding[] => {
|
|
202
|
+
const others = options.others ?? [];
|
|
203
|
+
const findings: ToolLintFinding[] = [];
|
|
204
|
+
|
|
205
|
+
tools.forEach((tool, index) => {
|
|
206
|
+
const description = (tool.description ?? '').trim();
|
|
207
|
+
|
|
208
|
+
if (!description) {
|
|
209
|
+
findings.push({
|
|
210
|
+
rule: 'missing-description',
|
|
211
|
+
tool: tool.name,
|
|
212
|
+
message: `${quoted(tool.name)} has no description, so a model sees only its name when deciding whether to call it.`
|
|
213
|
+
});
|
|
214
|
+
} else if (description.length < TOOL_LINT_MIN_DESCRIPTION_LENGTH) {
|
|
215
|
+
findings.push({
|
|
216
|
+
rule: 'short-description',
|
|
217
|
+
tool: tool.name,
|
|
218
|
+
message: `${quoted(tool.name)} has a very short description. Say what it does and when to call it, in at least ${TOOL_LINT_MIN_DESCRIPTION_LENGTH} characters.`
|
|
219
|
+
});
|
|
220
|
+
} else if (!USAGE_GUIDANCE.test(fold(description))) {
|
|
221
|
+
findings.push({
|
|
222
|
+
rule: 'no-usage-guidance',
|
|
223
|
+
tool: tool.name,
|
|
224
|
+
message: `${quoted(tool.name)}'s description says what it does but not when to use it (e.g. "Use when the user asks about…").`
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Undeclared means "may change things" by the MCP defaults, so with the
|
|
229
|
+
// organization confirming sensitive actions every call asks first — a
|
|
230
|
+
// lookup included.
|
|
231
|
+
const hints = tool.annotations;
|
|
232
|
+
if (
|
|
233
|
+
hints?.readOnlyHint === undefined &&
|
|
234
|
+
hints?.destructiveHint === undefined
|
|
235
|
+
) {
|
|
236
|
+
findings.push({
|
|
237
|
+
rule: 'missing-annotations',
|
|
238
|
+
tool: tool.name,
|
|
239
|
+
message: `${quoted(tool.name)} declares no annotations, so it is treated as one that may change things and is confirmed before every call when the organization confirms sensitive actions. Set readOnlyHint: true if it only reads, or destructiveHint: false if its changes can be undone.`
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const paths = undescribedPaths(tool.inputSchema);
|
|
244
|
+
if (paths.length > 0) {
|
|
245
|
+
findings.push({
|
|
246
|
+
rule: 'undescribed-input',
|
|
247
|
+
tool: tool.name,
|
|
248
|
+
paths,
|
|
249
|
+
message: `${quoted(tool.name)} has input properties with no description: ${paths.join(', ')}. A model has to guess what to pass.`
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Each pair once: against the tools after this one, then the rest of the
|
|
254
|
+
// server.
|
|
255
|
+
for (const other of [...tools.slice(index + 1), ...others]) {
|
|
256
|
+
if (other.name === tool.name || !overlaps(tool, other)) continue;
|
|
257
|
+
findings.push({
|
|
258
|
+
rule: 'overlapping-tools',
|
|
259
|
+
tool: tool.name,
|
|
260
|
+
other: other.name,
|
|
261
|
+
message: `${quoted(tool.name)} and ${quoted(other.name)} look alike in name or description, so a model may call one for the other. Make each description say what sets it apart.`
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const count = tools.length + (options.otherCount ?? others.length);
|
|
267
|
+
if (count > TOOL_LINT_MAX_TOOLS) {
|
|
268
|
+
findings.push({
|
|
269
|
+
rule: 'too-many-tools',
|
|
270
|
+
count,
|
|
271
|
+
limit: TOOL_LINT_MAX_TOOLS,
|
|
272
|
+
message: `${count} tools are enabled on this server. Every one's schema is sent to the model on every call, and models choose worse past a few dozen — turn off the ones it doesn't need (more than ${TOOL_LINT_MAX_TOOLS} is where this warns).`
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return findings;
|
|
277
|
+
};
|