Skip to main content
interlace
Plugin: mcp-sdk-securityRules

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.

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
.oxlintrc.json
{  "jsPlugins": ["oxlint-plugin-mcp-sdk-security"],  "rules": {    "mcp-sdk-security/require-tool-input-schema": "error"  }}
vite.config.ts
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.