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.