Guide · Modern Build Toolchain

MCP Server Biome — all-in-one linter and formatter replacing ESLint + Prettier

Three Biome behaviours require adjustment when adopting it for MCP server projects: Biome does not require TypeScript type information to lint — unlike @typescript-eslint's type-aware rules (which require parserOptions.project and take 5–30 seconds on large projects), Biome's linter runs purely on the AST without type inference, meaning it finishes in under 100 ms but cannot enforce rules like no-floating-promises that require knowing whether an expression is a Promise; the nursery/noFloatingPromises rule exists in newer Biome versions but is not yet stable; Biome's formatter is opinionated like Prettier and most settings cannot be changed — you can set indentStyle, lineWidth, and trailingCommas, but there is no equivalent of Prettier's singleQuote, bracketSpacing, or semi per-file override; migrating an existing project from Prettier means reformatting all files to Biome's output, which creates a large diff on first adoption; and Biome does not support the ESLint plugin ecosystem — plugins like eslint-plugin-n (Node.js-specific rules), eslint-plugin-unicorn, and framework-specific plugins have no Biome equivalent; if your MCP server relies on those rules, you must keep ESLint for them or accept losing that coverage.

TL;DR

Run npx @biomejs/biome init to generate biome.json. Use biome check --apply src/ to lint and format in one pass. In lint-staged: npx @biomejs/biome check --apply --no-errors-on-unmatched --staged. In CI: biome ci src/ (fails on any issue, no auto-fix). Keep tsc --noEmit for type checking — Biome does not type-check.

biome.json configuration for TypeScript MCP servers

Biome reads configuration from biome.json in the project root. The three top-level sections — formatter, linter, and organizeImports — map to what ESLint+Prettier+eslint-plugin-import provided separately.

// biome.json — for a TypeScript MCP server project
{
  "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
  "organizeImports": {
    // Biome sorts imports automatically — replaces eslint-plugin-import/order
    "enabled": true
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",   // "tab" (Biome default) or "space" (Prettier-compatible)
    "indentWidth": 2,
    "lineWidth": 100,          // wider than Prettier's 80 — better for nested MCP handlers
    "lineEnding": "lf",
    "ignore": [
      "dist/**",
      "node_modules/**",
      "*.json",               // don't format JSON files (can conflict with tsconfig)
      ".changeset/**"         // don't reformat changeset files
    ]
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,    // enables Biome's recommended rule set
      "correctness": {
        // noUnusedVariables catches dead code in tool handlers:
        "noUnusedVariables": "error",
        // noUnusedImports keeps MCP server files clean:
        "noUnusedImports": "error"
      },
      "suspicious": {
        // noExplicitAny flags untyped tool inputs — important for MCP tool schemas:
        "noExplicitAny": "warn",
        // noConsoleLog warns on console.log left in tool handlers:
        "noConsole": "warn"
      },
      "style": {
        // useConst enforces const where possible — good practice for MCP servers:
        "useConst": "error",
        // noVar bans var in TypeScript MCP server code:
        "noVar": "error"
      },
      "nursery": {
        // noFloatingPromises catches unhandled promise rejections in tool handlers:
        // NOTE: This rule is in nursery (not yet stable) — test before enabling
        "noFloatingPromises": "warn"
      }
    }
  },
  "javascript": {
    "formatter": {
      // Use single quotes for strings — matches most MCP SDK examples:
      "quoteStyle": "single",
      "semicolons": "always",
      "trailingCommas": "all"
    }
  },
  "files": {
    "ignore": [
      "dist/**",
      "node_modules/**",
      "*.d.ts",
      "coverage/**"
    ]
  }
}

biome check — lint + format in one pass

biome check runs the linter, formatter, and import organizer simultaneously in a single Rust process. It's the primary command for both development and CI. biome check --apply fixes safe issues automatically; biome check --apply-unsafe also applies suggestions that might change behaviour.

# Development: lint, format, and organize imports in one step
npx @biomejs/biome check --apply src/

# CI: same check but exit 1 on any issue (no auto-fix)
npx @biomejs/biome ci src/

# Check a specific file (fast feedback while editing):
npx @biomejs/biome check --apply src/tools/search.ts

# Format only (no lint — rarely useful, prefer check):
npx @biomejs/biome format --write src/

# Lint only (no format):
npx @biomejs/biome lint --apply src/

# Check what would change without modifying files:
npx @biomejs/biome check src/       # reports issues, exit 0 if none, exit 1 if any

# Show detailed rule explanations:
npx @biomejs/biome explain noFloatingPromises

For MCP server projects with many tool handler files, biome check typically runs 50–200 ms — fast enough to run on every save in the IDE without noticeable lag. The VS Code extension (biomejs.biome) replaces both the ESLint and Prettier extensions and applies fixes on save.

lint-staged integration with Biome

Biome's --staged flag (Biome ≥ 1.5) applies the check only to files staged for commit, replacing the lint-staged + ESLint + Prettier combination with a single tool.

// package.json — Biome with lint-staged
{
  "scripts": {
    "check": "biome check src/",
    "check:fix": "biome check --apply src/",
    "typecheck": "tsc --noEmit"
  },
  "lint-staged": {
    "*.ts": [
      // Single command replaces: prettier --write + eslint --fix
      "npx @biomejs/biome check --apply --no-errors-on-unmatched --staged"
    ]
  },
  "devDependencies": {
    "@biomejs/biome": "^1.9.0",
    "husky": "^9.0.0",
    "lint-staged": "^15.0.0"
  }
}

// .husky/pre-commit — Husky v9 hook
#!/usr/bin/env sh
npx lint-staged

// .husky/pre-push — type check before push (Biome doesn't type-check)
#!/usr/bin/env sh
npx tsc --noEmit

The --no-errors-on-unmatched flag prevents Biome from failing when lint-staged passes only staged files (some may not match Biome's configured file patterns). The --staged flag tells Biome to only process files that are currently in the Git staging area — it reads the staged file list from Git directly, so it works correctly even if your working directory has additional unstaged changes.

Migrating from ESLint + Prettier to Biome

Biome ships migration commands that read your existing ESLint and Prettier configurations and generate an equivalent biome.json. The migration is not 100% coverage — rules unique to ESLint plugins won't migrate — but it handles the standard ESLint recommended + Prettier combination correctly.

# Step 1: Initialize Biome (creates biome.json)
npx @biomejs/biome init

# Step 2: Migrate existing ESLint config:
npx @biomejs/biome migrate eslint --write
# Reads .eslintrc.js / eslint.config.js and maps rules to biome.json equivalents
# Unsupported rules are listed in the output — decide whether to drop or keep ESLint for them

# Step 3: Migrate existing Prettier config:
npx @biomejs/biome migrate prettier --write
# Reads .prettierrc / prettier.config.js and maps formatting settings to biome.json

# Step 4: Reformat all files to Biome's output (creates a large one-time diff):
npx @biomejs/biome format --write src/
# Commit this as a standalone "chore: migrate formatting to Biome" commit
# so it's clear in git blame that lines changed due to tooling, not logic

# Step 5: Remove ESLint and Prettier (after confirming Biome covers your needs):
npm uninstall eslint prettier eslint-config-prettier @typescript-eslint/eslint-plugin \
  @typescript-eslint/parser eslint-plugin-import lint-staged
# Keep Husky — it still runs Biome via lint-staged

The migration works best for projects using standard eslint:recommended + @typescript-eslint/recommended + Prettier. Projects with heavy use of eslint-plugin-n (Node.js-specific rules) or eslint-plugin-security should keep ESLint alongside Biome — use Biome for formatting and basic linting, ESLint for the domain-specific plugin rules.

The noFloatingPromises gap in MCP server code

The most valuable @typescript-eslint rule for MCP servers is no-floating-promises, which catches unhandled Promise rejections in tool handlers. An unhandled rejection in a stdio MCP server crashes the entire server process silently. Biome's equivalent (nursery/noFloatingPromises) requires type information and is experimental as of Biome 1.x.

// MCP tool handler — floating promise is invisible to Biome's linter (for now):
server.tool('send_notification', schema, async (args) => {
  sendWebhook(args.url, args.payload);  // floating Promise — sendWebhook is async
  // @typescript-eslint/no-floating-promises would error here
  // Biome nursery/noFloatingPromises is experimental and may or may not catch this
  return { content: [{ type: 'text', text: 'sent' }] };
});

// Fix: always await or explicitly discard with void:
server.tool('send_notification', schema, async (args) => {
  await sendWebhook(args.url, args.payload);   // awaited — safe
  // OR, for intentional fire-and-forget:
  void sendWebhook(args.url, args.payload);    // explicit discard — Biome understands void
  return { content: [{ type: 'text', text: 'sent' }] };
});

Until nursery/noFloatingPromises becomes stable in Biome, MCP server projects that adopt Biome for formatting and basic linting should keep the no-floating-promises rule active in ESLint with @typescript-eslint — or enforce the pattern through pre-push type checking (tsc --noEmit) combined with explicit code review guidelines for async tool handlers.

Common failure modes

SymptomCauseFix
Floating promises in tool handlers not caughtnoFloatingPromises is nursery (unstable) in Biome 1.xKeep ESLint @typescript-eslint/no-floating-promises or add nursery/noFloatingPromises with caution
Large diff on first Biome adoptionBiome formatter output differs from Prettier for most filesCommit formatting migration as a standalone commit separate from logic changes
ESLint plugin rules disappear after migrationBiome has no equivalent for most ESLint plugin rulesKeep ESLint for plugin-specific rules; use Biome for base linting + formatting
--staged flag unknown errorBiome version < 1.5 doesn't support --stagedUpgrade to Biome ≥ 1.5 or use lint-staged file patterns without --staged
JSON files reformatted unexpectedlyBiome formats JSON by defaultAdd "*.json" to formatter.ignore in biome.json