If you've been using Claude Code for a while, you've probably hit its built-in limits: it can read your files, run bash commands, and call a handful of built-in tools β but it can't query your internal database, hit your private API, or pull structured data from the systems you actually work with every day.
Model Context Protocol (MCP) fixes this. It's an open protocol from Anthropic that lets you build a local server exposing custom tools that Claude Code β and Claude Desktop β can call exactly like any built-in tool. Once wired up, Claude can SELECT from your Postgres database, fetch open tickets from your internal tracker, or pull metrics from Prometheus β all inside the same agentic loop, without leaving the terminal.
This tutorial walks through building a real MCP server from scratch in TypeScript, connecting it to Claude Code, and extending it with a live SQLite query tool. No filler β just the setup, the code, and the gotchas that burn people on first contact.
What MCP Servers Actually Do
An MCP server is a local process β or remote HTTPS endpoint β that speaks the Model Context Protocol over stdio or HTTP/SSE. It declares a list of tools, each with a name, a natural-language description, and a JSON Schema for its inputs. Claude reads those descriptions at startup, decides when a tool is relevant, sends structured inputs, and reasons about the response β the same way it uses built-in tools like Read or Bash.
From Claude Code's perspective, your custom tool is indistinguishable from a built-in one. You write the handler; Claude decides when and how to call it. The protocol supports three primitives:
- Tools β callable functions with structured input/output (the focus of this tutorial)
- Resources β read-only data sources Claude can browse, like a directory listing or API reference
- Prompts β pre-built prompt templates the user can invoke by name
Prerequisites
You'll need Node.js 20+, npm, and Claude Code installed. If you haven't installed Claude Code yet:
npm install -g @anthropic-ai/claude-code
Basic TypeScript familiarity helps, but the SDK is small enough that you can follow along even if you primarily write JavaScript. You can run the MCP server on your local dev box, or on a dedicated always-on machine. A Beelink EQ12 Mini PC (~$189) is a solid choice for a shared team MCP host β it draws under 10W at idle and handles multiple Node.js server processes without complaint.
Scaffolding the Project
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk
npm install -D typescript @types/node tsx
npx tsc --init --target ES2022 --module Node16 --moduleResolution Node16 --outDir dist
Add a build script to package.json:
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
}
Building the Server
Create src/index.ts. This minimal server registers one tool β get_project_status β and handles the call:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const server = new Server(
{ name: "my-mcp-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "get_project_status",
description:
"Returns current status of a project by ID from our internal tracker.",
inputSchema: {
type: "object",
properties: {
project_id: {
type: "string",
description: "Project identifier, e.g. PROJ-123",
},
},
required: ["project_id"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "get_project_status") {
const { project_id } = request.params.arguments as { project_id: string };
// Replace with your real data source
const data: Record = {
"PROJ-123": { status: "in-progress", owner: "alice", due: "2026-09-01" },
"PROJ-456": { status: "blocked", owner: "bob", blocker: "PROJ-789" },
};
const result = data[project_id] ?? { error: "Project not found" };
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP server running on stdio");
}
main();
Connecting It to Claude Code
Build the project, then tell Claude Code where to find the server. Edit ~/.claude/claude_desktop_config.json (user-global) or create .claude/mcp_servers.json at your project root for project-scoped access:
{
"mcpServers": {
"my-mcp-server": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/dist/index.js"]
}
}
}
For development, skip the build step and use tsx directly:
{
"mcpServers": {
"my-mcp-server": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/my-mcp-server/src/index.ts"]
}
}
}
Restart Claude Code and verify the server loaded by running /mcp or asking: "What MCP tools do you have available?" You should see get_project_status in the list. If it's missing, check the Claude Code logs β the server process stderr is captured there and will show any import or startup errors.
Real-World Extension: Querying a SQLite Database
The mock data example proves the wiring. Now connect it to a real local database. Install the driver:
npm install better-sqlite3
npm install -D @types/better-sqlite3
Add a query_db tool. The key detail here is the safety guard: the tool only allows SELECT statements, so Claude can't accidentally mutate your data even if it generates a destructive query.
import Database from "better-sqlite3";
const db = new Database("/path/to/your/data.db", { readonly: true });
// Add to ListToolsRequestSchema handler:
{
name: "query_db",
description:
"Run a read-only SQL SELECT against the local project database. Returns results as JSON rows.",
inputSchema: {
type: "object",
properties: {
sql: {
type: "string",
description: "A SELECT query. Mutations are rejected.",
},
},
required: ["sql"],
},
}
// Add to CallToolRequestSchema handler:
if (request.params.name === "query_db") {
const { sql } = request.params.arguments as { sql: string };
if (!sql.trim().toUpperCase().startsWith("SELECT")) {
return {
content: [{ type: "text", text: "Error: only SELECT queries are allowed." }],
isError: true,
};
}
try {
const rows = db.prepare(sql).all();
return {
content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
};
} catch (err) {
return {
content: [
{ type: "text", text: `SQL error: ${(err as Error).message}` },
],
isError: true,
};
}
}
Once registered, you can prompt Claude Code: "Query the database for all tasks assigned to me that are past their due date and summarize them." Claude writes the SQL, calls your tool, reads the rows, and gives you a summary β no browser switching, no copy-pasting results.
If your MCP server loads large indexes or caches query result sets on disk, storage throughput starts to matter. A WD Black 2TB NVMe SSD (~$129) keeps read latency low and handles the write throughput from index updates without bottlenecking the rest of your dev loop.
Passing Secrets with Environment Variables
Real servers need credentials β API keys, connection strings, OAuth tokens. Pass them through the MCP config rather than hardcoding:
{
"mcpServers": {
"my-mcp-server": {
"command": "node",
"args": ["/path/to/dist/index.js"],
"env": {
"DATABASE_URL": "postgres://user:pass@localhost:5432/mydb",
"INTERNAL_API_KEY": "sk-..."
}
}
}
}
Read them in your server with process.env as usual. The config file lives in your home directory, outside any git repo, so secrets stay off version control. If you're running the server as a systemd service on a shared host, use a .env file with EnvironmentFile= in the unit config instead.
Handling Errors and Partial Failures
Return structured error responses rather than letting uncaught exceptions crash the process. Claude gets better signal from an isError: true response than from a silent stdio disconnect:
try {
const result = await callExternalApi(params);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (err) {
return {
content: [
{
type: "text",
text: `Tool failed: ${(err as Error).message}. The upstream service may be down.`,
},
],
isError: true,
};
}
With isError: true set, Claude knows the call failed and can decide whether to retry, ask you for clarification, or try an alternative approach. Without it, Claude may treat an error message as valid data and reason incorrectly about it.
Project-Scoped Servers for Team Sharing
User-level config makes the server available everywhere. For a server that's specific to one project β say, a tool that knows about your monorepo's service registry β use project-level config instead:
mkdir -p .claude
cat > .claude/mcp_servers.json << 'EOF'
{
"mcpServers": {
"monorepo-tools": {
"command": "node",
"args": ["./mcp/dist/index.js"]
}
}
}
EOF
Claude Code detects and loads this when it finds the project root. Every developer who clones the repo gets the same MCP tooling automatically β no manual configuration step in the onboarding docs.
What to Build Next
Once the pattern is in muscle memory, the useful servers are easy to spot. A few that have high ROI for most developer teams:
- Internal docs search β embed your Confluence or Notion pages and expose a
search_docstool so Claude can retrieve relevant architecture docs before writing code that touches those systems - GitHub PR context β fetch PR descriptions, review comments, and CI status by PR number so Claude can reason about feedback without you switching to the browser
- Deployment and health status β wrap your Kubernetes API or deployment pipeline so Claude can verify a service is healthy before suggesting you restart it
- Metric lookups β a thin wrapper around your Prometheus or Datadog API gives Claude the last 24 hours of error rates when you're debugging an incident at 2am
The pattern is always the same: identify the context Claude is missing, wrap the data source in a tool with a clear description, wire it up in 50β100 lines of TypeScript. The description is where most of the work actually lives β Claude uses it to decide when to call the tool, so a vague description produces vague behavior.
If you plan to run several MCP servers concurrently, RAM starts to matter. A Crucial 32GB DDR4 upgrade (~$59) keeps your Node processes from swapping when you have three or four servers active alongside a local Ollama instance. Combined with a low-power always-on host, you end up with a dedicated MCP infrastructure layer that any machine on your LAN can use.
Wrapping Up
MCP servers are the cleanest way to extend Claude Code beyond its default toolset. The SDK is minimal, the stdio transport is easy to debug with basic logging, and the integration with Claude Code's agentic loop is seamless once the server is registered. Start with one small tool β a lookup you perform five times a day that requires switching context β get it working end to end, then expand from there. The first one is the hardest; after that, adding a new tool is a 20-minute job.
The source code for this tutorial is straightforward enough to adapt directly. Clone the structure, swap the handlers for your real data sources, drop the config file in your project root, and you're live.