require-tool-input-schema
Require an input schema when an MCP tool handler reads arguments.
Require an input schema when an MCP tool handler reads arguments.
- CWE: CWE-20 — Improper Input Validation
- OWASP: A03:2021 — Injection
- CVSS: 5.3 (Medium)
- Presets:
minimal,recommended,strict
Why
When a tool declares no inputSchema, the SDK calls its handler as
handler(extra) — v1's executeToolHandler, v2's createToolExecutor. No
client argument reaches it. A schema-less zero-argument tool (get_time,
list_projects, whoami) is therefore correct, and this rule is silent on it.
What is wrong is a schema-less handler that expects arguments. The keys it reads were never declared, so nothing validated them, and the SDK never passes them: the handler is reading the request context. Either way, the fix is to declare the schema.
Rule details
The rule fires only in files that import an MCP server package
(@modelcontextprotocol/sdk, the v2 @modelcontextprotocol/* packages,
mcp-handler). It reports a registerTool(...) / legacy tool(...) call with
no input schema whose handler — inline, or a function declared in the same
file — does one of:
- destructures a key from its first parameter that is not a request-context
key (
signal,authInfo,sessionId,_meta,requestInfo,mcpReq,http, …); - reads such a named property off its first parameter (
args.path); - declares a second parameter, which without a schema is always
undefined.
It stays silent when it cannot see the configuration or the handler: a config passed by reference or containing a spread, a legacy object it cannot classify as a params shape or annotations, and a handler imported from another module.
Incorrect
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
// `path` is read off the request context — it is never the caller's argument.
server.registerTool('read_file', { description: 'Read a file' }, async ({ path }) =>
readFileSync(path, 'utf8'),
);
server.tool('delete_record', async (args) => db.delete(args.id));Correct
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
server.registerTool('read_file', {
description: 'Read a file',
inputSchema: { path: z.string() },
}, async ({ path }) => readFileSync(path, 'utf8'));
server.tool('delete_record', { id: z.string().uuid() }, async (args) => db.delete(args.id));
// A zero-argument tool needs no schema.
server.registerTool('get_time', { description: 'Server time' }, async () => ({
content: [{ type: 'text', text: new Date().toISOString() }],
}));When not to use it
If the server registers tools through a wrapper that injects the schema, the rule cannot see it. Disable it at that call site rather than repeating the schema.
Further reading
Use this rule
npm install -D oxlint-plugin-mcp-sdk-security{ "jsPlugins": ["oxlint-plugin-mcp-sdk-security"], "rules": { "mcp-sdk-security/require-tool-input-schema": "error" }}import { defineConfig } from 'vite-plus';export default defineConfig({ lint: { jsPlugins: ['oxlint-plugin-mcp-sdk-security'], rules: { 'mcp-sdk-security/require-tool-input-schema': 'error', }, },});oxlint-plugin-mcp-sdk-security re-exports eslint-plugin-mcp-sdk-security/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.
Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.