Guide · Code Quality & Release Tooling

MCP Server ESLint — v9 flat config, TypeScript rules, no-floating-promises

Three ESLint behaviours trip up TypeScript MCP server projects: ESLint v9 replaced .eslintrc with a flat eslint.config.js file — the old format still works in v9 via a compatibility layer but prints deprecation warnings, and the new format requires different import syntax for every plugin; @typescript-eslint/recommended-type-checked requires parserOptions.project pointing to your tsconfig.json — omitting it silently falls back to rules that don't need type information, leaving the most valuable type-aware rules like no-floating-promises and no-misused-promises disabled; and floating promises kill MCP stdio servers silently — an unhandled promise rejection in the stdio transport crashes the server process with no error message visible to the connected client, making @typescript-eslint/no-floating-promises the single highest-value lint rule for MCP tool handler code.

TL;DR

Install eslint, @eslint/js, typescript-eslint, and eslint-plugin-n. Create eslint.config.js (not .eslintrc) using the tseslint.config() helper. Enable tseslint.configs.recommendedTypeChecked and set languageOptions.parserOptions.project: true so type-aware rules activate. Add '@typescript-eslint/no-floating-promises': 'error' explicitly — a floating server.tool() registration or an unawaited async call inside a handler silently crashes the MCP stdio transport.

ESLint v9 flat config for a TypeScript MCP server

ESLint v9 uses a flat config file (eslint.config.js or eslint.config.mjs) instead of .eslintrc.json. Each entry in the exported array is a config object that applies to a glob pattern — there is no extends key any more; instead, you spread config arrays directly. The typescript-eslint package (the new unified package, not the older @typescript-eslint/eslint-plugin + @typescript-eslint/parser split) provides a tseslint.config() helper that handles the spread correctly.

// eslint.config.js  (ESM — add "type": "module" to package.json, or use .mjs)
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import pluginN from 'eslint-plugin-n';

export default tseslint.config(
  // Ignore generated / built output — never lint these
  {
    ignores: ['dist/**', 'node_modules/**', '*.d.ts'],
  },

  // Base JS recommended rules
  eslint.configs.recommended,

  // TypeScript rules — recommendedTypeChecked requires parserOptions.project
  ...tseslint.configs.recommendedTypeChecked,

  {
    // Applies to all .ts files in the project
    files: ['**/*.ts'],
    languageOptions: {
      parserOptions: {
        // `true` = auto-locate the nearest tsconfig.json for each linted file
        // Alternatively, pass an explicit path: project: './tsconfig.json'
        project: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
    plugins: {
      n: pluginN,
    },
    rules: {
      // CRITICAL for MCP stdio servers: unhandled promise rejections kill the process
      '@typescript-eslint/no-floating-promises': 'error',

      // Catches async callbacks passed where a sync callback is expected
      '@typescript-eslint/no-misused-promises': 'error',

      // MCP tools often return complex objects — require explicit return types
      '@typescript-eslint/explicit-function-return-type': [
        'warn',
        {
          allowExpressions: true,       // arrow functions in inline callbacks ok
          allowTypedFunctionExpressions: true,
        },
      ],

      // Catches require() calls in ESM MCP servers
      'n/no-missing-require': 'error',

      // Disallow process.exit() in tool handlers — kills the MCP server
      'n/no-process-exit': 'error',

      // Prefer specific error-handling over swallowing errors with empty catch
      'no-empty': ['error', { allowEmptyCatch: false }],
    },
  },

  // Relax rules for test files
  {
    files: ['**/*.test.ts', '**/*.spec.ts', 'tests/**/*.ts'],
    rules: {
      '@typescript-eslint/no-explicit-any': 'off',
      '@typescript-eslint/no-floating-promises': 'off', // test runners handle this
    },
  },
);

The parserOptions.project: true option tells the TypeScript ESLint parser to find the tsconfig.json nearest to each file being linted. This is the recommended setting for single-package MCP servers. For monorepos with per-package tsconfigs, use project: true combined with tsconfigRootDir: import.meta.dirname so each package finds its own config rather than a root-level one.

Why no-floating-promises is the highest-value rule for MCP servers

MCP stdio servers communicate over stdin/stdout. Any unhandled promise rejection causes Node.js to print to stderr and, depending on the Node.js version and the --unhandled-rejections flag, either continue running or exit. In MCP clients like Claude Desktop or Cursor, the stderr output is often invisible — the tool call simply hangs or returns an error, and the developer has no stack trace to debug from. The no-floating-promises rule catches the most common patterns that lead to this:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

const server = new McpServer({ name: 'example', version: '1.0.0' });

// ❌ WRONG — server.tool() is synchronous but the handler is async
// If the handler throws asynchronously, no-floating-promises catches this indirectly
// via the return type annotation on the handler
server.tool(
  'fetch_data',
  { url: z.string().url() },
  async ({ url }) => {
    const data = await fetch(url).then(r => r.json()); // throws on network error
    return { content: [{ type: 'text', text: JSON.stringify(data) }] };
    // Missing: error handling — network error = unhandled rejection = server crash
  }
);

// ✓ CORRECT — catch all async errors inside tool handlers
server.tool(
  'fetch_data',
  { url: z.string().url() },
  async ({ url }) => {
    try {
      const response = await fetch(url);
      if (!response.ok) {
        return {
          content: [{ type: 'text', text: `HTTP ${response.status}: ${response.statusText}` }],
          isError: true,
        };
      }
      const data = await response.json() as unknown;
      return { content: [{ type: 'text', text: JSON.stringify(data) }] };
    } catch (err: unknown) {
      const message = err instanceof Error ? err.message : String(err);
      return {
        content: [{ type: 'text', text: `Error: ${message}` }],
        isError: true,
      };
    }
  }
);

// ❌ LINT ERROR — floating promise (no-floating-promises catches this)
async function loadConfig(): Promise<void> {
  await fs.readFile('config.json');
}
loadConfig(); // ESLint error: Promises must be awaited, end with a call to .catch, or ...

// ✓ CORRECT — await or catch
await loadConfig();
// or if you can't use top-level await:
void loadConfig(); // explicit void disables the rule for intentional fire-and-forget

The void operator explicitly marks an intentional fire-and-forget operation. Use it when you genuinely want to start an async operation without waiting — for example, kicking off background telemetry after a tool handler has already returned. no-floating-promises treats void expr as deliberately ignored, while bare expr (where expr is a Promise) is an error.

Type-aware rules that catch MCP-specific mistakes

The recommendedTypeChecked rule set enables several rules that require type information and are especially useful in MCP server code. These run slower than syntax-only rules because ESLint invokes the TypeScript compiler's language service, but they catch a class of bugs that syntax-only rules cannot.

// package.json scripts for MCP server projects
{
  "scripts": {
    "lint":       "eslint src/",
    "lint:fix":   "eslint src/ --fix",
    "typecheck":  "tsc --noEmit",
    // Run both together in CI — lint first (faster), then typecheck
    "check":      "npm run lint && npm run typecheck"
  }
}

// Type-aware rules that catch real MCP server bugs:

// 1. @typescript-eslint/no-unsafe-assignment
//    Catches: const result = await someAsyncTool() assigned to a typed variable
//    when someAsyncTool() returns `any` (common with older JS libraries)

// 2. @typescript-eslint/no-unsafe-member-access
//    Catches: accessing .content on an `any` typed response without narrowing
//    MCP tool result objects are typed — if you're getting `any`, the type is wrong

// 3. @typescript-eslint/require-await
//    Catches: async functions that don't contain any `await` expression
//    Common in MCP tool handlers that were made async "just in case" but never needed it

// 4. @typescript-eslint/no-misused-promises
//    Catches: passing an async function where a sync callback is expected
//    Example: Array.filter(async (x) => ...) — filter ignores the returned Promise

// Example of no-misused-promises catching a real MCP bug:
const tools = ['search', 'summarize', 'classify'];

// ❌ WRONG — Array.forEach doesn't await the async callback
tools.forEach(async (toolName) => {
  await server.connect(toolName); // floating promise inside forEach — no-misused-promises
});

// ✓ CORRECT — use Promise.all with .map for async iteration
await Promise.all(
  tools.map(async (toolName) => {
    await server.connect(toolName);
  })
);

Run npm run check in CI before every merge. ESLint with type-aware rules typically takes 5–15 seconds on a medium-sized MCP server codebase — fast enough for pre-push hooks but too slow for pre-commit (where you want sub-second feedback on staged files only). Use lint-staged with syntax-only ESLint for pre-commit and the full type-aware check for pre-push or CI.

Adding eslint-plugin-n for Node.js-specific MCP server rules

eslint-plugin-n (the successor to eslint-plugin-node) adds rules that are particularly relevant for MCP servers running in Node.js. The most important: n/no-process-exit prevents process.exit() calls inside tool handlers (which would kill the whole MCP server, not just the current tool call), and n/no-missing-require catches missing npm dependencies that TypeScript's module resolution might not flag.

// eslint.config.js — Node.js rules for MCP servers
import pluginN from 'eslint-plugin-n';

// Additional n/ rules useful for MCP server projects:
const nodeRules = {
  // Catch process.exit() in tool handlers
  'n/no-process-exit': 'error',

  // Ensure all require()/import paths resolve to installed packages
  'n/no-missing-require': 'error',
  'n/no-missing-import': 'error',  // for ESM imports

  // Flag use of deprecated Node.js APIs
  'n/no-deprecated-api': 'error',

  // Prefer process.exitCode = 1; process.exit() over process.exit(1)
  // (lets cleanup handlers run)
  'n/prefer-global/process': ['error', 'always'],

  // Require error-first callback convention for non-promise async patterns
  // (less relevant for modern async/await MCP servers, but useful for older code)
  'n/no-callback-literal': 'error',
};

// Full config object
export default tseslint.config(
  { ignores: ['dist/**', 'node_modules/**'] },
  eslint.configs.recommended,
  ...tseslint.configs.recommendedTypeChecked,
  {
    files: ['**/*.ts'],
    plugins: { n: pluginN },
    languageOptions: {
      parserOptions: { project: true, tsconfigRootDir: import.meta.dirname },
    },
    rules: {
      '@typescript-eslint/no-floating-promises': 'error',
      '@typescript-eslint/no-misused-promises': 'error',
      ...nodeRules,
    },
  },
);

Install all required packages: npm install -D eslint @eslint/js typescript-eslint eslint-plugin-n. The typescript-eslint package (without the @ scope) is the modern unified package introduced in typescript-eslint v7 — it includes both the parser and plugin. Do not install the older @typescript-eslint/parser and @typescript-eslint/eslint-plugin separately unless you are still on ESLint v8.