Build a production-ready MCP server in TypeScript that integrates with Claude Code. Full working code: tool definitions, resource handlers, prompts, and testing.
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.
Comments · 0
No comments yet. Be the first to share your thoughts.