Guide · Developer Experience

MCP server local development — full local stack setup

Most MCP server tutorials show you how to write a tool handler. Few show you what the whole local environment looks like: how to structure the project, what TypeScript settings work with the MCP SDK, how to manage environment variables across dev and prod, how to wire the MCP Inspector for interactive testing, and how to build a dev loop that makes iteration fast. This guide covers the complete setup — from npm init to a running server you can poke from Claude Desktop in under 10 minutes.

TL;DR

Scaffold with npm init -y; install @modelcontextprotocol/sdk tsx zod zod-to-json-schema as dependencies and typescript @types/node vitest as devDependencies. Set "module": "node16" and "moduleResolution": "node16" in tsconfig.json — the MCP SDK uses .js import extensions that require node16 module resolution. Use better-sqlite3 for local data (no server to spin up). Store secrets in .env loaded with --env-file .env (Node 20.6+) or dotenv. Run tsx --watch src/index.ts for hot reload. Wire Inspector with npx @modelcontextprotocol/inspector npm run dev.

Project structure

A well-organized MCP server project separates tool definitions, business logic, and infrastructure so each can be tested and changed independently.

my-mcp-server/
├── src/
│   ├── index.ts          # server entry point — creates Server, connects transport
│   ├── tools/
│   │   ├── index.ts      # registers all tools on the Server instance
│   │   ├── search.ts     # one file per tool or tool group
│   │   └── write.ts
│   ├── deps.ts           # factory: createDeps() returns DB + HTTP clients
│   ├── db.ts             # SQLite schema + query functions
│   └── types.ts          # shared TypeScript types
├── tests/
│   ├── search.test.ts
│   └── write.test.ts
├── data/
│   └── .gitkeep          # SQLite files live here, gitignored
├── .env                  # local secrets — never commit
├── .env.example          # committed template with placeholder values
├── .gitignore
├── package.json
├── tsconfig.json
└── vitest.config.ts

Key decisions: src/index.ts does nothing except create the server and connect the transport — all tool registration happens in src/tools/index.ts, all database access in src/db.ts. This separation lets tests import createServer(deps) without starting a real transport, which is the pattern required by InMemoryTransport testing.

package.json — dependencies and scripts

{
  "name": "my-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev":        "tsx --watch src/index.ts",
    "dev:debug":  "tsx --watch --inspect src/index.ts",
    "build":      "tsc",
    "typecheck":  "tsc --noEmit",
    "start":      "node dist/index.js",
    "test":       "vitest run",
    "test:watch": "vitest"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.0",
    "better-sqlite3": "^9.0.0",
    "zod": "^3.0.0",
    "zod-to-json-schema": "^3.0.0"
  },
  "devDependencies": {
    "@types/better-sqlite3": "^7.0.0",
    "@types/node": "^22.0.0",
    "tsx": "^4.0.0",
    "typescript": "^5.0.0",
    "vitest": "^2.0.0"
  }
}

Use "type": "module" for ES modules — the MCP SDK is published as ESM. This means you will use import everywhere, and the dist/ folder will contain .js files that Node runs as ESM.

tsconfig.json — settings that work with the MCP SDK

The MCP SDK imports its own modules using .js extensions (import { Server } from './server/index.js'). TypeScript's node16 and bundler module resolution modes understand these extension-bearing imports. Classic node resolution does not — it will fail to resolve .js-suffixed imports from the SDK.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "node16",
    "moduleResolution": "node16",
    "lib": ["ES2022"],
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": false,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist", "tests"]
}

Common mistakes that cause confusing import errors:

MistakeErrorFix
"moduleResolution": "node"Cannot find module @modelcontextprotocol/sdk/server/index.jsChange to "node16" or "bundler"
"module": "commonjs"ERR_REQUIRE_ESM at runtimeChange to "node16" + add "type": "module" to package.json
Import without .js extension in your own filesModule not found at runtimeUse import { x } from './module.js' even for .ts source files
skipLibCheck: falseType errors in MCP SDK type declarationsSet skipLibCheck: true — SDK types assume strict mode settings

Local data layer — better-sqlite3

better-sqlite3 is a synchronous SQLite binding for Node.js. It requires no server, no Docker, and no connection pool — just a file. For local development and small-to-medium production MCP servers, it is the simplest data layer that works.

// src/db.ts
import Database from 'better-sqlite3';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));

export function openDb(path?: string): Database.Database {
  const dbPath = path ?? join(__dirname, '..', 'data', 'dev.db');
  const db = new Database(dbPath);

  // Performance settings for development
  db.pragma('journal_mode = WAL');    // safe for hot-reload restarts
  db.pragma('synchronous = NORMAL');  // faster writes, still crash-safe with WAL

  // Schema migrations inline for simplicity (use a migration library for production)
  db.exec(`
    CREATE TABLE IF NOT EXISTS items (
      id    INTEGER PRIMARY KEY AUTOINCREMENT,
      name  TEXT NOT NULL,
      value TEXT,
      created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
    );
    CREATE INDEX IF NOT EXISTS items_name ON items(name);
  `);

  return db;
}

// Typed query helpers — one function per query keeps SQL out of tool handlers
export function getItemById(db: Database.Database, id: number) {
  return db.prepare('SELECT * FROM items WHERE id = ?').get(id) as Item | undefined;
}

export function searchItems(db: Database.Database, query: string, limit = 20) {
  return db.prepare('SELECT * FROM items WHERE name LIKE ? LIMIT ?')
    .all(`%${query}%`, limit) as Item[];
}

export interface Item {
  id: number;
  name: string;
  value: string | null;
  created_at: string;
}

Keep data/dev.db in .gitignore. Add data/.gitkeep to ensure the directory is committed. Use a different path in production: DB_PATH=/var/data/prod.db in the production environment.

Environment variables

MCP servers run as child processes of the MCP client (Claude Desktop, Inspector, a custom agent). Environment variables set in the client config are passed to the server process. In local development, use a .env file.

# .env.example — commit this; replace values in .env
API_BASE_URL=https://api.example.com
API_KEY=replace-me
DB_PATH=./data/dev.db
LOG_LEVEL=debug
NODE_ENV=development
# Load .env with Node 20.6+ (no dotenv dependency needed)
# In package.json scripts:
"dev": "tsx --watch --env-file .env src/index.ts"

# Or with dotenv (Node < 20.6):
"dev": "tsx --watch src/index.ts"
# And in src/index.ts: import 'dotenv/config';

Access environment variables with null-coalescing defaults that make misconfigurations obvious:

// src/config.ts
function required(name: string): string {
  const v = process.env[name];
  if (!v) throw new Error(`Required env var ${name} is not set`);
  return v;
}

export const config = {
  apiBaseUrl: process.env.API_BASE_URL ?? 'http://localhost:3000',
  apiKey:     required('API_KEY'),
  dbPath:     process.env.DB_PATH ?? './data/dev.db',
  logLevel:   (process.env.LOG_LEVEL ?? 'info') as 'debug' | 'info' | 'warn' | 'error',
};

The required() function throws at startup — not during a tool call — so misconfiguration is immediately obvious rather than silently producing undefined behavior when a tool is called.

The deps factory pattern

All external dependencies (database, HTTP clients, third-party SDKs) are constructed in a single createDeps() factory function and passed into the server. This makes testing simple (inject mock deps), makes hot reload clean (deps reinitializes on restart), and makes the dependency graph explicit.

// src/deps.ts
import { openDb } from './db.js';
import { config } from './config.js';
import type Database from 'better-sqlite3';

export interface Deps {
  db: Database.Database;
  apiBaseUrl: string;
  apiKey: string;
  close: () => Promise<void>;
}

export async function createDeps(overrides?: Partial<Deps>): Promise<Deps> {
  const db = overrides?.db ?? openDb(config.dbPath);

  return {
    db,
    apiBaseUrl: overrides?.apiBaseUrl ?? config.apiBaseUrl,
    apiKey:     overrides?.apiKey     ?? config.apiKey,
    close: async () => { db.close(); },
    ...overrides,
  };
}
// src/index.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createDeps } from './deps.js';
import { registerTools } from './tools/index.js';

async function main() {
  const deps = await createDeps();

  const server = new Server(
    { name: 'my-mcp-server', version: '1.0.0' },
    { capabilities: { tools: {} } }
  );

  registerTools(server, deps);

  const transport = new StdioServerTransport();
  await server.connect(transport);

  const shutdown = async () => { await deps.close(); process.exit(0); };
  process.on('SIGTERM', shutdown);
  process.on('SIGINT',  shutdown);
}

main().catch(err => { console.error(err); process.exit(1); });

Wiring MCP Inspector

Inspector is the fastest way to test tools interactively during local development. Configure it once and use it throughout development:

# Run server and Inspector together (Inspector manages the server subprocess)
npx @modelcontextprotocol/inspector npm run dev

# Or open Inspector in the browser and point it at a running server
npm run dev &
npx @modelcontextprotocol/inspector --server-process-pid $!

Alternatively, add an Inspector script to package.json:

"inspect": "npx @modelcontextprotocol/inspector npm run dev"

In Inspector's UI: the Tools tab lists all registered tools with their inputSchema rendered as a form. Fill in arguments, click "Call Tool", and see the response. The History tab shows every tool call and response from the current session — useful for reviewing LLM-like call sequences during development.

Claude Desktop config for local dev

To test your local server with Claude Desktop, add a dev entry to the config file. Use absolute paths — Claude Desktop spawns the server from its own working directory, not yours.

// macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "my-server-dev": {
      "command": "/usr/local/bin/npx",
      "args": ["tsx", "--env-file", "/Users/you/my-mcp-server/.env",
               "--watch", "/Users/you/my-mcp-server/src/index.ts"],
      "env": {
        "NODE_ENV": "development"
      }
    }
  }
}

Restart Claude Desktop after editing the config. Use which npx to get the absolute path to npx — Claude Desktop may not have the same PATH as your shell. The --env-file flag loads your .env from the project directory so Claude Desktop's spawned process has the same environment variables as your npm run dev session.

Local dev checklist before first tool call

  1. Run npm run typecheck — zero type errors in src/
  2. Run npm run dev — server starts, no startup errors in stderr
  3. Run npm run inspect — Inspector connects, Tools tab shows your tools
  4. Call each tool with valid args — verify successful response
  5. Call each tool with invalid args — verify isError: true response (not a crash)
  6. Run npm test — all unit tests pass
  7. Edit a tool handler — verify Inspector reconnects and reflects the change within 2 seconds

Related questions

Should I use Postgres locally instead of SQLite?

Only if your production environment uses Postgres and you have seen SQLite-vs-Postgres behavioral differences cause bugs in your specific queries. For most MCP servers — especially those doing simple lookups or accumulating logs — SQLite with WAL mode is behaviorally equivalent to Postgres for the query patterns that matter. If you do use Postgres locally, docker compose up -d postgres with a compose.yml in the project root is the cleanest setup. Add the Postgres connection string to .env.example.

How do I share the local MCP server with a teammate?

MCP servers run locally on each developer's machine — they are not network services in development. Share the git repo and .env.example. Teammates run npm install && cp .env.example .env, fill in their own API keys, and run npm run dev. If you need a shared staging environment (for integration testing or demo), deploy to a server and use the HTTP/SSE MCP transport instead of stdio.

My server logs mix with the MCP protocol output on stdout. How do I fix this?

The stdio MCP transport uses stdout for JSON-RPC messages. Any console.log() in your server code corrupts the protocol stream — the client receives partial JSON and fails to parse it. Use console.error() for all logging (stderr is not read by the transport), or install a logger that writes to a file: import { createWriteStream } from 'fs'; const log = createWriteStream('./data/dev.log', { flags: 'a' }); and write log.write(JSON.stringify(entry) + '\n'). The MCP Inspector shows stderr output in its Logs panel.

Can I run multiple MCP servers locally at the same time?

Yes. Each server is an independent process. Claude Desktop supports multiple servers in its config — add separate entries under mcpServers with different keys. Inspector supports connecting to one server at a time per browser tab; open multiple tabs for multiple servers. There is no port conflict because stdio transport does not use network ports.

Further reading