Build a production MCP server in TypeScript with the v2 SDK and Claude Code 2.1 — full working code, stdio and HTTP transports, plus what breaks in production.
The Model Context Protocol (MCP) crossed 9,400 registered servers in early May 2026. Every one of those servers unlocks a new capability inside Claude Code — file access, database queries, API calls, custom tool chains. Building your own MCP server is the fastest way to make Claude Code understand your stack, your data, and your workflows. This guide walks you through a complete, working server in TypeScript from zero to a running integration.
MCP is a JSON-RPC 2.0-based protocol that lets Claude Code call external tools, read resources, and use pre-built prompt templates. The server you build runs as a local process. Claude Code spawns it via stdio or connects via HTTP+SSE. The protocol has three capability types: tools (functions Claude can call), resources (data Claude can read), and prompts (reusable templates). You will build a server that exposes all three.
What We Are Building
A database introspection server. Claude Code will be able to: list tables in a SQLite database (tool), read table schemas (resource), and use a pre-built prompt template for generating migration scripts. By the end you will have a fully functional MCP server you can extend for any data source.
Find ready-made developer toolkits at WOWHOW Browse and TypeScript starter kits at WOWHOW Tools.
Project Setup
mkdir mcp-db-server && cd mcp-db-server
npm init -y
npm install @modelcontextprotocol/sdk better-sqlite3 zod
npm install -D typescript @types/node @types/better-sqlite3 tsx
npx tsc --init --target ES2022 --module Node16 --moduleResolution Node16 --strict --outDir dist
The @modelcontextprotocol/sdk package handles all protocol framing, message routing, and capability negotiation. You write handlers; the SDK handles the wire protocol.
Server Entry Point
// src/index.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
GetPromptRequestSchema,
ListPromptsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import Database from 'better-sqlite3'
import { z } from 'zod'
import path from 'node:path'
// --- create the server instance
const server = new Server(
{ name: 'db-introspection-server', version: '1.0.0' },
{
capabilities: {
tools: {},
resources: {},
prompts: {},
},
}
)
// --- open database (path from env or default)
const DB_PATH = process.env.DB_PATH ?? path.join(process.cwd(), 'dev.sqlite')
const db = new Database(DB_PATH, { readonly: true })
export { server, db }
Defining Tools
Tools are functions Claude Code can invoke. Each tool has a name, description, and a JSON Schema for its input parameters. The SDK validates inputs against the schema before calling your handler.
// src/tools.ts
import { server, db } from './index.js'
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
// --- list available tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'list_tables',
description: 'List all tables in the SQLite database with row counts',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'query_table',
description: 'Run a SELECT query on a specific table (read-only)',
inputSchema: {
type: 'object',
properties: {
table: {
type: 'string',
description: 'Name of the table to query',
},
limit: {
type: 'number',
description: 'Maximum rows to return (default 10, max 100)',
default: 10,
},
where: {
type: 'string',
description: 'Optional WHERE clause (without the WHERE keyword)',
},
},
required: ['table'],
},
},
{
name: 'describe_table',
description: 'Get column definitions and indexes for a table',
inputSchema: {
type: 'object',
properties: {
table: { type: 'string', description: 'Table name' },
},
required: ['table'],
},
},
],
}))
// --- call tool handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
switch (name) {
case 'list_tables': {
const tables = db
.prepare(
`SELECT name, (SELECT COUNT(*) FROM main.\`\${name}\`) AS row_count
FROM sqlite_master WHERE type='table' ORDER BY name`
)
.all() as Array<{ name: string; row_count: number }>
return {
content: [
{
type: 'text',
text: tables
.map((t) => `\${t.name} (\${t.row_count} rows)`)
.join('\n'),
},
],
}
}
case 'query_table': {
const schema = z.object({
table: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/),
limit: z.number().int().min(1).max(100).default(10),
where: z.string().optional(),
})
const { table, limit, where } = schema.parse(args)
const sql = where
? `SELECT * FROM \`\${table}\` WHERE \${where} LIMIT \${limit}`
: `SELECT * FROM \`\${table}\` LIMIT \${limit}`
const rows = db.prepare(sql).all()
return {
content: [
{
type: 'text',
text: JSON.stringify(rows, null, 2),
},
],
}
}
case 'describe_table': {
const schema = z.object({
table: z.string().regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/),
})
const { table } = schema.parse(args)
const columns = db.prepare(`PRAGMA table_info(\`\${table}\`)`).all()
const indexes = db.prepare(`PRAGMA index_list(\`\${table}\`)`).all()
const foreignKeys = db.prepare(`PRAGMA foreign_key_list(\`\${table}\`)`).all()
return {
content: [
{
type: 'text',
text: JSON.stringify({ columns, indexes, foreignKeys }, null, 2),
},
],
}
}
default:
throw new Error(`Unknown tool: \${name}`)
}
})
Defining Resources
Resources are data Claude can read — think of them as files or URLs your server exposes. They are listed via resources/list and fetched via resources/read.
// src/resources.ts
import { server, db } from './index.js'
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const tables = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`)
.all() as Array<{ name: string }>
return {
resources: tables.map((t) => ({
uri: `db://schema/\${t.name}`,
name: `Schema: \${t.name}`,
description: `Full DDL and column info for table \${t.name}`,
mimeType: 'application/json',
})),
}
})
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params
const match = uri.match(/^db:\/\/schema\/([a-zA-Z_][a-zA-Z0-9_]*)$/)
if (!match) throw new Error(`Unsupported resource URI: \${uri}`)
const table = match[1]
const ddl = db
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`)
.get(table) as { sql: string } | undefined
if (!ddl) throw new Error(`Table not found: \${table}`)
const columns = db.prepare(`PRAGMA table_info(\`\${table}\`)`).all()
const indexes = db.prepare(`PRAGMA index_list(\`\${table}\`)`).all()
return {
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify({ ddl: ddl.sql, columns, indexes }, null, 2),
},
],
}
})
Defining Prompt Templates
Prompts are reusable templates that surface inside Claude Code’s slash-command picker. They can accept arguments and return pre-built message arrays.
// src/prompts.ts
import { server, db } from './index.js'
import {
ListPromptsRequestSchema,
GetPromptRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
prompts: [
{
name: 'generate_migration',
description: 'Generate a SQL migration script to add a column to a table',
arguments: [
{ name: 'table', description: 'Target table name', required: true },
{ name: 'column', description: 'New column definition (e.g. email TEXT NOT NULL DEFAULT \'\')', required: true },
],
},
],
}))
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: promptArgs } = request.params
if (name === 'generate_migration') {
const schema = z.object({
table: z.string(),
column: z.string(),
})
const { table, column } = schema.parse(promptArgs ?? {})
const existingSchema = db
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`)
.get(table) as { sql: string } | undefined
const context = existingSchema
? `Existing DDL:\n\${existingSchema.sql}`
: `Table '\${table}' not found. Generate a CREATE TABLE + ALTER TABLE migration.`
return {
messages: [
{
role: 'user' as const,
content: {
type: 'text' as const,
text: `Generate a safe, idempotent SQLite migration to add '\${column}' to '\${table}'.
\${context}
Requirements:
- Use ALTER TABLE ... ADD COLUMN
- Include a rollback section (DROP COLUMN if SQLite version supports it)
- Add a comment with the date: \${new Date().toISOString().split('T')[0]}
- Wrap in a transaction`,
},
},
],
}
}
throw new Error(`Unknown prompt: \${name}`)
})
Wiring Everything Together and Starting the Server
// src/main.ts
import './tools.js'
import './resources.js'
import './prompts.js'
import { server } from './index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
async function main() {
const transport = new StdioServerTransport()
await server.connect(transport)
// server is now listening on stdin/stdout
}
main().catch((err) => {
process.stderr.write(`Fatal: \${err.message}\n`)
process.exit(1)
})
Registering with Claude Code
Add the server to your ~/.claude.json (global) or project-level .claude.json:
{
"mcpServers": {
"db-introspection": {
"command": "node",
"args": ["dist/main.js"],
"env": {
"DB_PATH": "/path/to/your/dev.sqlite"
}
}
}
}
Then build and restart Claude Code:
npx tsc
# restart Claude Code — it spawns the server fresh each session
Claude Code will now show your tools in its tool picker and your tables as readable resources.
Testing Without Claude Code
Use the MCP inspector for quick iteration:
npx @modelcontextprotocol/inspector node dist/main.js
# Opens a browser UI at http://localhost:5173
# Call tools, read resources, test prompts interactively
You can also write unit tests directly against your handler functions since they are plain async functions — no need to spin up the full server for logic tests.
Error Handling and Production Hardening
// wrap every handler to catch unexpected errors
function safeHandler(
handler: (req: T) => Promise
): (req: T) => Promise {
return async (req) => {
try {
return await handler(req)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
// MCP errors surface as structured error responses
throw Object.assign(new Error(message), { code: -32000 })
}
}
}
People Also Ask
What is the difference between MCP tools and resources?
Tools are callable functions — Claude Code invokes them with arguments and gets a result back. Resources are read-only data endpoints — Claude reads them like files. Use tools for actions and computations; use resources for structured data that Claude should be able to browse or include as context.
Can I run an MCP server over HTTP instead of stdio?
Yes. Replace StdioServerTransport with SSEServerTransport from @modelcontextprotocol/sdk/server/sse.js. You set up an Express (or Hono) server, attach the transport to a route, and Claude Code connects via the server’s URL instead of spawning a subprocess. This is required for remote or multi-user server deployments.
How do I keep sensitive credentials out of .claude.json?
Use the env field in the server config to inject environment variables — pull values from your shell’s existing env rather than hardcoding them. For team setups, use a secrets manager and inject at process startup. Never commit API keys or database credentials inside .claude.json.
2026 Update: The v2 SDK and Claude Code 2.1
Two things changed since this guide first shipped, and both change the code you write. The MCP TypeScript SDK moved to a v2 API built around a high-level McpServer class with Standard-Schema (Zod) validation, and Claude Code 2.1 added Tool Search lazy-loading plus parallel subagents. If you built a server on the old low-level Server primitive, it still runs — but the v2 surface is shorter, type-safe end to end, and what every new server should target.
The v2 server: McpServer + registerTool
The v2 SDK collapses tool registration into a single typed call. You pass a name, a config object whose inputSchema is a Zod object, and a handler that receives already-validated arguments. No manual JSON-Schema, no hand-written request routing. Here is a complete stdio server that exposes one tool, verified against the v2 docs:
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
const server = new McpServer({ name: "wowhow-tools", version: "1.0.0" });
server.registerTool(
"lookup_order",
{
description: "Look up a customer order by its ID",
inputSchema: z.object({ orderId: z.string() }),
},
async ({ orderId }) => ({
content: [{ type: "text", text: "Order " + orderId + ": shipped" }],
}),
);
const transport = new StdioServerTransport();
await server.connect(transport);
Resources use the same shape via server.registerResource(name, uri, meta, handler) — return a contents array instead of content. The validated-args handler is the whole point: a malformed call never reaches your logic, because the Zod schema rejects it at the protocol boundary.
Two transports: stdio for local, Streamable HTTP for remote
A local server that Claude Code spawns as a subprocess uses StdioServerTransport (above). A server you host and share across machines uses StreamableHTTPServerTransport from @modelcontextprotocol/server/streamableHttp, mounted on a POST route (conventionally /mcp) behind your own auth. The server logic — the registerTool calls — is identical; only the transport you connect changes. Build the tools once, expose them either way.
Registering with Claude Code 2.1
Registration is a one-line CLI call, and the scope flag matters more than people expect. Local stdio:
# local stdio server (spawned by Claude Code)
claude mcp add wowhow-tools -- node dist/server.js
# remote server over Streamable HTTP
claude mcp add --transport http wowhow-tools https://api.example.com/mcp
# pick a scope: -s local (this project, default), -s user (all your projects),
# -s project (shared via .mcp.json, committed for your team)
claude mcp add wowhow-tools -s user -- node dist/server.js
The scope decides who gets the server. local is per-project and private; user follows you across every repo; project writes a checked-in .mcp.json so a teammate cloning the repo is prompted to approve the same server. Run claude mcp list to confirm it connects — a server that fails to start shows there before it ever wastes a turn.
Tool Search: why 50 tools no longer wreck your context
Through 2025 the hard ceiling on MCP servers was context: every tool definition loaded into the system prompt on every turn, so ten busy servers could burn tens of thousands of tokens before you typed anything. Claude Code 2.1 added Tool Search — tool definitions load lazily, on demand, cutting that overhead by up to 95%. The practical effect: you can keep the servers you actually use without paying a per-turn tax for the ones you do not call. The old advice to keep 3–6 servers was a context-budget workaround; with lazy loading the real limit is how many your workflow genuinely needs.
MCP tools inside parallel subagents and dynamic workflows
The other 2.1 shift is reach. Claude Code can now fan a task across many subagents in parallel through dynamic workflows, and every subagent can call your MCP server’s tools. A server that exposes lookup_order or query_db is no longer used by one conversation — it can be hit concurrently by a fleet of agents auditing, migrating, or analyzing in parallel. That makes two server properties non-negotiable that used to be optional: idempotency (the same tool call must be safe to run twice) and bounded concurrency (your database pool will see parallel calls, not serial ones).
What breaks in production
The demo works; production is where the sharp edges live. Unbounded responses are the first: a tool that returns an entire table will blow the context window and the model never recovers in that turn — paginate and cap every result server-side. Floating async work is the second: if your handler kicks off a fetch without awaiting it, the transport closes the response before the work finishes and the tool silently returns nothing. Schema drift is the third: change an inputSchema and old cached tool definitions on a client can mismatch until it reconnects — version your server and bump it on breaking changes. And secrets: inject them through the env field at process start, never inside the committed config. None of these show up in a five-minute test; all of them show up under a parallel-subagent load.
Build the server, register it with the right scope, and stress it the way 2.1 will actually use it — many calls at once, not one at a time. If you want a working harness to test against, the codebase graph visualizer and the rest of the free WOWHOW tools are built on the same patterns. For the wider 2026 context, see the AI code security crisis and 12 Hermes tool-gateway patterns for Claude Code MCP. Then run claude mcp list and tell us what your first server exposes.
Written by
WOWHOW
The WOWHOW team brings 14+ years of production engineering experience. Every tool and product in the catalog is personally built, tested, and curated.
Monday Memo · Free
One insight, every Monday. 7am IST. Zero fluff.
1 field report, 3 links, 1 tool we actually use. No fluff, no spam.
Need production-ready templates?
Free browser tools with no signup, plus 2,000+ premium dev templates and starter kits.
Comments · 0
Beta: comments are stored locally on your device and not visible to other readers.
No comments yet. Be the first to share your thoughts.