raidcom-mcp 0.0.0-stage → 0.1.2
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/CHANGELOG.md +57 -0
- package/INSTALL.md +169 -0
- package/LICENSE +21 -0
- package/README.docker.md +50 -0
- package/README.md +117 -2
- package/README.plugin.md +56 -0
- package/dist/builder.js +409 -0
- package/dist/catalog.js +79 -0
- package/dist/cmdline.js +145 -0
- package/dist/config.js +118 -0
- package/dist/http.js +198 -0
- package/dist/index.js +307 -0
- package/dist/recipes.js +307 -0
- package/dist/search.js +157 -0
- package/dist/tools.js +179 -0
- package/package.json +54 -4
- package/spec/examples.json +390 -0
- package/spec/limits.json +236 -0
- package/spec/notes/conventions.md +56 -0
- package/spec/notes/horcm-setup.md +27 -0
- package/spec/raidcom-catalog.json +1 -0
- package/spec/recipes.json +1106 -0
package/dist/recipes.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { build, checkGlobal } from "./builder.js";
|
|
2
|
+
/** Most commands one request may expand to. */
|
|
3
|
+
export const MAX_COMMANDS = 500;
|
|
4
|
+
// ---------- value expansion ----------
|
|
5
|
+
/** "100-103" → 100..103, "1,2,5" → 1,2,5, "dd:00-dd:0f" → dd:00..dd:0f, a mix of these; other text is kept as is.
|
|
6
|
+
* Stops with an error past `max` values, so no input can make it run long or use much memory. */
|
|
7
|
+
export function expandList(input, max = MAX_COMMANDS) {
|
|
8
|
+
const out = [];
|
|
9
|
+
const push = (v) => {
|
|
10
|
+
if (out.length >= max)
|
|
11
|
+
throw new Error(`the list has more than ${max} values`);
|
|
12
|
+
out.push(v);
|
|
13
|
+
};
|
|
14
|
+
for (const item of Array.isArray(input) ? input : [input]) {
|
|
15
|
+
if (typeof item === "number") {
|
|
16
|
+
if (!Number.isFinite(item))
|
|
17
|
+
throw new Error(`${item} is not a usable value`);
|
|
18
|
+
push(item);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (typeof item !== "string")
|
|
22
|
+
throw new Error(`list values must be strings or numbers, not ${item === null ? "null" : typeof item}`);
|
|
23
|
+
for (const part of item.split(/[,\s]+/).filter(Boolean)) {
|
|
24
|
+
// CU:LDEV ranges such as dd:00-dd:0f, as used for mainframe volumes: expanded in the same format
|
|
25
|
+
const h = /^([0-9a-f]{2}):([0-9a-f]{2})-([0-9a-f]{2}):([0-9a-f]{2})$/i.exec(part);
|
|
26
|
+
// decimal ranges; 15 digits keeps both ends exact as JavaScript numbers
|
|
27
|
+
const m = /^(\d{1,15})-(\d{1,15})$/.exec(part);
|
|
28
|
+
if (h || m) {
|
|
29
|
+
const a = h ? parseInt(h[1] + h[2], 16) : Number(m[1]);
|
|
30
|
+
const b = h ? parseInt(h[3] + h[4], 16) : Number(m[2]);
|
|
31
|
+
if (b < a)
|
|
32
|
+
throw new Error(`range "${part}" runs backwards`);
|
|
33
|
+
if (b - a + 1 > max - out.length)
|
|
34
|
+
throw new Error(`range "${part}" has more than ${max} values`);
|
|
35
|
+
const lower = part === part.toLowerCase();
|
|
36
|
+
for (let n = a; n <= b; n++) {
|
|
37
|
+
if (!h)
|
|
38
|
+
push(n);
|
|
39
|
+
else {
|
|
40
|
+
const x = n.toString(16).padStart(4, "0");
|
|
41
|
+
push(`${x.slice(0, 2)}:${x.slice(2)}`[lower ? "toLowerCase" : "toUpperCase"]());
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else
|
|
46
|
+
push(/^\d{1,15}$/.test(part) ? Number(part) : part);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
const refs = (s) => [...s.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]);
|
|
52
|
+
/** A scope value by name. Only the scope's own entries count, so "{constructor}" or "{__proto__}" find nothing. */
|
|
53
|
+
const lookup = (scope, name) => (Object.hasOwn(scope, name) ? scope[name] : undefined);
|
|
54
|
+
const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
55
|
+
/** Substitutes {name} in a template. Undefined when it names something that has no value. */
|
|
56
|
+
function fill(template, scope) {
|
|
57
|
+
const whole = /^\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(template);
|
|
58
|
+
if (whole) {
|
|
59
|
+
const v = lookup(scope, whole[1]);
|
|
60
|
+
return v === undefined || (Array.isArray(v) && v.length === 0) || v === "" ? undefined : v;
|
|
61
|
+
}
|
|
62
|
+
let missing = false;
|
|
63
|
+
const text = template.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => {
|
|
64
|
+
const v = lookup(scope, name);
|
|
65
|
+
if (v === undefined || Array.isArray(v) || v === "") {
|
|
66
|
+
missing = true;
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
return String(v);
|
|
70
|
+
});
|
|
71
|
+
return missing ? undefined : text;
|
|
72
|
+
}
|
|
73
|
+
/** One concrete option set from a step's templates. An option whose template has no value is left out. */
|
|
74
|
+
function fillOptions(options, scope) {
|
|
75
|
+
// no prototype, so an option named "__proto__" is an ordinary (and then unknown) option
|
|
76
|
+
const out = Object.create(null);
|
|
77
|
+
for (const [flag, raw] of Object.entries(options ?? {})) {
|
|
78
|
+
if (typeof raw === "boolean") {
|
|
79
|
+
out[flag] = raw;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const templates = Array.isArray(raw) ? raw : [raw];
|
|
83
|
+
const values = [];
|
|
84
|
+
for (const t of templates) {
|
|
85
|
+
if (t === null || t === undefined)
|
|
86
|
+
continue;
|
|
87
|
+
if (typeof t === "number")
|
|
88
|
+
values.push(t);
|
|
89
|
+
else {
|
|
90
|
+
// a template that names a parameter nobody gave contributes nothing, so optional trailing values drop out
|
|
91
|
+
const v = fill(t, scope);
|
|
92
|
+
if (v === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
if (Array.isArray(v))
|
|
95
|
+
values.push(...v);
|
|
96
|
+
else
|
|
97
|
+
values.push(v);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (values.length)
|
|
101
|
+
out[flag] = values;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
/** Checks that a step's option values are what a template can be: text, numbers, switches, or lists of these. */
|
|
106
|
+
function checkOptions(options) {
|
|
107
|
+
if (options === undefined || options === null)
|
|
108
|
+
return;
|
|
109
|
+
if (typeof options !== "object" || Array.isArray(options))
|
|
110
|
+
throw new Error("options must be an object of option names and values");
|
|
111
|
+
for (const [flag, v] of Object.entries(options)) {
|
|
112
|
+
const ok = (x) => x === null || typeof x === "string" || typeof x === "number";
|
|
113
|
+
if (!(ok(v) || typeof v === "boolean" || (Array.isArray(v) && v.every(ok))))
|
|
114
|
+
throw new Error(`option ${flag}: a value must be text, a number, true/false or a list of text and numbers`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function loopValues(name, spec, params, max) {
|
|
118
|
+
if (!VAR_NAME.test(name))
|
|
119
|
+
throw new Error(`"${name}" is not a usable variable name (letters, digits and _)`);
|
|
120
|
+
let values;
|
|
121
|
+
if (typeof spec === "string" && Object.hasOwn(params, spec)) {
|
|
122
|
+
const p = params[spec];
|
|
123
|
+
values = p === undefined ? [] : Array.isArray(p) ? p : [p];
|
|
124
|
+
if (values.length > max)
|
|
125
|
+
throw new Error(`${name} has more than ${max} values`);
|
|
126
|
+
}
|
|
127
|
+
else if (typeof spec === "string" || typeof spec === "number" || Array.isArray(spec))
|
|
128
|
+
values = expandList(spec, max);
|
|
129
|
+
else
|
|
130
|
+
throw new Error(`${name} must be a list or a range such as "100-109"`);
|
|
131
|
+
if (!values.length)
|
|
132
|
+
throw new Error(`${name} has no values`);
|
|
133
|
+
return values;
|
|
134
|
+
}
|
|
135
|
+
/** Every variable binding a step runs with, at most `max` of them. */
|
|
136
|
+
function bindings(step, params, max) {
|
|
137
|
+
let combos = [{}];
|
|
138
|
+
for (const [what, spec] of [["zip", step.zip], ["foreach", step.foreach]]) {
|
|
139
|
+
if (spec !== undefined && (spec === null || typeof spec !== "object" || Array.isArray(spec)))
|
|
140
|
+
throw new Error(`${what} must be an object of variable names and lists`);
|
|
141
|
+
if (spec && !Object.keys(spec).length)
|
|
142
|
+
throw new Error(`${what} is empty; leave it out or give at least one variable`);
|
|
143
|
+
}
|
|
144
|
+
if (step.zip) {
|
|
145
|
+
const lists = Object.entries(step.zip).map(([k, v]) => [k, loopValues(k, v, params, max)]);
|
|
146
|
+
const n = lists[0][1].length;
|
|
147
|
+
for (const [k, l] of lists)
|
|
148
|
+
if (l.length !== n)
|
|
149
|
+
throw new Error(`zip lists must have the same length (${lists[0][0]} has ${n}, ${k} has ${l.length})`);
|
|
150
|
+
combos = Array.from({ length: n }, (_, i) => Object.fromEntries(lists.map(([k, l]) => [k, l[i]])));
|
|
151
|
+
}
|
|
152
|
+
for (const [k, v] of Object.entries(step.foreach ?? {})) {
|
|
153
|
+
const values = loopValues(k, v, params, max);
|
|
154
|
+
if (combos.length * values.length > max)
|
|
155
|
+
throw new Error(`the step would produce more than ${max} commands`);
|
|
156
|
+
combos = combos.flatMap((c) => values.map((x) => ({ ...c, [k]: x })));
|
|
157
|
+
}
|
|
158
|
+
return combos;
|
|
159
|
+
}
|
|
160
|
+
// ---------- scripts ----------
|
|
161
|
+
export function buildScript(catalog, req, params = {}) {
|
|
162
|
+
const errors = [];
|
|
163
|
+
const warnings = [];
|
|
164
|
+
const notes = [];
|
|
165
|
+
const commands = [];
|
|
166
|
+
const lines = [];
|
|
167
|
+
let total = 0;
|
|
168
|
+
let pendingAsync = false;
|
|
169
|
+
// The global options are written into lines of their own (HORCMINST, lock, login), so they are checked here, once,
|
|
170
|
+
// before anything is written, and not only when a command is built.
|
|
171
|
+
const modelHit = catalog.model(req.model);
|
|
172
|
+
const gc = checkGlobal(req.global, modelHit?.limits ?? catalog.models.get("generic"), !!modelHit);
|
|
173
|
+
if (gc.errors.length)
|
|
174
|
+
return { ok: false, commands: [], errors: gc.errors.map((e) => `global: ${e}`), warnings: [], notes: [] };
|
|
175
|
+
const inst = gc.instanceFlag ? ` ${gc.instanceFlag}` : "";
|
|
176
|
+
if (!Array.isArray(req.steps) || !req.steps.length)
|
|
177
|
+
return { ok: false, commands: [], errors: ["steps must be a non-empty list"], warnings: [], notes: [] };
|
|
178
|
+
const flushAsync = () => {
|
|
179
|
+
if (!pendingAsync)
|
|
180
|
+
return;
|
|
181
|
+
lines.push(`run raidcom get command_status${inst}`);
|
|
182
|
+
pendingAsync = false;
|
|
183
|
+
};
|
|
184
|
+
req.steps.forEach((step, si) => {
|
|
185
|
+
const title = step && typeof step === "object" && typeof step.title === "string" ? step.title : undefined;
|
|
186
|
+
const label = `step ${si + 1}${title ? ` (${title})` : ""}`;
|
|
187
|
+
let combos;
|
|
188
|
+
try {
|
|
189
|
+
if (!step || typeof step !== "object" || typeof step.command !== "string")
|
|
190
|
+
throw new Error("a step needs a command name, such as {\"command\": \"add ldev\", \"options\": {...}}");
|
|
191
|
+
checkOptions(step.options);
|
|
192
|
+
combos = bindings(step, params, MAX_COMMANDS - total);
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
errors.push(`${label}: ${e.message}`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
total += combos.length;
|
|
199
|
+
if (total > MAX_COMMANDS) {
|
|
200
|
+
errors.push(`${label}: the script would have more than ${MAX_COMMANDS} commands`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// a step that starts with a command that is not asynchronous first waits for the pending asynchronous ones
|
|
204
|
+
if (catalog.find(step.command) && !catalog.find(step.command).async)
|
|
205
|
+
flushAsync();
|
|
206
|
+
if (title)
|
|
207
|
+
lines.push("", `# ${title.replace(/[\r\n]+/g, " ")}`);
|
|
208
|
+
for (const combo of combos) {
|
|
209
|
+
const scope = { ...params, ...combo };
|
|
210
|
+
const options = fillOptions(step.options, scope);
|
|
211
|
+
const r = build(catalog, { command: step.command, options, model: req.model, global: req.global, strict: req.strict });
|
|
212
|
+
if (!r.ok) {
|
|
213
|
+
const where = Object.keys(combo).length ? ` with ${Object.entries(combo).map(([k, v]) => `${k}=${v}`).join(", ")}` : "";
|
|
214
|
+
for (const m of r.errors)
|
|
215
|
+
errors.push(`${label}${where}: ${m}`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
for (const w of r.warnings)
|
|
219
|
+
if (!warnings.includes(`${label}: ${w}`))
|
|
220
|
+
warnings.push(`${label}: ${w}`);
|
|
221
|
+
commands.push({ step: si + 1, title, command: r.command, readOnly: !!r.readOnly, destructive: !!r.destructive, async: !!r.async });
|
|
222
|
+
if (!r.async && pendingAsync)
|
|
223
|
+
flushAsync();
|
|
224
|
+
lines.push(`run ${r.command}`);
|
|
225
|
+
if (r.async)
|
|
226
|
+
pendingAsync = true;
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
flushAsync();
|
|
230
|
+
if (!errors.length && !commands.length)
|
|
231
|
+
errors.push("The script would contain no commands.");
|
|
232
|
+
if (errors.length)
|
|
233
|
+
return { ok: false, commands: [], errors, warnings, notes };
|
|
234
|
+
if (commands.some((c) => c.async))
|
|
235
|
+
notes.push("Commands that run asynchronously are followed by `raidcom get command_status`; read its output before moving on.");
|
|
236
|
+
if (commands.some((c) => c.destructive))
|
|
237
|
+
notes.push("The script contains commands that delete or initialize. Read it through before running it.");
|
|
238
|
+
const head = ["#!/bin/bash", "# Generated by raidcom-mcp. The server only builds text; it has not run anything on a storage system."];
|
|
239
|
+
if (req.description)
|
|
240
|
+
head.push(`# ${req.description.replace(/[\r\n]+/g, " ")}`);
|
|
241
|
+
head.push("# Review before running. Run it on a host with CCI installed and a running HORCM instance.", "");
|
|
242
|
+
if (gc.instance !== undefined)
|
|
243
|
+
head.push(`export HORCMINST=${gc.instance}`);
|
|
244
|
+
for (const [k, v] of Object.entries(req.env ?? {})) {
|
|
245
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(k) || !/^[A-Za-z0-9_.:-]*$/.test(v))
|
|
246
|
+
throw new Error(`unsafe environment variable ${k}`);
|
|
247
|
+
head.push(`export ${k}=${v}`);
|
|
248
|
+
}
|
|
249
|
+
head.push('run() { "$@" || { echo "FAILED: $*" >&2; exit 1; }; }', "");
|
|
250
|
+
if (req.login)
|
|
251
|
+
head.push(`# Set RAIDCOM_USER and RAIDCOM_PASSWORD in your shell; they are not part of this script.`, `run raidcom -login "$RAIDCOM_USER" "$RAIDCOM_PASSWORD"${inst}`);
|
|
252
|
+
if (req.lock_resource)
|
|
253
|
+
head.push(`run raidcom lock resource${inst}`);
|
|
254
|
+
const tail = [""];
|
|
255
|
+
if (req.lock_resource)
|
|
256
|
+
tail.push(`run raidcom unlock resource${inst}`);
|
|
257
|
+
if (req.login)
|
|
258
|
+
tail.push(`run raidcom -logout${inst}`);
|
|
259
|
+
return { ok: true, script: [...head, ...lines.filter((l, i) => !(l === "" && i === 0)), ...tail].join("\n") + "\n", commands, errors, warnings, notes };
|
|
260
|
+
}
|
|
261
|
+
/** Checks the parameters of a recipe and returns the values to substitute. */
|
|
262
|
+
function recipeScope(recipe, given, errors) {
|
|
263
|
+
const scope = {};
|
|
264
|
+
for (const k of Object.keys(given))
|
|
265
|
+
if (!Object.hasOwn(recipe.params, k))
|
|
266
|
+
errors.push(`Unknown parameter "${k}". This recipe takes: ${Object.keys(recipe.params).join(", ") || "none"}.`);
|
|
267
|
+
for (const [name, spec] of Object.entries(recipe.params)) {
|
|
268
|
+
const v = given[name];
|
|
269
|
+
if (v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0)) {
|
|
270
|
+
if (spec.required)
|
|
271
|
+
errors.push(`Parameter "${name}" is required: ${spec.description}`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
if (spec.type === "array") {
|
|
276
|
+
const list = Array.isArray(v) ? v : [v];
|
|
277
|
+
scope[name] = spec.range ? expandList(list) : list.flatMap((x) => (typeof x === "string" ? x.split(/[,\s]+/).filter(Boolean) : [x]));
|
|
278
|
+
}
|
|
279
|
+
else
|
|
280
|
+
scope[name] = typeof v === "number" ? v : String(v);
|
|
281
|
+
}
|
|
282
|
+
catch (e) {
|
|
283
|
+
errors.push(`Parameter "${name}": ${e.message}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return scope;
|
|
287
|
+
}
|
|
288
|
+
export function buildRecipe(catalog, id, recipe, given, opts = {}) {
|
|
289
|
+
const errors = [];
|
|
290
|
+
const scope = recipeScope(recipe, given, errors);
|
|
291
|
+
if (errors.length)
|
|
292
|
+
return { ok: false, recipe: id, commands: [], errors, warnings: [], notes: [] };
|
|
293
|
+
// a step with `when` runs if all its parameters were given; some but not all is a mistake worth reporting
|
|
294
|
+
const steps = recipe.steps.filter((s) => {
|
|
295
|
+
if (!s.when)
|
|
296
|
+
return true;
|
|
297
|
+
const names = Array.isArray(s.when) ? s.when : [s.when];
|
|
298
|
+
const have = names.filter((n) => lookup(scope, n) !== undefined);
|
|
299
|
+
if (have.length && have.length < names.length)
|
|
300
|
+
errors.push(`Give ${names.join(" and ")} together (only ${have.join(", ")} was given).`);
|
|
301
|
+
return have.length === names.length;
|
|
302
|
+
});
|
|
303
|
+
if (errors.length)
|
|
304
|
+
return { ok: false, recipe: id, commands: [], errors, warnings: [], notes: [] };
|
|
305
|
+
const r = buildScript(catalog, { ...opts, steps, description: opts.description ?? recipe.title, env: recipe.env }, scope);
|
|
306
|
+
return { ...r, recipe: id, notes: [...r.notes, ...recipe.notes] };
|
|
307
|
+
}
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// BM25 ranking over the raidcom catalogue, with a confidence signal so the model knows when to build straight
|
|
2
|
+
// away and when to describe candidates first.
|
|
3
|
+
import { commandName } from "./catalog.js";
|
|
4
|
+
// Field weights, applied by repeating tokens (BM25F-lite): a word in the command name says more than one in a
|
|
5
|
+
// long option description.
|
|
6
|
+
const WEIGHTS = { name: 5, summary: 3, flags: 1, optionText: 0.5, examples: 3, questions: 3 };
|
|
7
|
+
const K1 = 1.2;
|
|
8
|
+
const B = 0.75;
|
|
9
|
+
const STOPWORDS = new Set("a an and are as at be by can do does for from give how i in is it me my of on or the this to what when where which with all any please need want would like using use raidcom".split(" "));
|
|
10
|
+
// What people say vs. the words in the catalogue.
|
|
11
|
+
const SYNONYMS = {
|
|
12
|
+
group: ["grp"], groups: ["grp"],
|
|
13
|
+
volume: ["ldev"], volumes: ["ldev"], vol: ["ldev"], dpvol: ["ldev", "dp"], disk: ["ldev"], luns: ["lun", "ldev"],
|
|
14
|
+
hostgroup: ["host_grp"], "host-group": ["host_grp"], iscsi: ["iscsi", "hba_iscsi"], target: ["host_grp", "iscsi"],
|
|
15
|
+
wwn: ["hba_wwn"], wwpn: ["hba_wwn"], initiator: ["hba_wwn", "hba_iscsi"], hba: ["hba_wwn"],
|
|
16
|
+
nqn: ["host_nqn"], nvme: ["nvm_subsystem", "namespace", "host_nqn"], namespace: ["namespace", "nvm_subsystem"], subsystem: ["nvm_subsystem"],
|
|
17
|
+
present: ["lun"], map: ["lun"], mapping: ["lun"], unmap: ["lun"], path: ["lun", "path"], paths: ["lun", "path"],
|
|
18
|
+
thin: ["dp_pool", "pool", "ldev"], dp: ["dp_pool", "pool"], hdp: ["dp_pool", "pool"], hdt: ["pool"], tiering: ["pool"],
|
|
19
|
+
capacity: ["ldev", "pool"], space: ["pool"], free: ["ldev", "pool"],
|
|
20
|
+
fibre: ["port"], fc: ["port"], port: ["port"], ports: ["port"],
|
|
21
|
+
resize: ["extend"], expand: ["extend"], grow: ["extend", "dp_pool"], enlarge: ["extend"],
|
|
22
|
+
format: ["initialize"], wipe: ["initialize"], erase: ["initialize"],
|
|
23
|
+
nickname: ["ldev", "modify"], label: ["modify"], rename: ["modify", "rename"],
|
|
24
|
+
asynchronous: ["command_status"], async: ["command_status"], status: ["command_status"], progress: ["command_status"], finished: ["command_status"], complete: ["command_status"], completion: ["command_status"],
|
|
25
|
+
error: ["error_message", "command_status"], errors: ["error_message", "command_status"],
|
|
26
|
+
lock: ["lock", "resource"], unlock: ["unlock", "resource"], locking: ["lock", "resource"],
|
|
27
|
+
replication: ["copy_grp", "device_grp", "journal"], mirror: ["copy_grp"], pair: ["copy_grp", "device_grp"], ur: ["journal"], gad: ["quorum", "journal", "copy_grp"], quorum: ["quorum"],
|
|
28
|
+
snapshot: ["snapshot", "snap_pool"], thinimage: ["snapshot", "snap_pool"], hti: ["snapshot", "snap_pool"],
|
|
29
|
+
external: ["external_grp", "external_storage"], virtualize: ["external_grp"], virtualization: ["external_grp", "external_storage"],
|
|
30
|
+
quota: ["qos_grp"], qos: ["qos_grp"], throttle: ["qos_grp", "spm_wwn"], priority: ["spm_wwn", "spm_group"],
|
|
31
|
+
parity: ["parity_grp"], raid: ["parity_grp"], pg: ["parity_grp"], drive: ["drive"], drives: ["drive"], disks_: ["drive"],
|
|
32
|
+
cache: ["clpr"], partition: ["clpr"], cpu: ["system"], license: ["license"], licence: ["license"],
|
|
33
|
+
chap: ["chap_user"], secret: ["chap_user"], password: ["chap_user"],
|
|
34
|
+
serial: ["system"], model: ["system"], firmware: ["system"], version: ["system"], info: ["system", "get"],
|
|
35
|
+
inventory: ["get"], report: ["get"], audit: ["get"], list: ["get"], show: ["get"], display: ["get"], view: ["get"], find: ["get"], check: ["get"],
|
|
36
|
+
remove: ["delete"], destroy: ["delete"], unregister: ["delete"], decommission: ["delete"],
|
|
37
|
+
create: ["add"], make: ["add"], provision: ["add"], register: ["add"], new: ["add"], attach: ["add"],
|
|
38
|
+
change: ["modify"], update: ["modify"], edit: ["modify"], set: ["modify"], enable: ["modify"], disable: ["modify"],
|
|
39
|
+
block: ["modify", "ldev"], restore: ["modify", "ldev"], reclaim: ["modify", "ldev"], zero: ["modify", "ldev"],
|
|
40
|
+
commanddevice: ["ldev", "modify"], "command-device": ["ldev", "modify"],
|
|
41
|
+
};
|
|
42
|
+
/** The action a request most likely wants, from its verbs. */
|
|
43
|
+
const ACTION_VERBS = {
|
|
44
|
+
add: ["create", "add", "make", "provision", "register", "new", "attach", "build", "allocate", "assign", "map", "present", "expose", "give"],
|
|
45
|
+
delete: ["delete", "remove", "destroy", "unregister", "decommission", "drop", "unmap", "detach", "unpresent"],
|
|
46
|
+
get: ["show", "list", "display", "get", "view", "find", "which", "what", "status", "report", "inventory", "how", "many", "check", "see"],
|
|
47
|
+
modify: ["change", "update", "modify", "edit", "set", "enable", "disable", "rename", "block", "restore"],
|
|
48
|
+
extend: ["extend", "expand", "grow", "resize", "enlarge"],
|
|
49
|
+
initialize: ["format", "initialize", "wipe", "erase", "reformat"],
|
|
50
|
+
lock: ["lock"], unlock: ["unlock"],
|
|
51
|
+
};
|
|
52
|
+
/** Lower-cased words of a request. Underscores split, so `host_grp` matches "host grp". */
|
|
53
|
+
export function words(text) {
|
|
54
|
+
return text.toLowerCase().replace(/[_]/g, " ").split(/[^a-z0-9#-]+/).map((w) => w.replace(/^-+|-+$/g, "")).filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
const stem = (w) => (w.length > 3 && w.endsWith("s") && !w.endsWith("ss") ? w.slice(0, -1) : w);
|
|
57
|
+
function tokenize(text) {
|
|
58
|
+
return words(text).filter((w) => !STOPWORDS.has(w)).map(stem);
|
|
59
|
+
}
|
|
60
|
+
function intentOf(query) {
|
|
61
|
+
const w = words(query);
|
|
62
|
+
for (const [action, verbs] of Object.entries(ACTION_VERBS))
|
|
63
|
+
if (w.some((x) => verbs.includes(x)))
|
|
64
|
+
return action;
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
export class CommandIndex {
|
|
68
|
+
docs = [];
|
|
69
|
+
df = new Map();
|
|
70
|
+
avgLen = 0;
|
|
71
|
+
constructor(commands, questions = {}) {
|
|
72
|
+
for (const command of commands) {
|
|
73
|
+
const name = commandName(command);
|
|
74
|
+
const tf = new Map();
|
|
75
|
+
const add = (text, weight) => {
|
|
76
|
+
for (const t of tokenize(text))
|
|
77
|
+
tf.set(t, (tf.get(t) ?? 0) + weight);
|
|
78
|
+
};
|
|
79
|
+
add(name, WEIGHTS.name);
|
|
80
|
+
add(command.object, WEIGHTS.name);
|
|
81
|
+
add(command.summary, WEIGHTS.summary);
|
|
82
|
+
add(Object.keys(command.flags).join(" "), WEIGHTS.flags);
|
|
83
|
+
add(Object.values(command.options).map((o) => o.summary).join(" "), WEIGHTS.optionText);
|
|
84
|
+
add(command.examples.map((e) => e.text).join(" "), WEIGHTS.examples);
|
|
85
|
+
add((questions[name] ?? []).join(" "), WEIGHTS.questions);
|
|
86
|
+
const len = [...tf.values()].reduce((a, b) => a + b, 0);
|
|
87
|
+
this.docs.push({ command, name, tf, len });
|
|
88
|
+
for (const t of tf.keys())
|
|
89
|
+
this.df.set(t, (this.df.get(t) ?? 0) + 1);
|
|
90
|
+
}
|
|
91
|
+
this.avgLen = this.docs.reduce((a, d) => a + d.len, 0) / Math.max(this.docs.length, 1);
|
|
92
|
+
}
|
|
93
|
+
/** Query terms with synonyms added at half weight. */
|
|
94
|
+
terms(query) {
|
|
95
|
+
const out = new Map();
|
|
96
|
+
for (const w of words(query)) {
|
|
97
|
+
if (STOPWORDS.has(w))
|
|
98
|
+
continue;
|
|
99
|
+
out.set(stem(w), Math.max(out.get(stem(w)) ?? 0, 1));
|
|
100
|
+
for (const s of Object.hasOwn(SYNONYMS, w) ? SYNONYMS[w] : [])
|
|
101
|
+
for (const t of tokenize(s))
|
|
102
|
+
out.set(t, Math.max(out.get(t) ?? 0, 0.5));
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
search(query, opts = {}) {
|
|
107
|
+
const terms = this.terms(query);
|
|
108
|
+
const intent = intentOf(query);
|
|
109
|
+
const N = this.docs.length;
|
|
110
|
+
const hits = [];
|
|
111
|
+
for (const d of this.docs) {
|
|
112
|
+
if (opts.action && d.command.action !== opts.action)
|
|
113
|
+
continue;
|
|
114
|
+
if (opts.object && d.command.object !== opts.object)
|
|
115
|
+
continue;
|
|
116
|
+
let score = 0;
|
|
117
|
+
const matched = [];
|
|
118
|
+
for (const [t, w] of terms) {
|
|
119
|
+
const f = d.tf.get(t);
|
|
120
|
+
if (!f)
|
|
121
|
+
continue;
|
|
122
|
+
const df = this.df.get(t) ?? 0;
|
|
123
|
+
const idf = Math.log(1 + (N - df + 0.5) / (df + 0.5));
|
|
124
|
+
score += w * idf * ((f * (K1 + 1)) / (f + K1 * (1 - B + (B * d.len) / this.avgLen)));
|
|
125
|
+
matched.push(t);
|
|
126
|
+
}
|
|
127
|
+
if (score <= 0)
|
|
128
|
+
continue;
|
|
129
|
+
// The verb in the request decides between `add lun` and `get lun`.
|
|
130
|
+
if (intent)
|
|
131
|
+
score *= d.command.action === intent ? 1.35 : 0.8;
|
|
132
|
+
hits.push({ command: d.command, name: d.name, score, matched });
|
|
133
|
+
}
|
|
134
|
+
hits.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
135
|
+
const limit = opts.limit ?? 10;
|
|
136
|
+
const top = hits[0];
|
|
137
|
+
const second = hits[1];
|
|
138
|
+
let confidence = "low";
|
|
139
|
+
let reason = "no command matched";
|
|
140
|
+
if (top) {
|
|
141
|
+
const lead = second ? top.score / second.score : 10;
|
|
142
|
+
const coverage = [...terms.entries()].filter(([, w]) => w === 1).filter(([t]) => top.matched.includes(t)).length / Math.max([...terms.values()].filter((w) => w === 1).length, 1);
|
|
143
|
+
if (lead >= 1.25 && coverage >= 0.6) {
|
|
144
|
+
confidence = "high";
|
|
145
|
+
reason = `the top result leads the next by ${lead.toFixed(2)}x and matches most of the request's words`;
|
|
146
|
+
}
|
|
147
|
+
// a tie at the top is never better than low, however many words matched
|
|
148
|
+
else if (lead >= 1.1) {
|
|
149
|
+
confidence = "medium";
|
|
150
|
+
reason = `the top result leads by ${lead.toFixed(2)}x and matches ${(coverage * 100).toFixed(0)}% of the request's words`;
|
|
151
|
+
}
|
|
152
|
+
else
|
|
153
|
+
reason = `the top results are close (${lead.toFixed(2)}x) and match ${(coverage * 100).toFixed(0)}% of the request's words`;
|
|
154
|
+
}
|
|
155
|
+
return { hits: hits.slice(0, limit), confidence, reason };
|
|
156
|
+
}
|
|
157
|
+
}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
const globalSchema = {
|
|
2
|
+
type: "object",
|
|
3
|
+
description: "Settings that apply to the whole command, not to one raidcom option.",
|
|
4
|
+
properties: {
|
|
5
|
+
instance: { type: "integer", minimum: 0, description: "CCI instance number: written as -I<n>. Example: 0." },
|
|
6
|
+
pair_type: { type: "string", enum: ["H", "M", "TC", "SI"], description: "With instance: H or TC for TrueCopy, Universal Replicator and global-active device; M or SI for ShadowImage, Volume Migration and Thin Image." },
|
|
7
|
+
serial: { type: ["string", "integer"], description: "Storage system serial number (-s), needed when the HORCM file lists several arrays. VSP 5000 series add a leading 5, VSP G1x00 and VSP F1500 a leading 3." },
|
|
8
|
+
unit: { type: "integer", minimum: 0, description: "Unit ID of the command device (-u)." },
|
|
9
|
+
fx: { type: "boolean", description: "Print LDEV numbers in hexadecimal (-fx)." },
|
|
10
|
+
nomsg: { type: "boolean", description: "Suppress messages (-nomsg)." },
|
|
11
|
+
checkmode: { type: "string", enum: ["precheck"], description: "Check the command without changing the array (-checkmode precheck)." },
|
|
12
|
+
store: { type: "string", description: "File name for the implementation-check file (-store)." },
|
|
13
|
+
},
|
|
14
|
+
additionalProperties: false,
|
|
15
|
+
};
|
|
16
|
+
const modelSchema = {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "Storage model, so values are checked against its limits: vsp-one-b24, vsp-one-b26, vsp-one-b28, vsp-5000, vsp-e590, vsp-e790, vsp-e990, vsp-e1090. Aliases such as B28 or E990 work. " +
|
|
19
|
+
"Omit it to check against the widest range of any model.",
|
|
20
|
+
};
|
|
21
|
+
const optionsSchema = {
|
|
22
|
+
type: "object",
|
|
23
|
+
description: "raidcom options and their values, keyed by option name with or without the leading dash. A value is a string or number; an option that takes several values (for example -port takes a port and an optional host group name) " +
|
|
24
|
+
"takes an array: {\"-port\": [\"CL1-A-0\", \"esx01\"]}. An option with no value (a switch such as -allports) is true. Example for `add lun`: {\"-port\": [\"CL1-A-0\"], \"-ldev_id\": 200, \"-lun_id\": 1}.",
|
|
25
|
+
additionalProperties: { type: ["string", "number", "boolean", "array"], items: { type: ["string", "number"] } },
|
|
26
|
+
};
|
|
27
|
+
const strictSchema = {
|
|
28
|
+
type: "boolean",
|
|
29
|
+
description: "Default true. The command catalogue is parsed from the manual, whose syntax lines are occasionally incomplete, so a \"needs <option>\" error can be a false alarm. " +
|
|
30
|
+
"If one looks wrong after reading raidcom_describe_command, retry with strict=false: missing-option errors then become warnings. Unknown options and out-of-range values stay errors.",
|
|
31
|
+
};
|
|
32
|
+
const stepSchema = {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
title: { type: "string", description: "A short label, written into the script as a comment." },
|
|
36
|
+
command: { type: "string", description: "Command name, e.g. \"add ldev\"." },
|
|
37
|
+
options: optionsSchema,
|
|
38
|
+
foreach: {
|
|
39
|
+
type: "object",
|
|
40
|
+
description: "Repeat the step for every value (cartesian product when there are several variables). Variable → list or range: {\"ldev\": \"100-109\"} or {\"port\": [\"CL1-A\", \"CL2-A\"]}. " +
|
|
41
|
+
"Reference a variable in option values as {ldev}. Example: options {\"-ldev_id\": \"{ldev}\"} with foreach {\"ldev\": \"100-109\"} writes ten commands.",
|
|
42
|
+
additionalProperties: { type: ["string", "array"], items: { type: ["string", "number"] } },
|
|
43
|
+
},
|
|
44
|
+
zip: {
|
|
45
|
+
type: "object",
|
|
46
|
+
description: "Like foreach, but the variables advance together and the lists must have the same length: {\"ldev\": \"100-103\", \"lun\": \"0-3\"} pairs LDEV 100 with LUN 0, 101 with 1, and so on.",
|
|
47
|
+
additionalProperties: { type: ["string", "array"], items: { type: ["string", "number"] } },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
required: ["command"],
|
|
51
|
+
additionalProperties: false,
|
|
52
|
+
};
|
|
53
|
+
export function tools() {
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
name: "raidcom_list_commands",
|
|
57
|
+
description: "Find raidcom commands. Use this first when you do not know the exact command name. With no arguments, lists every object (ldev, lun, host_grp, pool, ...) with the actions available on it. " +
|
|
58
|
+
"With `query` (plain words, e.g. \"map a volume to a host group\", \"grow a pool\", \"show free ldev ids\"), ranks matching commands and rates the confidence of the top result. " +
|
|
59
|
+
"With `action` and/or `object`, lists exactly those commands. Then call raidcom_describe_command, or raidcom_build_command if you already know the options.",
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: {
|
|
63
|
+
query: { type: "string", description: "What you want to do, in plain words." },
|
|
64
|
+
action: { type: "string", description: "Only this action: add, delete, get, modify, extend, initialize, set, reset, ..." },
|
|
65
|
+
object: { type: "string", description: "Only this object: ldev, lun, host_grp, hba_wwn, pool, dp_pool, port, parity_grp, ..." },
|
|
66
|
+
limit: { type: "integer", minimum: 1, maximum: 40, description: "Maximum results for a query (default 10)." },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "raidcom_describe_command",
|
|
73
|
+
description: "Everything needed to build one raidcom command: what it does, which models support it, whether it is read-only, destructive or asynchronous, each syntax form, every option with the number and kind of values it takes " +
|
|
74
|
+
"(and the words it accepts, where it accepts only a few), and examples. Call it before raidcom_build_command when you are unsure which options a command needs or which of its syntax forms fits the request.",
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
command: { type: "string", description: "Command name, e.g. \"add lun\" or \"raidcom get ldev\"." },
|
|
79
|
+
option: { type: "string", description: "Only describe this option, e.g. \"-ldev_id\"." },
|
|
80
|
+
},
|
|
81
|
+
required: ["command"],
|
|
82
|
+
},
|
|
83
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "raidcom_build_command",
|
|
87
|
+
description: "Build one raidcom command from structured arguments and validate it. Nothing is executed and no storage system is contacted: you get command text to review and run elsewhere. " +
|
|
88
|
+
"Unknown options, wrong value counts, missing required options, bad port names and out-of-range LDEV, LUN, pool and serial numbers are reported as errors with a fix. " +
|
|
89
|
+
"Example: {\"command\": \"add ldev\", \"options\": {\"-pool\": 0, \"-ldev_id\": 256, \"-capacity\": \"100G\"}, \"model\": \"vsp-one-b28\", \"global\": {\"instance\": 0}} → `raidcom add ldev -pool 0 -ldev_id 256 -capacity 100G -I0`. " +
|
|
90
|
+
"For many similar commands use raidcom_build_script, not repeated calls. When the user asks for a command, show them the `command` from the result.",
|
|
91
|
+
inputSchema: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
command: { type: "string", description: "Command name: action and object, e.g. \"add lun\", \"get ldev\", \"modify host_grp\"." },
|
|
95
|
+
options: optionsSchema,
|
|
96
|
+
model: modelSchema,
|
|
97
|
+
global: globalSchema,
|
|
98
|
+
strict: strictSchema,
|
|
99
|
+
},
|
|
100
|
+
required: ["command"],
|
|
101
|
+
},
|
|
102
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: "raidcom_check_command",
|
|
106
|
+
description: "Validate and explain a raidcom command line the user already has (for example one pasted from a runbook). It is parsed, not executed: shell operators such as ; | & > are refused. " +
|
|
107
|
+
"Returns the normalised command, what each option means, and every problem found. Credentials after -login are dropped.",
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {
|
|
111
|
+
command_line: { type: "string", description: "One raidcom command, e.g. \"raidcom add lun -port CL1-A-0 -ldev_id 200 -lun_id 1\"." },
|
|
112
|
+
model: modelSchema,
|
|
113
|
+
strict: strictSchema,
|
|
114
|
+
},
|
|
115
|
+
required: ["command_line"],
|
|
116
|
+
},
|
|
117
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "raidcom_build_script",
|
|
121
|
+
description: "Build a shell script of raidcom commands from steps. Each step is a command with options, optionally repeated with `foreach` (every combination) or `zip` (values advance together), so \"create LDEVs 100 to 109 in pool 0, then map each to port CL1-A\" is two steps. " +
|
|
122
|
+
"Every expanded command is validated; the script is returned only if all of them are valid, otherwise you get the errors per step. The script checks each command's exit status, adds `raidcom get command_status` after asynchronous commands, " +
|
|
123
|
+
"and can lock the resource group and log in (credentials are read from RAIDCOM_USER and RAIDCOM_PASSWORD in the user's shell, never written into the script). Nothing is executed.",
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: {
|
|
127
|
+
steps: { type: "array", minItems: 1, items: stepSchema },
|
|
128
|
+
model: modelSchema,
|
|
129
|
+
global: globalSchema,
|
|
130
|
+
strict: strictSchema,
|
|
131
|
+
login: { type: "boolean", description: "Add raidcom -login / -logout using $RAIDCOM_USER and $RAIDCOM_PASSWORD." },
|
|
132
|
+
lock_resource: { type: "boolean", description: "Wrap the commands in raidcom lock resource / unlock resource. Recommended for changing commands." },
|
|
133
|
+
description: { type: "string", description: "A one-line comment for the top of the script." },
|
|
134
|
+
},
|
|
135
|
+
required: ["steps"],
|
|
136
|
+
},
|
|
137
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "raidcom_list_recipes",
|
|
141
|
+
description: "Ready-made workflows: provision volumes to an FC, iSCSI or NVMe/TCP host, create or grow a pool, create a mainframe (3390) pool, extend or delete volumes, create a command device, reclaim zero pages, read-only inventory, copy groups. " +
|
|
142
|
+
"With no arguments lists them. With `recipe`, shows its parameters and steps. With `query`, ranks recipes by keyword. Use raidcom_build_recipe to turn one into a script.",
|
|
143
|
+
inputSchema: {
|
|
144
|
+
type: "object",
|
|
145
|
+
properties: {
|
|
146
|
+
recipe: { type: "string", description: "Recipe ID to show in detail, e.g. provision-fc-volumes." },
|
|
147
|
+
query: { type: "string", description: "Words describing the task, e.g. \"new ESXi host\"." },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "raidcom_build_recipe",
|
|
154
|
+
description: "Build the script for a recipe from its parameters (see raidcom_list_recipes). Lists such as ldev_ids accept ranges: [\"100-109\"]. " +
|
|
155
|
+
"Example: recipe provision-fc-volumes with {\"pool\": 0, \"ldev_ids\": [\"100-103\"], \"capacity\": \"100G\", \"ports\": [\"CL1-A\", \"CL2-A\"], \"host_grp\": \"esx01\", \"host_mode\": \"VMWARE_EX\", \"wwns\": [\"210000e08b0256f8\"]}. " +
|
|
156
|
+
"Returns the script and the individual commands. Nothing is executed.",
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: "object",
|
|
159
|
+
properties: {
|
|
160
|
+
recipe: { type: "string", description: "Recipe ID." },
|
|
161
|
+
params: { type: "object", description: "Recipe parameters.", additionalProperties: true },
|
|
162
|
+
model: modelSchema,
|
|
163
|
+
global: globalSchema,
|
|
164
|
+
strict: strictSchema,
|
|
165
|
+
login: { type: "boolean", description: "Add raidcom -login / -logout using $RAIDCOM_USER and $RAIDCOM_PASSWORD." },
|
|
166
|
+
lock_resource: { type: "boolean", description: "Wrap the commands in raidcom lock resource / unlock resource." },
|
|
167
|
+
},
|
|
168
|
+
required: ["recipe", "params"],
|
|
169
|
+
},
|
|
170
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "raidcom_model_limits",
|
|
174
|
+
description: "Valid parameter ranges for a storage model (serial number, LDEV ID, pool ID, LU number, port names), taken from the Command Reference appendices. Without `model`, lists the models.",
|
|
175
|
+
inputSchema: { type: "object", properties: { model: modelSchema } },
|
|
176
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
}
|