eslint-plugin-webmcp 0.0.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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/index.d.mts +44 -0
- package/dist/index.mjs +1251 -0
- package/package.json +89 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
3
|
+
//#region package.json
|
|
4
|
+
var name = "eslint-plugin-webmcp";
|
|
5
|
+
var version = "0.0.0";
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/profiles.ts
|
|
8
|
+
const DEFAULT_TARGET = "draft-2026-09-04";
|
|
9
|
+
const TOOL_PROPERTIES = [
|
|
10
|
+
"name",
|
|
11
|
+
"title",
|
|
12
|
+
"description",
|
|
13
|
+
"inputSchema",
|
|
14
|
+
"execute",
|
|
15
|
+
"annotations"
|
|
16
|
+
];
|
|
17
|
+
const ANNOTATIONS = [
|
|
18
|
+
"readOnlyHint",
|
|
19
|
+
"untrustedContentHint",
|
|
20
|
+
"consequentialHint"
|
|
21
|
+
];
|
|
22
|
+
const API_OPTIONS = {
|
|
23
|
+
registerTool: ["exposedTo", "signal"],
|
|
24
|
+
getTools: ["fromOrigins"],
|
|
25
|
+
executeTool: ["signal"]
|
|
26
|
+
};
|
|
27
|
+
const API_METHODS = [
|
|
28
|
+
"registerTool",
|
|
29
|
+
"getTools",
|
|
30
|
+
"executeTool"
|
|
31
|
+
];
|
|
32
|
+
const LEGACY_METHODS = [
|
|
33
|
+
"provideContext",
|
|
34
|
+
"clearContext",
|
|
35
|
+
"unregisterTool"
|
|
36
|
+
];
|
|
37
|
+
function resolveTarget(target) {
|
|
38
|
+
if (target === void 0) return DEFAULT_TARGET;
|
|
39
|
+
if (target === "draft-2026-09-04" || target === "chrome-2026-09-01") return target;
|
|
40
|
+
throw new Error("webmcp: target must be \"draft-2026-09-04\" or \"chrome-2026-09-01\".");
|
|
41
|
+
}
|
|
42
|
+
function isWrapper(wrapper) {
|
|
43
|
+
return typeof wrapper === "object" && wrapper !== null && "module" in wrapper && typeof wrapper.module === "string" && wrapper.module.length > 0 && "imported" in wrapper && typeof wrapper.imported === "string" && wrapper.imported.length > 0 && "toolArgument" in wrapper && typeof wrapper.toolArgument === "number" && Number.isSafeInteger(wrapper.toolArgument) && wrapper.toolArgument >= 0 && "kind" in wrapper && wrapper.kind === "definition";
|
|
44
|
+
}
|
|
45
|
+
function resolveSettings(settings) {
|
|
46
|
+
if (settings === void 0) return {
|
|
47
|
+
target: DEFAULT_TARGET,
|
|
48
|
+
wrappers: []
|
|
49
|
+
};
|
|
50
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) throw new Error("webmcp: settings.webmcp must be an object.");
|
|
51
|
+
const target = resolveTarget("target" in settings ? settings.target : void 0);
|
|
52
|
+
const wrappers = "wrappers" in settings ? settings.wrappers : [];
|
|
53
|
+
if (!Array.isArray(wrappers) || !wrappers.every((wrapper) => isWrapper(wrapper))) throw new Error("webmcp: wrappers must contain module, imported, toolArgument and kind: \"definition\".");
|
|
54
|
+
return {
|
|
55
|
+
target,
|
|
56
|
+
wrappers
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/analysis/bindings.ts
|
|
61
|
+
const MAX_DEPTH$1 = 20;
|
|
62
|
+
function unwrap(node) {
|
|
63
|
+
switch (node.type) {
|
|
64
|
+
case "TSAsExpression":
|
|
65
|
+
case "TSTypeAssertion":
|
|
66
|
+
case "TSSatisfiesExpression":
|
|
67
|
+
case "TSNonNullExpression":
|
|
68
|
+
case "TSInstantiationExpression":
|
|
69
|
+
case "ChainExpression": return unwrap(node.expression);
|
|
70
|
+
default: return node;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function getKey(node) {
|
|
74
|
+
const key = node.type === "MemberExpression" ? node.property : node.key;
|
|
75
|
+
if (!node.computed && key.type === "Identifier") return key.name;
|
|
76
|
+
const unwrapped = unwrap(key);
|
|
77
|
+
if (unwrapped.type === "Literal" && (typeof unwrapped.value === "string" || typeof unwrapped.value === "number")) return String(unwrapped.value);
|
|
78
|
+
if (unwrapped.type === "TemplateLiteral" && unwrapped.expressions.length === 0) return unwrapped.quasis[0]?.value.cooked ?? void 0;
|
|
79
|
+
}
|
|
80
|
+
function getVariable(source, node) {
|
|
81
|
+
let scope = source.getScope(node);
|
|
82
|
+
while (scope) {
|
|
83
|
+
const variable = scope.set.get(node.name);
|
|
84
|
+
if (variable) return variable;
|
|
85
|
+
scope = scope.upper;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function isGlobal(source, node, name) {
|
|
89
|
+
const expression = unwrap(node);
|
|
90
|
+
if (expression.type !== "Identifier" || expression.name !== name) return false;
|
|
91
|
+
const variable = getVariable(source, expression);
|
|
92
|
+
return !variable || variable.defs.length === 0;
|
|
93
|
+
}
|
|
94
|
+
function getExecutionOwner(node) {
|
|
95
|
+
let current = node;
|
|
96
|
+
while (current.parent) {
|
|
97
|
+
current = current.parent;
|
|
98
|
+
if (current.type === "Program" || current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") return current;
|
|
99
|
+
}
|
|
100
|
+
return current;
|
|
101
|
+
}
|
|
102
|
+
function hasSafeReceiverReferences(source, variable, seen = /* @__PURE__ */ new Set()) {
|
|
103
|
+
if (seen.has(variable) || seen.size > MAX_DEPTH$1 || variable.references.some((reference) => reference.isWrite() && !reference.init)) return false;
|
|
104
|
+
seen.add(variable);
|
|
105
|
+
return variable.references.filter((reference) => reference.isRead()).every((reference) => {
|
|
106
|
+
const { parent } = reference.identifier;
|
|
107
|
+
if (parent.type === "MemberExpression" && parent.object === reference.identifier) {
|
|
108
|
+
const use = parent.parent;
|
|
109
|
+
return use.type !== "AssignmentExpression" && use.type !== "UpdateExpression" && !(use.type === "UnaryExpression" && use.operator === "delete");
|
|
110
|
+
}
|
|
111
|
+
if (parent.type === "VariableDeclarator" && parent.parent.type === "VariableDeclaration" && parent.parent.kind === "const") {
|
|
112
|
+
if (parent.id.type === "ObjectPattern") return true;
|
|
113
|
+
if (parent.id.type === "Identifier") {
|
|
114
|
+
const alias = getVariable(source, parent.id);
|
|
115
|
+
return alias !== void 0 && hasSafeReceiverReferences(source, alias, seen);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Only immutable bindings are followed. Objects additionally need a single read,
|
|
123
|
+
* preventing writes, aliases, exports and escapes from masquerading as constants.
|
|
124
|
+
*
|
|
125
|
+
* @param source The parser-provided source and scope manager
|
|
126
|
+
* @param node The identifier at its use site
|
|
127
|
+
* @param mode Whether to enforce value ownership or native method/receiver constraints
|
|
128
|
+
* @returns The initializer only when its binding is safe to follow
|
|
129
|
+
*/
|
|
130
|
+
function getConstInitializer(source, node, mode = "value") {
|
|
131
|
+
const variable = getVariable(source, node);
|
|
132
|
+
if (!variable || variable.defs.length !== 1 || variable.references.some((reference) => reference.isWrite() && !reference.init)) return;
|
|
133
|
+
const [definition] = variable.defs;
|
|
134
|
+
if (definition?.type !== "Variable" || definition.parent.kind !== "const" || definition.node.id.type !== "Identifier" || !definition.node.init) return;
|
|
135
|
+
if (definition.parent.parent.type === "ExportNamedDeclaration" || definition.node.range[1] > node.range[0] || getExecutionOwner(definition.node) !== getExecutionOwner(node)) return;
|
|
136
|
+
const initializer = unwrap(definition.node.init);
|
|
137
|
+
const reads = variable.references.filter((reference) => reference.isRead());
|
|
138
|
+
if (mode === "receiver") {
|
|
139
|
+
if (!hasSafeReceiverReferences(source, variable)) return;
|
|
140
|
+
} else if (mode === "value" && initializer.type !== "Literal" && initializer.type !== "TemplateLiteral" && initializer.type !== "UnaryExpression" && (reads.length !== 1 || reads[0]?.identifier !== node)) return;
|
|
141
|
+
return initializer;
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/analysis/static.ts
|
|
145
|
+
const UNKNOWN = { kind: "unknown" };
|
|
146
|
+
const MAX_DEPTH = 20;
|
|
147
|
+
const MAX_NODES = 1e3;
|
|
148
|
+
function resolveExpression(source, node, depth = 0) {
|
|
149
|
+
const expression = unwrap(node);
|
|
150
|
+
if (depth < MAX_DEPTH && expression.type === "Identifier") {
|
|
151
|
+
const initializer = getConstInitializer(source, expression);
|
|
152
|
+
if (initializer) return resolveExpression(source, initializer, depth + 1);
|
|
153
|
+
}
|
|
154
|
+
return expression;
|
|
155
|
+
}
|
|
156
|
+
function readObject(source, node, depth = 0) {
|
|
157
|
+
if (!node || depth > MAX_DEPTH) return;
|
|
158
|
+
const expression = resolveExpression(source, node);
|
|
159
|
+
if (expression.type !== "ObjectExpression" || expression.properties.length > MAX_NODES) return;
|
|
160
|
+
const properties = /* @__PURE__ */ new Map();
|
|
161
|
+
let complete = true;
|
|
162
|
+
for (const property of expression.properties) if (property.type === "SpreadElement") {
|
|
163
|
+
const spread = readObject(source, property.argument, depth + 1);
|
|
164
|
+
if (!spread?.complete) {
|
|
165
|
+
properties.clear();
|
|
166
|
+
complete = false;
|
|
167
|
+
}
|
|
168
|
+
for (const [name, field] of spread?.properties ?? []) properties.set(name, field);
|
|
169
|
+
} else {
|
|
170
|
+
const key = getKey(property);
|
|
171
|
+
if (property.kind !== "init" || key === "__proto__") return;
|
|
172
|
+
if (key === void 0) {
|
|
173
|
+
properties.clear();
|
|
174
|
+
complete = false;
|
|
175
|
+
} else properties.set(key, property);
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
node: expression,
|
|
179
|
+
properties,
|
|
180
|
+
complete
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function getField(object, name) {
|
|
184
|
+
const property = object?.properties.get(name);
|
|
185
|
+
if (property) return {
|
|
186
|
+
kind: "known",
|
|
187
|
+
node: property.value
|
|
188
|
+
};
|
|
189
|
+
return { kind: object?.complete ? "missing" : "unknown" };
|
|
190
|
+
}
|
|
191
|
+
function readValue(source, node, depth = 0, budget) {
|
|
192
|
+
const remaining = budget ?? { remaining: MAX_NODES };
|
|
193
|
+
if (!node || depth > MAX_DEPTH || remaining.remaining-- <= 0) return UNKNOWN;
|
|
194
|
+
const expression = resolveExpression(source, node);
|
|
195
|
+
if (expression.type === "Literal") {
|
|
196
|
+
if ("regex" in expression) return UNKNOWN;
|
|
197
|
+
return {
|
|
198
|
+
kind: "known",
|
|
199
|
+
value: expression.value
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (expression.type === "Identifier" && isGlobal(source, expression, "undefined")) return {
|
|
203
|
+
kind: "known",
|
|
204
|
+
value: void 0
|
|
205
|
+
};
|
|
206
|
+
if (expression.type === "TemplateLiteral" && expression.expressions.length === 0) {
|
|
207
|
+
const value = expression.quasis[0]?.value.cooked;
|
|
208
|
+
return value === null || value === void 0 ? UNKNOWN : {
|
|
209
|
+
kind: "known",
|
|
210
|
+
value
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (expression.type === "UnaryExpression") {
|
|
214
|
+
const argument = readValue(source, expression.argument, depth + 1, remaining);
|
|
215
|
+
if (argument.kind === "known" && expression.operator === "void") return {
|
|
216
|
+
kind: "known",
|
|
217
|
+
value: void 0
|
|
218
|
+
};
|
|
219
|
+
if (argument.kind === "known" && typeof argument.value === "number" && (expression.operator === "-" || expression.operator === "+")) return {
|
|
220
|
+
kind: "known",
|
|
221
|
+
value: expression.operator === "-" ? -argument.value : argument.value
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (expression.type === "ArrayExpression") {
|
|
225
|
+
const values = [];
|
|
226
|
+
for (const element of expression.elements) {
|
|
227
|
+
const fact = element ? readValue(source, element, depth + 1, remaining) : {
|
|
228
|
+
kind: "known",
|
|
229
|
+
value: void 0
|
|
230
|
+
};
|
|
231
|
+
if (fact.kind === "unknown") return UNKNOWN;
|
|
232
|
+
values.push(fact.value);
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
kind: "known",
|
|
236
|
+
value: values
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const object = readObject(source, expression);
|
|
240
|
+
if (object?.complete && !object.properties.has("toJSON")) {
|
|
241
|
+
const entries = [];
|
|
242
|
+
for (const [name, property] of object.properties) {
|
|
243
|
+
const fact = readValue(source, property.value, depth + 1, remaining);
|
|
244
|
+
if (fact.kind === "unknown") return UNKNOWN;
|
|
245
|
+
entries.push([name, fact.value]);
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
kind: "known",
|
|
249
|
+
value: Object.fromEntries(entries)
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return UNKNOWN;
|
|
253
|
+
}
|
|
254
|
+
function readString(source, node) {
|
|
255
|
+
const fact = readValue(source, node);
|
|
256
|
+
return fact.kind === "known" && typeof fact.value === "string" ? fact.value : void 0;
|
|
257
|
+
}
|
|
258
|
+
function isDefinitelyNonCallable(source, node) {
|
|
259
|
+
const expression = resolveExpression(source, node);
|
|
260
|
+
return readValue(source, expression).kind === "known" || expression.type === "ObjectExpression" || expression.type === "ArrayExpression" || expression.type === "ClassExpression" || expression.type === "Literal";
|
|
261
|
+
}
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/analysis/api.ts
|
|
264
|
+
var ApiAnalyzer = class {
|
|
265
|
+
settings;
|
|
266
|
+
source;
|
|
267
|
+
calls = /* @__PURE__ */ new WeakMap();
|
|
268
|
+
constructor(source, settings) {
|
|
269
|
+
this.source = source;
|
|
270
|
+
this.settings = resolveSettings(settings);
|
|
271
|
+
}
|
|
272
|
+
getReceiver(node, depth = 0) {
|
|
273
|
+
if (depth > 20) return;
|
|
274
|
+
const expression = unwrap(node);
|
|
275
|
+
if (expression.type === "Identifier") {
|
|
276
|
+
const initializer = getConstInitializer(this.source, expression, "receiver");
|
|
277
|
+
return initializer ? this.getReceiver(initializer, depth + 1) : void 0;
|
|
278
|
+
}
|
|
279
|
+
if (expression.type !== "MemberExpression" || getKey(expression) !== "modelContext") return;
|
|
280
|
+
const owner = unwrap(expression.object);
|
|
281
|
+
for (const name of ["document", "navigator"]) {
|
|
282
|
+
if (isGlobal(this.source, owner, name)) return name;
|
|
283
|
+
if (owner.type === "MemberExpression" && getKey(owner) === name && (isGlobal(this.source, owner.object, "window") || isGlobal(this.source, owner.object, "globalThis"))) return name;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
getMethod(node, depth = 0) {
|
|
287
|
+
if (depth > 20) return;
|
|
288
|
+
const expression = unwrap(node);
|
|
289
|
+
if (expression.type === "MemberExpression") {
|
|
290
|
+
const receiver = this.getReceiver(expression.object);
|
|
291
|
+
const method = getKey(expression);
|
|
292
|
+
return receiver && method ? {
|
|
293
|
+
receiver,
|
|
294
|
+
method,
|
|
295
|
+
bound: true
|
|
296
|
+
} : void 0;
|
|
297
|
+
}
|
|
298
|
+
if (expression.type === "SequenceExpression") {
|
|
299
|
+
const last = expression.expressions.at(-1);
|
|
300
|
+
const method = last ? this.getMethod(last, depth + 1) : void 0;
|
|
301
|
+
return method ? {
|
|
302
|
+
...method,
|
|
303
|
+
bound: false
|
|
304
|
+
} : void 0;
|
|
305
|
+
}
|
|
306
|
+
if (expression.type !== "Identifier") return;
|
|
307
|
+
const initializer = getConstInitializer(this.source, expression, "method");
|
|
308
|
+
if (initializer) {
|
|
309
|
+
const method = this.getMethod(initializer, depth + 1);
|
|
310
|
+
return method ? {
|
|
311
|
+
...method,
|
|
312
|
+
bound: false
|
|
313
|
+
} : void 0;
|
|
314
|
+
}
|
|
315
|
+
const variable = getVariable(this.source, expression);
|
|
316
|
+
if (variable?.defs.length !== 1 || variable.references.some((reference) => reference.isWrite() && !reference.init)) return;
|
|
317
|
+
const [definition] = variable.defs;
|
|
318
|
+
if (definition?.type !== "Variable" || definition.parent.kind !== "const" || !definition.node.init || definition.node.id.type !== "ObjectPattern" || definition.node.range[1] > node.range[0]) return;
|
|
319
|
+
const receiver = this.getReceiver(definition.node.init);
|
|
320
|
+
if (!receiver) return;
|
|
321
|
+
for (const property of definition.node.id.properties) if (property.type === "Property" && property.value.type === "Identifier" && property.value.name === expression.name) {
|
|
322
|
+
const method = getKey(property);
|
|
323
|
+
return method ? {
|
|
324
|
+
receiver,
|
|
325
|
+
method,
|
|
326
|
+
bound: false
|
|
327
|
+
} : void 0;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
getWrapper(node) {
|
|
331
|
+
const callee = unwrap(node.callee);
|
|
332
|
+
if (callee.type !== "Identifier") return;
|
|
333
|
+
const definition = getVariable(this.source, callee)?.defs[0];
|
|
334
|
+
if (definition?.type !== "ImportBinding" || definition.node.type !== "ImportSpecifier" || definition.parent.type !== "ImportDeclaration" || definition.parent.importKind === "type" || definition.node.importKind === "type") return;
|
|
335
|
+
const imported = definition.node.imported.type === "Identifier" ? definition.node.imported.name : definition.node.imported.value;
|
|
336
|
+
const moduleName = definition.parent.source.value;
|
|
337
|
+
return this.settings.wrappers.find((wrapper) => wrapper.module === moduleName && wrapper.imported === imported)?.toolArgument;
|
|
338
|
+
}
|
|
339
|
+
identify(node) {
|
|
340
|
+
const cached = this.calls.get(node);
|
|
341
|
+
if (cached !== void 0) return cached ?? void 0;
|
|
342
|
+
const method = this.getMethod(node.callee);
|
|
343
|
+
const wrapperIndex = method ? void 0 : this.getWrapper(node);
|
|
344
|
+
if (!method && wrapperIndex === void 0) {
|
|
345
|
+
this.calls.set(node, null);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const descriptor = method ?? {
|
|
349
|
+
method: "definition",
|
|
350
|
+
receiver: "wrapper",
|
|
351
|
+
bound: true
|
|
352
|
+
};
|
|
353
|
+
const argument = (index) => node.arguments.slice(0, index + 1).some((entry) => entry.type === "SpreadElement") ? void 0 : node.arguments[index];
|
|
354
|
+
const toolIndex = wrapperIndex ?? (descriptor.method === "registerTool" || descriptor.method === "executeTool" ? 0 : -1);
|
|
355
|
+
const toolNode = toolIndex >= 0 ? argument(toolIndex) : void 0;
|
|
356
|
+
const optionIndex = {
|
|
357
|
+
registerTool: 1,
|
|
358
|
+
getTools: 0,
|
|
359
|
+
executeTool: 2
|
|
360
|
+
}[descriptor.method] ?? -1;
|
|
361
|
+
const result = {
|
|
362
|
+
...descriptor,
|
|
363
|
+
node,
|
|
364
|
+
toolNode,
|
|
365
|
+
tool: readObject(this.source, toolNode),
|
|
366
|
+
options: optionIndex >= 0 ? readObject(this.source, argument(optionIndex)) : void 0
|
|
367
|
+
};
|
|
368
|
+
this.calls.set(node, result);
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
const ANALYZERS = /* @__PURE__ */ new WeakMap();
|
|
373
|
+
function getAnalyzer(source, settings) {
|
|
374
|
+
const resolved = resolveSettings(settings);
|
|
375
|
+
const key = JSON.stringify(resolved);
|
|
376
|
+
let analyzers = ANALYZERS.get(source);
|
|
377
|
+
if (!analyzers) {
|
|
378
|
+
analyzers = /* @__PURE__ */ new Map();
|
|
379
|
+
ANALYZERS.set(source, analyzers);
|
|
380
|
+
}
|
|
381
|
+
let analyzer = analyzers.get(key);
|
|
382
|
+
if (!analyzer) {
|
|
383
|
+
analyzer = new ApiAnalyzer(source, resolved);
|
|
384
|
+
analyzers.set(key, analyzer);
|
|
385
|
+
}
|
|
386
|
+
return analyzer;
|
|
387
|
+
}
|
|
388
|
+
function isDefinition(call) {
|
|
389
|
+
return call.bound && (call.receiver === "document" && call.method === "registerTool" || call.receiver === "wrapper");
|
|
390
|
+
}
|
|
391
|
+
function isNativeMethod(call) {
|
|
392
|
+
return call.receiver !== "wrapper" && (API_METHODS.includes(call.method) || LEGACY_METHODS.includes(call.method));
|
|
393
|
+
}
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/createRule.ts
|
|
396
|
+
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/ntnyq/eslint-plugin-webmcp/blob/main/docs/rules/${name}.md`);
|
|
397
|
+
function createApiRule(definition) {
|
|
398
|
+
return createRule({
|
|
399
|
+
name: definition.name,
|
|
400
|
+
meta: {
|
|
401
|
+
type: definition.type ?? "problem",
|
|
402
|
+
docs: {
|
|
403
|
+
description: definition.description,
|
|
404
|
+
...definition.recommended ? { recommended: definition.recommended } : {},
|
|
405
|
+
...definition.strict ? { strict: definition.strict } : {}
|
|
406
|
+
},
|
|
407
|
+
schema: definition.schema ?? [],
|
|
408
|
+
messages: definition.messages
|
|
409
|
+
},
|
|
410
|
+
defaultOptions: definition.defaultOptions,
|
|
411
|
+
create(context, options) {
|
|
412
|
+
const analyzer = getAnalyzer(context.sourceCode, context.settings["webmcp"]);
|
|
413
|
+
return { CallExpression(node) {
|
|
414
|
+
const call = analyzer.identify(node);
|
|
415
|
+
if (call) definition.check(call, analyzer, context, options);
|
|
416
|
+
} };
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
function stringListOption(name) {
|
|
421
|
+
return [{
|
|
422
|
+
type: "object",
|
|
423
|
+
properties: { [name]: {
|
|
424
|
+
type: "array",
|
|
425
|
+
items: { type: "string" },
|
|
426
|
+
uniqueItems: true
|
|
427
|
+
} },
|
|
428
|
+
additionalProperties: false
|
|
429
|
+
}];
|
|
430
|
+
}
|
|
431
|
+
//#endregion
|
|
432
|
+
//#region src/rules/apiOptions.ts
|
|
433
|
+
function isCurrentCall(call) {
|
|
434
|
+
return call.receiver === "document" && call.bound && API_METHODS.includes(call.method);
|
|
435
|
+
}
|
|
436
|
+
function getOrigins(call) {
|
|
437
|
+
if (!isCurrentCall(call)) return;
|
|
438
|
+
const originOption = {
|
|
439
|
+
registerTool: "exposedTo",
|
|
440
|
+
getTools: "fromOrigins"
|
|
441
|
+
};
|
|
442
|
+
if (call.method !== "registerTool" && call.method !== "getTools") return;
|
|
443
|
+
return call.options?.properties.get(originOption[call.method])?.value;
|
|
444
|
+
}
|
|
445
|
+
function getOriginEntries(analyzer, node) {
|
|
446
|
+
const expression = resolveExpression(analyzer.source, node);
|
|
447
|
+
return expression.type === "ArrayExpression" ? expression.elements.filter((element) => element !== null && element.type !== "SpreadElement") : [];
|
|
448
|
+
}
|
|
449
|
+
function parseOrigin(value) {
|
|
450
|
+
try {
|
|
451
|
+
return new URL(value);
|
|
452
|
+
} catch {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const requireApiArguments = createApiRule({
|
|
457
|
+
name: "require-api-arguments",
|
|
458
|
+
description: "Require mandatory WebMCP call arguments.",
|
|
459
|
+
recommended: "error",
|
|
460
|
+
defaultOptions: [],
|
|
461
|
+
messages: {
|
|
462
|
+
missing: "{{method}} requires a tool argument.",
|
|
463
|
+
input: "chrome-2026-09-01 requires a JSON text input argument for executeTool."
|
|
464
|
+
},
|
|
465
|
+
check(call, analyzer, context) {
|
|
466
|
+
if (isCurrentCall(call) && call.method !== "getTools" && call.node.arguments.length === 0) context.report({
|
|
467
|
+
node: call.node,
|
|
468
|
+
messageId: "missing",
|
|
469
|
+
data: { method: call.method }
|
|
470
|
+
});
|
|
471
|
+
else if (isCurrentCall(call) && call.method === "executeTool" && analyzer.settings.target === "chrome-2026-09-01" && call.node.arguments.length === 1 && call.node.arguments[0]?.type !== "SpreadElement") context.report({
|
|
472
|
+
node: call.node,
|
|
473
|
+
messageId: "input"
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
const noUnknownApiOptions = createApiRule({
|
|
478
|
+
name: "no-unknown-api-options",
|
|
479
|
+
description: "Catch unrecognized or misplaced API options.",
|
|
480
|
+
type: "suggestion",
|
|
481
|
+
strict: "warn",
|
|
482
|
+
defaultOptions: [{ allowOptions: [] }],
|
|
483
|
+
schema: stringListOption("allowOptions"),
|
|
484
|
+
messages: { unknown: "Option \"{{name}}\" is not declared for {{method}} in {{target}} and may be ignored." },
|
|
485
|
+
check(call, analyzer, context, [options]) {
|
|
486
|
+
if (!isCurrentCall(call)) return;
|
|
487
|
+
for (const [name, property] of call.options?.properties ?? []) if (!API_OPTIONS[call.method]?.includes(name) && !options.allowOptions.includes(name)) context.report({
|
|
488
|
+
node: property.key,
|
|
489
|
+
messageId: "unknown",
|
|
490
|
+
data: {
|
|
491
|
+
name,
|
|
492
|
+
method: call.method,
|
|
493
|
+
target: analyzer.settings.target
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
const validAbortSignal = createApiRule({
|
|
499
|
+
name: "valid-abort-signal",
|
|
500
|
+
description: "Reject values that are definitely not AbortSignals.",
|
|
501
|
+
recommended: "error",
|
|
502
|
+
defaultOptions: [],
|
|
503
|
+
messages: { invalid: "Pass an AbortSignal, such as controller.signal, as the signal option." },
|
|
504
|
+
check(call, analyzer, context) {
|
|
505
|
+
const node = isCurrentCall(call) && call.method !== "getTools" ? call.options?.properties.get("signal")?.value : void 0;
|
|
506
|
+
if (!node) return;
|
|
507
|
+
const expression = resolveExpression(analyzer.source, node);
|
|
508
|
+
const fact = readValue(analyzer.source, expression);
|
|
509
|
+
if (fact.kind === "known" && fact.value !== void 0 || expression.type === "ObjectExpression" || expression.type === "ArrayExpression" || expression.type === "ArrowFunctionExpression" || expression.type === "FunctionExpression" || expression.type === "NewExpression" && isGlobal(analyzer.source, expression.callee, "AbortController")) context.report({
|
|
510
|
+
node,
|
|
511
|
+
messageId: "invalid"
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
const validOriginList = createApiRule({
|
|
516
|
+
name: "valid-origin-list",
|
|
517
|
+
description: "Validate known origin lists and absolute URL strings.",
|
|
518
|
+
recommended: "error",
|
|
519
|
+
defaultOptions: [],
|
|
520
|
+
messages: {
|
|
521
|
+
list: "The origin list must be an iterable object of absolute URL strings.",
|
|
522
|
+
url: "\"{{origin}}\" cannot be parsed as an absolute origin URL."
|
|
523
|
+
},
|
|
524
|
+
check(call, analyzer, context) {
|
|
525
|
+
const node = getOrigins(call);
|
|
526
|
+
if (!node) return;
|
|
527
|
+
const fact = readValue(analyzer.source, node);
|
|
528
|
+
if (fact.kind === "known" && fact.value !== void 0 && (fact.value === null || typeof fact.value !== "object")) {
|
|
529
|
+
context.report({
|
|
530
|
+
node,
|
|
531
|
+
messageId: "list"
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
for (const entry of getOriginEntries(analyzer, node)) {
|
|
536
|
+
const origin = readString(analyzer.source, entry);
|
|
537
|
+
if (origin !== void 0 && !parseOrigin(origin)) context.report({
|
|
538
|
+
node: entry,
|
|
539
|
+
messageId: "url",
|
|
540
|
+
data: { origin }
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
const preferCanonicalOrigin = createApiRule({
|
|
546
|
+
name: "prefer-canonical-origin",
|
|
547
|
+
description: "Make origin-wide exposure explicit in URL options.",
|
|
548
|
+
type: "suggestion",
|
|
549
|
+
strict: "warn",
|
|
550
|
+
defaultOptions: [],
|
|
551
|
+
messages: { canonical: "This option matches the entire origin; credentials, paths, queries and fragments do not narrow its scope." },
|
|
552
|
+
check(call, analyzer, context) {
|
|
553
|
+
const node = getOrigins(call);
|
|
554
|
+
if (!node) return;
|
|
555
|
+
for (const entry of getOriginEntries(analyzer, node)) {
|
|
556
|
+
const origin = readString(analyzer.source, entry);
|
|
557
|
+
const url = origin === void 0 ? void 0 : parseOrigin(origin);
|
|
558
|
+
if (url && url.origin !== "null" && (url.username || url.password || url.pathname !== "/" && url.pathname !== "" || url.search || url.hash)) context.report({
|
|
559
|
+
node: entry,
|
|
560
|
+
messageId: "canonical"
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
const noPreabortedRegistrationSignal = createApiRule({
|
|
566
|
+
name: "no-preaborted-registration-signal",
|
|
567
|
+
description: "Avoid registering with an already-aborted signal.",
|
|
568
|
+
type: "suggestion",
|
|
569
|
+
strict: "warn",
|
|
570
|
+
defaultOptions: [],
|
|
571
|
+
messages: { aborted: "AbortSignal.abort() produces an already-aborted signal; this tool will not be registered." },
|
|
572
|
+
check(call, analyzer, context) {
|
|
573
|
+
const node = isCurrentCall(call) && call.method === "registerTool" ? call.options?.properties.get("signal")?.value : void 0;
|
|
574
|
+
if (!node) return;
|
|
575
|
+
const expression = resolveExpression(analyzer.source, node);
|
|
576
|
+
if (expression.type !== "CallExpression") return;
|
|
577
|
+
const callee = unwrap(expression.callee);
|
|
578
|
+
if (callee.type === "MemberExpression" && getKey(callee) === "abort" && isGlobal(analyzer.source, callee.object, "AbortSignal")) context.report({
|
|
579
|
+
node,
|
|
580
|
+
messageId: "aborted"
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
//#endregion
|
|
585
|
+
//#region src/rules/consumer.ts
|
|
586
|
+
const validRegisteredTool = createApiRule({
|
|
587
|
+
name: "valid-registered-tool",
|
|
588
|
+
description: "Validate statically known RegisteredTool dictionaries.",
|
|
589
|
+
recommended: "error",
|
|
590
|
+
defaultOptions: [],
|
|
591
|
+
messages: {
|
|
592
|
+
invalid: "executeTool expects a RegisteredTool dictionary, not a tool name or primitive.",
|
|
593
|
+
missing: "RegisteredTool is missing required property \"{{property}}\"."
|
|
594
|
+
},
|
|
595
|
+
check(call, analyzer, context) {
|
|
596
|
+
if (!isCurrentCall(call) || call.method !== "executeTool" || !call.toolNode) return;
|
|
597
|
+
const fact = readValue(analyzer.source, call.toolNode);
|
|
598
|
+
if (fact.kind === "known" && (fact.value === null || typeof fact.value !== "object")) {
|
|
599
|
+
context.report({
|
|
600
|
+
node: call.toolNode,
|
|
601
|
+
messageId: "invalid"
|
|
602
|
+
});
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
for (const property of [
|
|
606
|
+
"name",
|
|
607
|
+
"description",
|
|
608
|
+
"window",
|
|
609
|
+
"origin"
|
|
610
|
+
]) {
|
|
611
|
+
const field = getField(call.tool, property);
|
|
612
|
+
const value = field.kind === "known" ? readValue(analyzer.source, field.node) : void 0;
|
|
613
|
+
if (field.kind === "missing" || value?.kind === "known" && value.value === void 0) context.report({
|
|
614
|
+
node: field.kind === "known" ? field.node : call.toolNode,
|
|
615
|
+
messageId: "missing",
|
|
616
|
+
data: { property }
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
const noUnknownModelContextEvents = createApiRule({
|
|
622
|
+
name: "no-unknown-model-context-events",
|
|
623
|
+
description: "Catch unrecognized native ModelContext event names.",
|
|
624
|
+
type: "suggestion",
|
|
625
|
+
strict: "warn",
|
|
626
|
+
defaultOptions: [{ allowEvents: [] }],
|
|
627
|
+
schema: stringListOption("allowEvents"),
|
|
628
|
+
messages: { unknown: "{{target}} declares the native event \"toolchange\", not \"{{event}}\". Allow intentional custom events explicitly." },
|
|
629
|
+
check(call, analyzer, context, [options]) {
|
|
630
|
+
if (call.receiver !== "document" || !call.bound || !["addEventListener", "removeEventListener"].includes(call.method)) return;
|
|
631
|
+
const [node] = call.node.arguments;
|
|
632
|
+
const event = readString(analyzer.source, node);
|
|
633
|
+
if (node && event !== void 0 && event !== "toolchange" && !options.allowEvents.includes(event)) context.report({
|
|
634
|
+
node,
|
|
635
|
+
messageId: "unknown",
|
|
636
|
+
data: {
|
|
637
|
+
event,
|
|
638
|
+
target: analyzer.settings.target
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
//#endregion
|
|
644
|
+
//#region src/rules/core.ts
|
|
645
|
+
function isValidToolName(name) {
|
|
646
|
+
return name.length > 0 && name.length <= 128 && !/[^A-Za-z0-9_.-]/u.test(name);
|
|
647
|
+
}
|
|
648
|
+
const requireToolProperties = createApiRule({
|
|
649
|
+
name: "require-tool-properties",
|
|
650
|
+
description: "Require the mandatory tool definition fields.",
|
|
651
|
+
recommended: "error",
|
|
652
|
+
defaultOptions: [],
|
|
653
|
+
messages: {
|
|
654
|
+
missing: "Tool definition is missing required property \"{{property}}\".",
|
|
655
|
+
invalid: "A tool definition must be a dictionary with name, description and execute."
|
|
656
|
+
},
|
|
657
|
+
check(call, analyzer, context) {
|
|
658
|
+
if (!isDefinition(call) || !call.toolNode) return;
|
|
659
|
+
const fact = readValue(analyzer.source, call.toolNode);
|
|
660
|
+
if (fact.kind === "known" && (fact.value === null || typeof fact.value !== "object")) {
|
|
661
|
+
context.report({
|
|
662
|
+
node: call.toolNode,
|
|
663
|
+
messageId: "invalid"
|
|
664
|
+
});
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
for (const property of [
|
|
668
|
+
"name",
|
|
669
|
+
"description",
|
|
670
|
+
"execute"
|
|
671
|
+
]) {
|
|
672
|
+
const field = getField(call.tool, property);
|
|
673
|
+
const value = field.kind === "known" ? readValue(analyzer.source, field.node) : void 0;
|
|
674
|
+
if (field.kind === "missing" || value?.kind === "known" && value.value === void 0) context.report({
|
|
675
|
+
node: field.kind === "known" ? field.node : call.toolNode,
|
|
676
|
+
messageId: "missing",
|
|
677
|
+
data: { property }
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
const validToolName = createApiRule({
|
|
683
|
+
name: "valid-tool-name",
|
|
684
|
+
description: "Validate static tool names against the target API.",
|
|
685
|
+
recommended: "error",
|
|
686
|
+
defaultOptions: [],
|
|
687
|
+
messages: { invalid: "Tool names must contain 1–128 ASCII letters, digits, underscores, hyphens or dots." },
|
|
688
|
+
check(call, analyzer, context) {
|
|
689
|
+
if (!isDefinition(call)) return;
|
|
690
|
+
const node = call.tool?.properties.get("name")?.value;
|
|
691
|
+
const name = readString(analyzer.source, node);
|
|
692
|
+
if (node && name !== void 0 && !isValidToolName(name)) context.report({
|
|
693
|
+
node,
|
|
694
|
+
messageId: "invalid"
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
const validExecute = createApiRule({
|
|
699
|
+
name: "valid-execute",
|
|
700
|
+
description: "Reject definitely non-callable execute values.",
|
|
701
|
+
recommended: "error",
|
|
702
|
+
defaultOptions: [],
|
|
703
|
+
messages: { invalid: "The execute property must be callable; an async keyword is not required." },
|
|
704
|
+
check(call, analyzer, context) {
|
|
705
|
+
const node = isDefinition(call) ? call.tool?.properties.get("execute")?.value : void 0;
|
|
706
|
+
const value = readValue(analyzer.source, node);
|
|
707
|
+
if (node && !(value.kind === "known" && value.value === void 0) && isDefinitelyNonCallable(analyzer.source, node)) context.report({
|
|
708
|
+
node,
|
|
709
|
+
messageId: "invalid"
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
const noEmptyDescription = createApiRule({
|
|
714
|
+
name: "no-empty-description",
|
|
715
|
+
description: "Require nonempty, non-whitespace tool descriptions.",
|
|
716
|
+
type: "suggestion",
|
|
717
|
+
recommended: "warn",
|
|
718
|
+
defaultOptions: [],
|
|
719
|
+
messages: { empty: "Describe what this tool does; its description is empty or whitespace-only." },
|
|
720
|
+
check(call, analyzer, context) {
|
|
721
|
+
const node = isDefinition(call) ? call.tool?.properties.get("description")?.value : void 0;
|
|
722
|
+
const description = readString(analyzer.source, node);
|
|
723
|
+
if (node && description !== void 0 && description.trim().length === 0) context.report({
|
|
724
|
+
node,
|
|
725
|
+
messageId: "empty"
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
const noUnsupportedApi = createApiRule({
|
|
730
|
+
name: "no-unsupported-api",
|
|
731
|
+
description: "Report legacy WebMCP entry points and removed methods.",
|
|
732
|
+
recommended: "error",
|
|
733
|
+
defaultOptions: [],
|
|
734
|
+
messages: {
|
|
735
|
+
legacy: "{{target}} uses document.modelContext; migrate the entry point and registration lifecycle together.",
|
|
736
|
+
removed: "{{method}} is not supported by {{target}}. Registration lifetime is controlled by options.signal."
|
|
737
|
+
},
|
|
738
|
+
check(call, analyzer, context) {
|
|
739
|
+
if (!isNativeMethod(call)) return;
|
|
740
|
+
if (call.receiver === "navigator") context.report({
|
|
741
|
+
node: call.node.callee,
|
|
742
|
+
messageId: "legacy",
|
|
743
|
+
data: { target: analyzer.settings.target }
|
|
744
|
+
});
|
|
745
|
+
else if (LEGACY_METHODS.includes(call.method)) context.report({
|
|
746
|
+
node: call.node.callee,
|
|
747
|
+
messageId: "removed",
|
|
748
|
+
data: {
|
|
749
|
+
target: analyzer.settings.target,
|
|
750
|
+
method: call.method
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
const noUnboundApiCall = createApiRule({
|
|
756
|
+
name: "no-unbound-api-call",
|
|
757
|
+
description: "Keep the native ModelContext receiver when calling its methods.",
|
|
758
|
+
recommended: "error",
|
|
759
|
+
defaultOptions: [],
|
|
760
|
+
messages: { unbound: "Call {{method}} on its ModelContext receiver, or explicitly bind the method to that receiver." },
|
|
761
|
+
check(call, _analyzer, context) {
|
|
762
|
+
if (isNativeMethod(call) && !call.bound) context.report({
|
|
763
|
+
node: call.node.callee,
|
|
764
|
+
messageId: "unbound",
|
|
765
|
+
data: { method: call.method }
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
//#endregion
|
|
770
|
+
//#region src/rules/metadata.ts
|
|
771
|
+
const noUnknownToolProperties = createApiRule({
|
|
772
|
+
name: "no-unknown-tool-properties",
|
|
773
|
+
description: "Catch unrecognized tool definition properties.",
|
|
774
|
+
type: "suggestion",
|
|
775
|
+
strict: "warn",
|
|
776
|
+
defaultOptions: [{ allowProperties: [] }],
|
|
777
|
+
schema: stringListOption("allowProperties"),
|
|
778
|
+
messages: { unknown: "Tool property \"{{name}}\" is not declared by {{target}} and may be ignored." },
|
|
779
|
+
check(call, analyzer, context, [options]) {
|
|
780
|
+
if (!isDefinition(call)) return;
|
|
781
|
+
for (const [name, property] of call.tool?.properties ?? []) if (!TOOL_PROPERTIES.includes(name) && !options.allowProperties.includes(name)) context.report({
|
|
782
|
+
node: property.key,
|
|
783
|
+
messageId: "unknown",
|
|
784
|
+
data: {
|
|
785
|
+
name,
|
|
786
|
+
target: analyzer.settings.target
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
const validAnnotations = createApiRule({
|
|
792
|
+
name: "valid-annotations",
|
|
793
|
+
description: "Use supported hint names and explicit boolean values.",
|
|
794
|
+
type: "suggestion",
|
|
795
|
+
strict: "warn",
|
|
796
|
+
defaultOptions: [],
|
|
797
|
+
messages: {
|
|
798
|
+
unknown: "Annotation \"{{name}}\" is not declared by {{target}} and may be ignored.",
|
|
799
|
+
boolean: "Write an explicit boolean for \"{{name}}\"; Web IDL coerces other values by truthiness.",
|
|
800
|
+
dictionary: "Use an annotations dictionary with explicit boolean hint values."
|
|
801
|
+
},
|
|
802
|
+
check(call, analyzer, context) {
|
|
803
|
+
const node = isDefinition(call) ? call.tool?.properties.get("annotations")?.value : void 0;
|
|
804
|
+
if (!node) return;
|
|
805
|
+
const fact = readValue(analyzer.source, node);
|
|
806
|
+
if (fact.kind === "known" && fact.value !== null && fact.value !== void 0 && typeof fact.value !== "object") {
|
|
807
|
+
context.report({
|
|
808
|
+
node,
|
|
809
|
+
messageId: "dictionary"
|
|
810
|
+
});
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
for (const [name, property] of readObject(analyzer.source, node)?.properties ?? []) if (ANNOTATIONS.includes(name)) {
|
|
814
|
+
const value = readValue(analyzer.source, property.value);
|
|
815
|
+
if (value.kind === "known" && value.value !== void 0 && typeof value.value !== "boolean") context.report({
|
|
816
|
+
node: property.value,
|
|
817
|
+
messageId: "boolean",
|
|
818
|
+
data: { name }
|
|
819
|
+
});
|
|
820
|
+
} else context.report({
|
|
821
|
+
node: property.key,
|
|
822
|
+
messageId: "unknown",
|
|
823
|
+
data: {
|
|
824
|
+
name,
|
|
825
|
+
target: analyzer.settings.target
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
//#endregion
|
|
831
|
+
//#region src/rules/schema.ts
|
|
832
|
+
function containsBigInt(value) {
|
|
833
|
+
if (typeof value === "bigint") return true;
|
|
834
|
+
return typeof value === "object" && value !== null && Object.values(value).some((entry) => containsBigInt(entry));
|
|
835
|
+
}
|
|
836
|
+
let validator = null;
|
|
837
|
+
function getValidator() {
|
|
838
|
+
validator ??= new Ajv2020({
|
|
839
|
+
strict: false,
|
|
840
|
+
allErrors: false,
|
|
841
|
+
validateFormats: false
|
|
842
|
+
});
|
|
843
|
+
return validator;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Visit schema keywords only, never enum/default/example data or remote references.
|
|
847
|
+
*
|
|
848
|
+
* @param source The source containing schema expressions
|
|
849
|
+
* @param node The root schema expression
|
|
850
|
+
* @param visit Called for each known parameter definition
|
|
851
|
+
* @param depth Current recursion depth
|
|
852
|
+
* @param budget Shared remaining node budget
|
|
853
|
+
*/
|
|
854
|
+
function visitParameters(source, node, visit, depth = 0, budget) {
|
|
855
|
+
const remaining = budget ?? { remaining: 1e3 };
|
|
856
|
+
if (depth > 20 || remaining.remaining-- <= 0) return;
|
|
857
|
+
const object = readObject(source, node);
|
|
858
|
+
if (!object) return;
|
|
859
|
+
for (const [key, property] of object.properties) if ([
|
|
860
|
+
"properties",
|
|
861
|
+
"$defs",
|
|
862
|
+
"patternProperties",
|
|
863
|
+
"dependentSchemas"
|
|
864
|
+
].includes(key)) for (const [name, child] of readObject(source, property.value)?.properties ?? []) {
|
|
865
|
+
if (key === "properties") visit(name, child.value);
|
|
866
|
+
visitParameters(source, child.value, visit, depth + 1, remaining);
|
|
867
|
+
}
|
|
868
|
+
else if ([
|
|
869
|
+
"items",
|
|
870
|
+
"additionalProperties",
|
|
871
|
+
"contains",
|
|
872
|
+
"not",
|
|
873
|
+
"if",
|
|
874
|
+
"then",
|
|
875
|
+
"else",
|
|
876
|
+
"propertyNames",
|
|
877
|
+
"unevaluatedProperties",
|
|
878
|
+
"unevaluatedItems"
|
|
879
|
+
].includes(key)) visitParameters(source, property.value, visit, depth + 1, remaining);
|
|
880
|
+
else if ([
|
|
881
|
+
"allOf",
|
|
882
|
+
"anyOf",
|
|
883
|
+
"oneOf",
|
|
884
|
+
"prefixItems"
|
|
885
|
+
].includes(key)) {
|
|
886
|
+
const array = resolveExpression(source, property.value);
|
|
887
|
+
const elements = array.type === "ArrayExpression" ? array.elements : [];
|
|
888
|
+
for (const child of elements) if (child && child.type !== "SpreadElement") visitParameters(source, child, visit, depth + 1, remaining);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const validInputSchema = createApiRule({
|
|
892
|
+
name: "valid-input-schema",
|
|
893
|
+
description: "Check platform schema boundaries and optionally JSON Schema 2020-12 syntax.",
|
|
894
|
+
recommended: "error",
|
|
895
|
+
defaultOptions: [{ mode: "platform" }],
|
|
896
|
+
schema: [{ oneOf: [{
|
|
897
|
+
type: "object",
|
|
898
|
+
properties: { mode: {
|
|
899
|
+
type: "string",
|
|
900
|
+
enum: ["platform"]
|
|
901
|
+
} },
|
|
902
|
+
required: ["mode"],
|
|
903
|
+
additionalProperties: false
|
|
904
|
+
}, {
|
|
905
|
+
type: "object",
|
|
906
|
+
properties: {
|
|
907
|
+
mode: {
|
|
908
|
+
type: "string",
|
|
909
|
+
enum: ["json-schema"]
|
|
910
|
+
},
|
|
911
|
+
dialect: {
|
|
912
|
+
type: "string",
|
|
913
|
+
enum: ["2020-12"]
|
|
914
|
+
}
|
|
915
|
+
},
|
|
916
|
+
required: ["mode", "dialect"],
|
|
917
|
+
additionalProperties: false
|
|
918
|
+
}] }],
|
|
919
|
+
messages: {
|
|
920
|
+
object: "inputSchema must be an object when provided.",
|
|
921
|
+
serialization: "inputSchema contains a BigInt and cannot be JSON serialized.",
|
|
922
|
+
schema: "Invalid JSON Schema 2020-12: {{reason}}."
|
|
923
|
+
},
|
|
924
|
+
check(call, analyzer, context, [options]) {
|
|
925
|
+
const node = isDefinition(call) ? call.tool?.properties.get("inputSchema")?.value : void 0;
|
|
926
|
+
if (!node) return;
|
|
927
|
+
const fact = readValue(analyzer.source, node);
|
|
928
|
+
if (fact.kind !== "known" || fact.value === void 0) return;
|
|
929
|
+
if (fact.value === null || typeof fact.value !== "object") {
|
|
930
|
+
context.report({
|
|
931
|
+
node,
|
|
932
|
+
messageId: "object"
|
|
933
|
+
});
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (containsBigInt(fact.value)) {
|
|
937
|
+
context.report({
|
|
938
|
+
node,
|
|
939
|
+
messageId: "serialization"
|
|
940
|
+
});
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (options.mode !== "json-schema") return;
|
|
944
|
+
const schema = JSON.parse(JSON.stringify(fact.value));
|
|
945
|
+
if (typeof schema !== "object" || schema === null) return;
|
|
946
|
+
if ("$schema" in schema && schema.$schema !== "https://json-schema.org/draft/2020-12/schema" && schema.$schema !== "https://json-schema.org/draft/2020-12/schema#") return;
|
|
947
|
+
const ajv = getValidator();
|
|
948
|
+
if (!ajv.validateSchema(schema)) context.report({
|
|
949
|
+
node,
|
|
950
|
+
messageId: "schema",
|
|
951
|
+
data: { reason: ajv.errorsText(ajv.errors) }
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
const noIncompatibleExecuteArguments = createApiRule({
|
|
956
|
+
name: "no-incompatible-execute-arguments",
|
|
957
|
+
description: "Use the executeTool input representation required by the target.",
|
|
958
|
+
recommended: "error",
|
|
959
|
+
defaultOptions: [],
|
|
960
|
+
messages: {
|
|
961
|
+
object: "{{target}} expects an input object; do not pass JSON text or primitives.",
|
|
962
|
+
text: "{{target}} expects JSON text; explicitly serialize the input object."
|
|
963
|
+
},
|
|
964
|
+
check(call, analyzer, context) {
|
|
965
|
+
if (call.receiver !== "document" || !call.bound || call.method !== "executeTool" || call.node.arguments.some((argument, index) => index <= 1 && argument.type === "SpreadElement")) return;
|
|
966
|
+
const [, node] = call.node.arguments;
|
|
967
|
+
if (!node) return;
|
|
968
|
+
const fact = readValue(analyzer.source, node);
|
|
969
|
+
const expression = resolveExpression(analyzer.source, node);
|
|
970
|
+
if (analyzer.settings.target === "draft-2026-09-04") {
|
|
971
|
+
if (fact.kind === "known" && fact.value !== void 0 && (fact.value === null || typeof fact.value !== "object")) context.report({
|
|
972
|
+
node,
|
|
973
|
+
messageId: "object",
|
|
974
|
+
data: { target: analyzer.settings.target }
|
|
975
|
+
});
|
|
976
|
+
} else if (expression.type === "ObjectExpression" || expression.type === "ArrayExpression") context.report({
|
|
977
|
+
node,
|
|
978
|
+
messageId: "text",
|
|
979
|
+
data: { target: analyzer.settings.target }
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
const preferParameterDescriptions = createApiRule({
|
|
984
|
+
name: "prefer-parameter-descriptions",
|
|
985
|
+
description: "Describe statically known input parameters.",
|
|
986
|
+
type: "suggestion",
|
|
987
|
+
strict: "warn",
|
|
988
|
+
defaultOptions: [],
|
|
989
|
+
messages: { missing: "Describe input parameter \"{{name}}\"." },
|
|
990
|
+
check(call, analyzer, context) {
|
|
991
|
+
const node = isDefinition(call) ? call.tool?.properties.get("inputSchema")?.value : void 0;
|
|
992
|
+
if (!node) return;
|
|
993
|
+
visitParameters(analyzer.source, node, (name, schema) => {
|
|
994
|
+
const object = readObject(analyzer.source, schema);
|
|
995
|
+
if (!object || object.properties.has("$ref")) return;
|
|
996
|
+
const description = object.properties.get("description")?.value;
|
|
997
|
+
const text = readString(analyzer.source, description);
|
|
998
|
+
if (!description && object.complete || text !== void 0 && text.trim().length === 0) context.report({
|
|
999
|
+
node: description ?? schema,
|
|
1000
|
+
messageId: "missing",
|
|
1001
|
+
data: { name }
|
|
1002
|
+
});
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
//#endregion
|
|
1007
|
+
//#region src/rules/quality.ts
|
|
1008
|
+
const preferToolTitle = createApiRule({
|
|
1009
|
+
name: "prefer-tool-title",
|
|
1010
|
+
description: "Opt into human-readable tool titles.",
|
|
1011
|
+
type: "suggestion",
|
|
1012
|
+
defaultOptions: [],
|
|
1013
|
+
messages: { title: "Provide a human-readable title for this tool." },
|
|
1014
|
+
check(call, analyzer, context) {
|
|
1015
|
+
if (!isDefinition(call) || !call.toolNode) return;
|
|
1016
|
+
const title = getField(call.tool, "title");
|
|
1017
|
+
const value = title.kind === "known" ? readValue(analyzer.source, title.node) : void 0;
|
|
1018
|
+
if (title.kind === "missing" || value?.kind === "known" && (value.value === void 0 || typeof value.value === "string" && value.value.trim().length === 0)) context.report({
|
|
1019
|
+
node: title.kind === "known" ? title.node : call.toolNode,
|
|
1020
|
+
messageId: "title"
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
const noPlaceholderDescription = createApiRule({
|
|
1025
|
+
name: "no-placeholder-description",
|
|
1026
|
+
description: "Replace complete placeholder descriptions with useful text.",
|
|
1027
|
+
type: "suggestion",
|
|
1028
|
+
strict: "warn",
|
|
1029
|
+
defaultOptions: [{ placeholders: [
|
|
1030
|
+
"TODO",
|
|
1031
|
+
"TBD",
|
|
1032
|
+
"FIXME"
|
|
1033
|
+
] }],
|
|
1034
|
+
schema: stringListOption("placeholders"),
|
|
1035
|
+
messages: { placeholder: "Replace the placeholder description \"{{description}}\" with the tool’s purpose." },
|
|
1036
|
+
check(call, analyzer, context, [options]) {
|
|
1037
|
+
const node = isDefinition(call) ? call.tool?.properties.get("description")?.value : void 0;
|
|
1038
|
+
const description = readString(analyzer.source, node);
|
|
1039
|
+
if (node && description && options.placeholders.some((placeholder) => placeholder.trim().toLowerCase() === description.trim().toLowerCase())) context.report({
|
|
1040
|
+
node,
|
|
1041
|
+
messageId: "placeholder",
|
|
1042
|
+
data: { description }
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
const maxDescriptionLength = createApiRule({
|
|
1047
|
+
name: "max-description-length",
|
|
1048
|
+
description: "Apply configurable Unicode code-point budgets to descriptions.",
|
|
1049
|
+
type: "suggestion",
|
|
1050
|
+
defaultOptions: [{
|
|
1051
|
+
tool: 500,
|
|
1052
|
+
parameter: 150,
|
|
1053
|
+
unit: "codePoint"
|
|
1054
|
+
}],
|
|
1055
|
+
schema: [{
|
|
1056
|
+
type: "object",
|
|
1057
|
+
properties: {
|
|
1058
|
+
tool: {
|
|
1059
|
+
type: "integer",
|
|
1060
|
+
minimum: 1
|
|
1061
|
+
},
|
|
1062
|
+
parameter: {
|
|
1063
|
+
type: "integer",
|
|
1064
|
+
minimum: 1
|
|
1065
|
+
},
|
|
1066
|
+
unit: {
|
|
1067
|
+
type: "string",
|
|
1068
|
+
enum: ["codePoint"]
|
|
1069
|
+
}
|
|
1070
|
+
},
|
|
1071
|
+
additionalProperties: false
|
|
1072
|
+
}],
|
|
1073
|
+
messages: { long: "This description has {{length}} Unicode code points; the configured limit is {{limit}}." },
|
|
1074
|
+
check(call, analyzer, context, [options]) {
|
|
1075
|
+
if (!isDefinition(call)) return;
|
|
1076
|
+
const reportDescription = (node, limit) => {
|
|
1077
|
+
const description = readString(analyzer.source, node);
|
|
1078
|
+
if (node && description !== void 0 && [...description].length > limit) context.report({
|
|
1079
|
+
node,
|
|
1080
|
+
messageId: "long",
|
|
1081
|
+
data: {
|
|
1082
|
+
length: [...description].length,
|
|
1083
|
+
limit
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
};
|
|
1087
|
+
reportDescription(call.tool?.properties.get("description")?.value, options.tool ?? 500);
|
|
1088
|
+
const schema = call.tool?.properties.get("inputSchema")?.value;
|
|
1089
|
+
if (schema) visitParameters(analyzer.source, schema, (_name, parameter) => {
|
|
1090
|
+
reportDescription(readObject(analyzer.source, parameter)?.properties.get("description")?.value, options.parameter ?? 150);
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
const NAME_PATTERNS = {
|
|
1095
|
+
snake_case: /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u,
|
|
1096
|
+
"kebab-case": /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u,
|
|
1097
|
+
camelCase: /^[a-z][a-zA-Z0-9]*$/u
|
|
1098
|
+
};
|
|
1099
|
+
//#endregion
|
|
1100
|
+
//#region src/rules/index.ts
|
|
1101
|
+
const rules = {
|
|
1102
|
+
"require-tool-properties": requireToolProperties,
|
|
1103
|
+
"valid-tool-name": validToolName,
|
|
1104
|
+
"valid-execute": validExecute,
|
|
1105
|
+
"no-empty-description": noEmptyDescription,
|
|
1106
|
+
"no-unsupported-api": noUnsupportedApi,
|
|
1107
|
+
"no-unbound-api-call": noUnboundApiCall,
|
|
1108
|
+
"valid-input-schema": validInputSchema,
|
|
1109
|
+
"no-incompatible-execute-arguments": noIncompatibleExecuteArguments,
|
|
1110
|
+
"prefer-parameter-descriptions": preferParameterDescriptions,
|
|
1111
|
+
"no-unknown-tool-properties": noUnknownToolProperties,
|
|
1112
|
+
"valid-annotations": validAnnotations,
|
|
1113
|
+
"require-api-arguments": requireApiArguments,
|
|
1114
|
+
"no-unknown-api-options": noUnknownApiOptions,
|
|
1115
|
+
"valid-abort-signal": validAbortSignal,
|
|
1116
|
+
"valid-origin-list": validOriginList,
|
|
1117
|
+
"prefer-canonical-origin": preferCanonicalOrigin,
|
|
1118
|
+
"no-preaborted-registration-signal": noPreabortedRegistrationSignal,
|
|
1119
|
+
"valid-registered-tool": validRegisteredTool,
|
|
1120
|
+
"no-unknown-model-context-events": noUnknownModelContextEvents,
|
|
1121
|
+
"prefer-tool-title": preferToolTitle,
|
|
1122
|
+
"max-description-length": maxDescriptionLength,
|
|
1123
|
+
"no-placeholder-description": noPlaceholderDescription,
|
|
1124
|
+
"tool-name-convention": createApiRule({
|
|
1125
|
+
name: "tool-name-convention",
|
|
1126
|
+
description: "Apply a project-specific naming convention to valid tool names.",
|
|
1127
|
+
type: "suggestion",
|
|
1128
|
+
defaultOptions: [{
|
|
1129
|
+
style: "snake_case",
|
|
1130
|
+
segmentStyle: "kebab-case",
|
|
1131
|
+
prefix: ""
|
|
1132
|
+
}],
|
|
1133
|
+
schema: [{
|
|
1134
|
+
type: "object",
|
|
1135
|
+
properties: {
|
|
1136
|
+
style: {
|
|
1137
|
+
type: "string",
|
|
1138
|
+
enum: [
|
|
1139
|
+
"snake_case",
|
|
1140
|
+
"kebab-case",
|
|
1141
|
+
"camelCase",
|
|
1142
|
+
"dot-separated"
|
|
1143
|
+
]
|
|
1144
|
+
},
|
|
1145
|
+
segmentStyle: {
|
|
1146
|
+
type: "string",
|
|
1147
|
+
enum: [
|
|
1148
|
+
"snake_case",
|
|
1149
|
+
"kebab-case",
|
|
1150
|
+
"camelCase"
|
|
1151
|
+
]
|
|
1152
|
+
},
|
|
1153
|
+
prefix: { type: "string" }
|
|
1154
|
+
},
|
|
1155
|
+
additionalProperties: false
|
|
1156
|
+
}],
|
|
1157
|
+
messages: { convention: "Tool name \"{{name}}\" does not follow the configured {{style}} style and prefix \"{{prefix}}\"." },
|
|
1158
|
+
check(call, analyzer, context, [options]) {
|
|
1159
|
+
const node = isDefinition(call) ? call.tool?.properties.get("name")?.value : void 0;
|
|
1160
|
+
const name = readString(analyzer.source, node);
|
|
1161
|
+
if (!node || name === void 0 || !isValidToolName(name)) return;
|
|
1162
|
+
const { style = "snake_case", segmentStyle = "kebab-case", prefix = "" } = options;
|
|
1163
|
+
const suffix = name.slice(prefix.length);
|
|
1164
|
+
const matchesStyle = style === "dot-separated" ? suffix.split(".").every((segment) => NAME_PATTERNS[segmentStyle].test(segment)) : NAME_PATTERNS[style].test(suffix);
|
|
1165
|
+
if (!name.startsWith(prefix) || !matchesStyle) context.report({
|
|
1166
|
+
node,
|
|
1167
|
+
messageId: "convention",
|
|
1168
|
+
data: {
|
|
1169
|
+
name,
|
|
1170
|
+
style,
|
|
1171
|
+
prefix
|
|
1172
|
+
}
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
}),
|
|
1176
|
+
"require-explicit-annotations": createApiRule({
|
|
1177
|
+
name: "require-explicit-annotations",
|
|
1178
|
+
description: "Require explicit evaluation of configured tool hints.",
|
|
1179
|
+
type: "suggestion",
|
|
1180
|
+
defaultOptions: [{ fields: ["readOnlyHint", "consequentialHint"] }],
|
|
1181
|
+
schema: [{
|
|
1182
|
+
type: "object",
|
|
1183
|
+
properties: { fields: {
|
|
1184
|
+
type: "array",
|
|
1185
|
+
items: {
|
|
1186
|
+
type: "string",
|
|
1187
|
+
enum: ANNOTATIONS
|
|
1188
|
+
},
|
|
1189
|
+
minItems: 1,
|
|
1190
|
+
uniqueItems: true
|
|
1191
|
+
} },
|
|
1192
|
+
additionalProperties: false
|
|
1193
|
+
}],
|
|
1194
|
+
messages: { missing: "Explicitly evaluate the \"{{name}}\" annotation for this tool." },
|
|
1195
|
+
check(call, analyzer, context, [options]) {
|
|
1196
|
+
if (!isDefinition(call) || !call.toolNode) return;
|
|
1197
|
+
const field = getField(call.tool, "annotations");
|
|
1198
|
+
const object = field.kind === "known" ? readObject(analyzer.source, field.node) : void 0;
|
|
1199
|
+
const value = field.kind === "known" ? readValue(analyzer.source, field.node) : void 0;
|
|
1200
|
+
for (const name of options.fields) {
|
|
1201
|
+
const annotation = getField(object, name);
|
|
1202
|
+
const hint = annotation.kind === "known" ? readValue(analyzer.source, annotation.node) : void 0;
|
|
1203
|
+
if (field.kind === "missing" || value?.kind === "known" && (value.value === null || value.value === void 0) || annotation.kind === "missing" || hint?.kind === "known" && hint.value === void 0) context.report({
|
|
1204
|
+
node: field.kind === "known" ? field.node : call.toolNode,
|
|
1205
|
+
messageId: "missing",
|
|
1206
|
+
data: { name }
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
})
|
|
1211
|
+
};
|
|
1212
|
+
//#endregion
|
|
1213
|
+
//#region src/index.ts
|
|
1214
|
+
const plugin = {
|
|
1215
|
+
meta: {
|
|
1216
|
+
name,
|
|
1217
|
+
version
|
|
1218
|
+
},
|
|
1219
|
+
rules
|
|
1220
|
+
};
|
|
1221
|
+
/**
|
|
1222
|
+
* Create a parser-independent Flat Config for a fixed WebMCP API snapshot.
|
|
1223
|
+
*
|
|
1224
|
+
* @param options Target, preset and optional file globs
|
|
1225
|
+
* @returns A standalone Flat Config referencing this plugin
|
|
1226
|
+
*/
|
|
1227
|
+
function createConfig(options = {}) {
|
|
1228
|
+
const target = resolveTarget(options.target);
|
|
1229
|
+
const preset = options.preset ?? "recommended";
|
|
1230
|
+
if (preset !== "recommended" && preset !== "strict") throw new Error("webmcp: preset must be \"recommended\" or \"strict\".");
|
|
1231
|
+
if (options.files !== void 0 && (!Array.isArray(options.files) || options.files.length === 0 || !options.files.every((file) => typeof file === "string" && file.length > 0))) throw new Error("webmcp: files must be a nonempty array of glob strings.");
|
|
1232
|
+
const enabled = {};
|
|
1233
|
+
for (const [ruleName, rule] of Object.entries(rules)) {
|
|
1234
|
+
const severity = rule.meta.docs?.recommended ?? (preset === "strict" ? rule.meta.docs?.strict : void 0);
|
|
1235
|
+
if (severity) enabled[`webmcp/${ruleName}`] = severity;
|
|
1236
|
+
}
|
|
1237
|
+
return {
|
|
1238
|
+
name: `webmcp/${preset}/${target}`,
|
|
1239
|
+
files: options.files ? [...options.files] : ["**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx}"],
|
|
1240
|
+
plugins: { webmcp: plugin },
|
|
1241
|
+
settings: { webmcp: { target } },
|
|
1242
|
+
rules: enabled
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
const configs = {
|
|
1246
|
+
recommended: createConfig(),
|
|
1247
|
+
strict: createConfig({ preset: "strict" })
|
|
1248
|
+
};
|
|
1249
|
+
const webmcp = Object.assign(plugin, { configs });
|
|
1250
|
+
//#endregion
|
|
1251
|
+
export { createConfig, webmcp as default };
|