@swissspidy/eslint-plugin-webmcp
v0.3.0
Published
ESLint rules that lint WebMCP registerTool() definitions statically, using the webmcp-lint rule engine.
Maintainers
Readme
@swissspidy/eslint-plugin-webmcp
ESLint rules for WebMCP tool definitions. Every rule is one of the tool-scoped rules of @swissspidy/webmcp-lint, run statically against the object literals passed to registerTool(), so problems show up in the editor and in eslint on CI before a browser ever loads the page.
npm install --save-dev @swissspidy/eslint-plugin-webmcp eslint// eslint.config.js
import webmcp from "@swissspidy/eslint-plugin-webmcp";
export default [
webmcp.configs.recommended,
// or pick rules yourself:
{
plugins: { webmcp },
rules: {
"webmcp/description-length": ["warn", { min: 30, max: 400 }],
"webmcp/sensitive-params": "error",
},
},
];document.modelContext.registerTool({
name: "add to cart", // webmcp/tool-name-valid: not a valid WebMCP tool name
description: "Add.", // webmcp/description-length: 4 characters; aim for at least 20
inputSchema: {
type: "object",
properties: { apiKey: { type: "string" } }, // webmcp/sensitive-params, webmcp/param-description-missing
required: ["apiKey"],
},
async execute({ apiKey }) {},
});Rules
Definition rules judge what a tool declares. They are the tool-scoped rules of @swissspidy/webmcp-lint, so a finding here is the same finding @swissspidy/playwright-webmcp and @swissspidy/webmcp-audit report from a live page.
Each rule has a page under docs/rules/ listing its options and defaults, which is also the URL your editor opens from a finding.
| Rule | Recommended | Checks |
| ----------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------- |
| webmcp/tool-name-valid | error | Name is 1-128 chars of [A-Za-z0-9_.-]. |
| webmcp/tool-name-style | off | Name follows the project's style (snake_case by default) and prefix. |
| webmcp/tool-title-missing | off | The tool has a title for clients to show people. |
| webmcp/description-missing | error | The tool has a description. |
| webmcp/description-length | warn | Between min (20) and max (600) characters. |
| webmcp/description-when-to-use | off | The description says when to call the tool, not only what it does. Heuristic; opt in. |
| webmcp/param-description-missing | warn | Every input property has a description. |
| webmcp/param-description-length | warn | Each parameter description, at any depth, is at most max (150) characters. |
| webmcp/schema-shape | error | Object schema; required entries exist in properties. |
| webmcp/schema-no-null-literals | error | No null anywhere in the schema (Chrome's Prompt API rejects it). |
| webmcp/schema-depth | warn | Property nesting at most max (3) levels. |
| webmcp/schema-unsupported-keywords | warn | Flags $ref, allOf, oneOf, anyOf, not, if/then/else, patternProperties. |
| webmcp/sensitive-params | warn | Parameter names that look like credentials or payment data (pattern overrides). |
| webmcp/description-injection | error | Instructions to the agent, role markers, or hidden characters in any tool text. |
| webmcp/annotations-explicit | off | The hints listed in fields (readOnlyHint, consequentialHint) are declared on every tool. |
| webmcp/exposed-to-secure-origins | error | registerTool(tool, { exposedTo }) lists something the API rejects: an insecure origin, or "*". |
| webmcp/exposed-to-origin-only | warn | An exposedTo entry carries a path, query, fragment or credentials, which the API ignores. |
| webmcp/declarative-description | error | JSX <form toolname> also has tooldescription. |
| webmcp/declarative-field-description | warn | Each named field in the form has a <label>, aria-label or toolparamdescription. |
| webmcp/declarative-autosubmit-sensitive | error | toolautosubmit on a form with a password field or a payment/one-time-code autocomplete. |
Source rules judge the code that calls the API. They have no @swissspidy/webmcp-lint counterpart because nothing they catch survives to runtime. Shape mistakes in the dictionaries themselves (an unknown property, a missing execute, a misspelt hint) are TypeScript's job, with the webmcp-types package installed.
| Rule | Recommended | Checks |
| ----------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| webmcp/valid-event-name | error | Listeners for tool* events name toolchange, toolactivated or toolcancel. |
| webmcp/no-interpolated-text | warn | Tool text assembled at runtime from values the author cannot name the source of. |
| webmcp/navigation-consequential | warn | A tool whose execute navigates (location, History and Navigation APIs, routers, navigate()) declares consequentialHint and not readOnlyHint. |
| webmcp/consequential-abort-signal | warn | A tool that declares consequentialHint and waits in its execute reads the abort signal it is given (its second parameter), so a canceled call stops. |
Rule options are the same objects @swissspidy/webmcp-lint accepts. webmcp.configs.recommended turns on every rule marked above except the off ones; webmcp.configs.all turns every rule, the opt-in ones included, into an error.
Where definitions are found
Out of the box the plugin looks at the argument of registerTool() and the argument of useWebMCP() from use-webmcp-tool, whose object has the same shape. Bare calls and member calls both match, so useWebMCP({...}) and document.modelContext.registerTool({...}) are found the same way.
Wrappers of your own are declared once in ESLint's shared settings, and every rule picks them up:
// eslint.config.js
export default [
webmcp.configs.recommended,
{
settings: {
webmcp: {
definitions: [
"defineAgentTool", // defineAgentTool({ name, description, inputSchema, ... })
{ call: "register", argument: 1 }, // register("scope", { name, ... })
{ call: "addTools", tools: "items" }, // addTools({ items: [{ name, ... }, ...] })
],
},
},
},
];An entry is a call name, or { call, argument = 0, tools?, options? }: argument is the index of the definition, tools names the property holding an array of definitions, and options is the index of an argument that may carry exposedTo. Entries add to the defaults.
The definition does not have to be inline. An identifier is followed to its initialiser when it is a const or let declared once in the same file and never reassigned, so this is linted too, with findings pointing into the literal:
const searchTool = { name: "search", description: "...", inputSchema: {...}, execute };
useWebMCP(searchTool);Tools are often defined in one module and registered in another, where no call names them. So an object literal anywhere in the file also counts as a definition when it is shaped like one: a literal string name, an execute and an inputSchema. One written as a ModelContextTool counts too (satisfies ModelContextTool, const t: ModelContextTool = {...}), since inputSchema is optional. name, description and execute alone are not enough, because CLI and chat commands look the same. A literal that is both exported and registered in the file is reported once.
// tools.js: linted even though registerTool() is called elsewhere
export const searchFlightsTool = { name: "searchFlights", description: "...", inputSchema: {...}, execute: searchFlights };If objects of your own match that shape and are not tools, turn it off with settings: { webmcp: { toolObjects: false } }, and name your registration calls in definitions instead.
Declarative tools in JSX
A <form toolname="..."> in a React component is a declarative tool, and the three declarative-* rules judge it the way the browser collector would: the form needs a tooldescription, every named <input>, <select> and <textarea> needs a <label htmlFor>, a wrapping <label>, an aria-label or a toolparamdescription, and toolautosubmit must not sit on a form with a password field or a field whose autocomplete names a payment card or a one-time code. Findings point at the form or the field. Attributes are matched case-insensitively, so toolName works too. A form with a computed tooldescription gets no description findings, and a field with a spread ({...props}) or a computed label attribute gets no field findings: what cannot be read is not reported.
What it can and cannot see
The plugin reads literals: strings, numbers, booleans, arrays and nested objects, including values wrapped in as const or satisfies when a TypeScript parser is used, and string literals joined with + to wrap a long line. A field whose value is computed (an import, a function call, a template with expressions, a spread, a getter) is treated as unknown, and findings about that field are dropped for that tool rather than guessed at; description-injection still checks a literal description when only the schema is computed, and reports separately on title and on annotation values. A tool whose name is not a literal has no definition to judge, so the rules that need one skip it; no-interpolated-text still reports on it, because a name assembled at runtime is what that rule is looking for. When a key is written twice the last one counts, as at runtime. A binding that is visibly mutated in place (tool.name = ..., delete tool.x, Object.assign(tool, ...)) is not followed; one passed to another function is assumed to come back unchanged.
ESLint looks at one file at a time, so some things are out of its reach. A clean lint does not mean they are fine. Each one is covered by a check that runs against the page, where the browser has already built the tool:
| Out of static reach | Example | Check it with |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| A definition built at runtime | tools.map((t) => ({ name: t.id, ... })), a schema from zodToJsonSchema(), a description imported from another module | await expect(webmcp).toPassLint() in a Playwright test, or lintTools() in a unit test of the module that builds the tools |
| What execute does through an event, a store or another module | execute dispatches a CustomEvent and a listener elsewhere calls navigate() | await expect(webmcp).toPassSmoke({ tools: ["search_flights"] }): result-navigates watches the URL during the call |
| Whether execute stops when canceled, when the work is in another module | execute: (args, client) => orders.place(args, client), where orders.place ignores client.signal | a Playwright test: webmcp.call("place_order", {}, { cancelAfter: "activated" }), then check no order request went out |
| What execute returns | an error written as a string, a result over the 1,500-character budget, null inside the result | the same smoke run: result-accepts-invalid-input, result-too-large, result-contains-null |
| What the page registers as a whole | two components registering the same name, 30 tools on one route | toPassLint({ scope: "page" }), or @swissspidy/webmcp-audit against a URL |
Smoke runs only read-only tools unless you name tools or pass all: true. Point it at a test page or mocked backend before running tools that act. A project that runs this plugin can keep its Playwright assertion to scope: "page", so each static finding is reported once, in the editor. Add a smoke run for the tools whose behaviour the plugin cannot follow.
Rules that need the whole page are not here on purpose: duplicate names, near-identical descriptions, tool count and cross-origin frames depend on what the page actually registers at runtime. Run those with @swissspidy/playwright-webmcp in a test, or with @swissspidy/webmcp-audit against a URL. The rule list and severities are documented in the repository README.
oxlint
The rules use only the ESLint rule contract (a CallExpression visitor, context.sourceCode.getScope, context.settings, context.report), so oxlint loads the package as a JS plugin. The test suite runs the real oxlint binary against it, including settings and rule options.
// .oxlintrc.json
{
"jsPlugins": ["@swissspidy/eslint-plugin-webmcp"],
"settings": { "webmcp": { "definitions": ["defineAgentTool"] } },
"rules": {
"webmcp/tool-name-valid": "error",
"webmcp/description-missing": "error",
"webmcp/description-injection": "error",
"webmcp/schema-shape": "error",
"webmcp/schema-no-null-literals": "error",
"webmcp/exposed-to-secure-origins": "error",
"webmcp/valid-event-name": "error",
"webmcp/no-interpolated-text": "warn",
"webmcp/navigation-consequential": "warn",
"webmcp/consequential-abort-signal": "warn",
"webmcp/description-length": "warn",
"webmcp/param-description-missing": "warn",
"webmcp/param-description-length": "warn",
"webmcp/schema-depth": "warn",
"webmcp/schema-unsupported-keywords": "warn",
"webmcp/sensitive-params": "warn",
},
}oxlint has no equivalent of configs.recommended, so rules are listed individually. Its JS plugin support is marked alpha upstream.
