Claude Code is a remarkably capable coding agent, but it operates in a context vacuum. By the end of this article, you'll have a working custom MCP server that exposes project-specific tools, resources, and prompts to Claude Code.
How to Build an MCP Server for Claude Code
- Scaffold a Node.js/TypeScript project and install
@modelcontextprotocol/sdk,zod, andtsx. - Instantiate a
McpServerwith a name and version, then connect it to aStdioServerTransport. - Register tools using
server.tool()with descriptive names, Zod input schemas, and async handlers. - Expose resources using
server.resource()to surface static or live data like database schemas. - Define prompt templates using
server.prompt()to encode reusable team workflows. - Configure Claude Code by adding a
.mcp.jsonfile to your project root with the server command. - Verify the connection with the
/mcpslash command and test tool invocation in conversation.
Table of Contents
- What MCP Actually Is (and Why It Matters for Claude Code)
- Setting Up the Project
- Building the MCP Server: Core Implementation
- Registering and Testing with Claude Code
- Real-World Patterns and Advanced Techniques
- Debugging and Common Pitfalls
- Context Is the Multiplier
Claude Code is a remarkably capable coding agent, but it operates in a context vacuum. It doesn't know your database schema, your team's naming conventions, your API contracts, or the dozen architectural decisions your team made last quarter. Every time you start a session, you're back to square one, copying and pasting project details into prompts and hoping you didn't forget something critical. If you've spent any time with an Anthropic MCP server tutorial, you know there has to be a better way. The Model Context Protocol is that better way: an open standard from Anthropic that gives you a structured mechanism to feed external context directly to AI models.
By the end of this article, you'll have a working custom MCP server that exposes project-specific tools, resources, and prompts to Claude Code. In MCP terms, "context" isn't magic memory. It's concrete: tools the model can call, resources it can read, and prompt templates it can invoke. You'll build all three.
Prerequisites: working knowledge of TypeScript and Node.js, Claude Code installed, and comfort with terminal workflows. Let's get to it.
What MCP Actually Is (and Why It Matters for Claude Code)
The Architecture in 30 Seconds
MCP follows a client-host-server model. Claude Code acts as the MCP host (and its internal connector serves as the MCP client). Your custom server exposes capabilities. The protocol defines three core primitives:
- Tools: Functions Claude can call to perform actions or retrieve computed results. Think of these as API endpoints the model can invoke.
- Resources: Data Claude can read. These are static or dynamic content sources, like documentation files or database schemas, that the model pulls in when it needs reference material.
- Prompts: Reusable prompt templates the model can use as structured starting points for specific tasks.
For transport, MCP supports stdio (local subprocess communication) and Streamable HTTP (for remote servers; the older SSE-based transport is deprecated but still seen in the wild). When working with Claude Code locally, stdio is the default and simplest path. One critical detail: under stdio transport, stdout is reserved exclusively for JSON-RPC protocol messages. Any console.log call in your server code will corrupt the communication stream. We'll revisit this in the debugging section, but keep it in mind from the start.
How Claude Code Discovers and Uses MCP Servers
Claude Code discovers MCP servers through configuration files. You register servers in a .mcp.json file in your project root (for project-scoped servers) or in your global Claude configuration. When Claude Code launches, it starts each registered server as a subprocess, performs a capability negotiation handshake, and catalogs the available tools, resources, and prompts.
Here's the key insight: Claude Code decides when to call your tools based on conversation context. You expose capabilities and write clear descriptions. Claude reasons about when to use them. A minimal server registration looks like this:
{
"mcpServers": {
"my-project-context": {
"command": "npx",
"args": ["tsx", "src/index.ts"]
}
}
}
That's enough for Claude Code to launch your server and discover everything it offers.
Setting Up the Project
Prerequisites and Tooling
You'll need Node.js 18 or later, TypeScript, and a package manager (npm or pnpm both work). The core dependency is @modelcontextprotocol/sdk, the official TypeScript SDK maintained under the modelcontextprotocol GitHub organization. You'll also want tsx for running TypeScript directly during development and zod for schema validation.
Scaffolding the Server
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
mkdir src
touch src/index.ts
Update your package.json to use ES modules and add a build script:
{
"name": "my-mcp-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"zod": "^3.24.4"
},
"devDependencies": {
"typescript": "^5.7.0",
"tsx": "^4.19.0",
"@types/node": "^22.0.0"
}
}
And a minimal tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"]
}
Building the MCP Server: Core Implementation
Creating the Server Instance
The SDK exports a McpServer class (a high-level wrapper) that handles protocol negotiation, capability advertisement, and message routing. You instantiate it with a name and version, then connect it to a StdioServerTransport for local Claude Code usage. Stdio works well here because Claude Code launches your server as a child process and communicates over stdin/stdout, so there's zero network configuration involved.
Here's the base server skeleton in src/index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new McpServer({
name: "my-project-context",
version: "1.0.0",
});
// Tools, resources, and prompts will be registered here
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP server running on stdio");
}
main().catch((error) => {
console.error("Fatal error starting server:", error);
process.exit(1);
});
Notice the console.error instead of console.log. Under stdio transport, stdout belongs to the protocol. All debug output must go to stderr.
This server starts, connects, and advertises zero capabilities. Let's fix that.
Implementing a Custom Tool: get-project-docs
Tools are the most powerful MCP primitive for custom AI context integration. They let Claude Code call functions you define, with typed inputs and structured outputs. I've found that the single highest-impact first tool for most teams is a documentation lookup. When I built an MCP server for an internal platform project, exposing a get-project-docs tool cut the "let me explain our auth flow again" back-and-forth by roughly 80% over a two-week sprint. Claude just looked it up.
When I built an MCP server for an internal platform project, exposing a
get-project-docstool cut the "let me explain our auth flow again" back-and-forth by roughly 80% over a two-week sprint. Claude just looked it up.
The SDK's server.tool() method takes a name, description, a Zod schema for input validation, and an async handler:
import { z } from "zod";
import { readFileSync, existsSync, realpathSync } from "fs";
import { join, resolve } from "path";
const DOCS_DIR = resolve("./docs");
server.tool(
"get-project-docs",
"Retrieve project documentation by topic. Available topics include: architecture, api-contracts, auth-flow, deployment, coding-standards.",
{
topic: z.string().describe(
"The documentation topic to retrieve (e.g., 'architecture', 'api-contracts', 'auth-flow')"
),
},
async ({ topic }) => {
// Sanitize topic to prevent path traversal
const safeTopic = topic.replace(/[^a-zA-Z0-9_-]/g, "");
const filePath = join(DOCS_DIR, `${safeTopic}.md`);
const realPath = existsSync(filePath) ? realpathSync(filePath) : null;
if (!realPath || !realPath.startsWith(DOCS_DIR)) {
return {
content: [
{
type: "text" as const,
text: `No documentation found for topic: "${topic}". Available topics: architecture, api-contracts, auth-flow, deployment, coding-standards.`,
},
],
};
}
const content = readFileSync(realPath, "utf-8");
return {
content: [
{
type: "text" as const,
text: content,
},
],
};
}
);
The description matters a lot. Claude Code uses it to decide when to invoke the tool. Vague descriptions like "gets docs" mean the tool gets ignored. Specific descriptions that list available topics give Claude the information it needs to match user questions to tool calls.
A caveat: the file-path joining here includes sanitization to prevent path traversal (e.g., a topic of ../../etc/passwd). The topic input gets stripped of any characters outside alphanumerics, hyphens, and underscores, and the resolved path is verified to stay within the docs directory.
This approach also has limits when your documentation is scattered across multiple formats or lives in a CMS. In that case, swap out the file-reading logic for an API call to your documentation system and return the response as text content.
Exposing a Resource: database-schema
Resources differ from tools in an important way. Tools are model-controlled actions that Claude decides to execute. Resources are application-controlled data sources that the client can read and surface to the model. A database schema resource fits perfectly here: Claude doesn't need to do anything, it just needs to see the schema when reasoning about data-related code.
The server.resource() method takes a name, a URI, and a handler that returns the content:
server.resource(
"database-schema",
"schema://main",
async (uri) => {
const schemaPath = "./prisma/schema.prisma";
if (!existsSync(schemaPath)) {
return {
contents: [
{
uri: uri.href,
mimeType: "text/plain",
text: "Schema file not found at ./prisma/schema.prisma",
},
],
};
}
const schema = readFileSync(schemaPath, "utf-8");
return {
contents: [
{
uri: uri.href,
mimeType: "text/plain",
text: schema,
},
],
};
}
);
The URI (schema://main) is an identifier Claude Code uses to reference this resource. The mimeType field tells the client how to interpret the content. For Prisma schemas, SQL files, or other structured text, text/plain works fine. How and when resources actually get surfaced to the model depends on the client implementation; Claude Code may attach resource content to the conversation when it judges it's relevant, or the user may need to reference it explicitly.
Adding a Prompt Template: code-review
Prompt templates encode reusable workflows. For MCP server development, a code review template that bakes in your team's coding standards pays off immediately. Instead of restating your review criteria every session, you define it once and Claude Code can invoke it by name.
server.prompt(
"code-review",
"Perform a code review using the team's coding standards and review criteria",
{
filePath: z.string().describe("Path to the file to review"),
focusArea: z
.enum(["security", "performance", "readability", "all"])
.optional()
.describe("Specific area to focus the review on"),
},
async ({ filePath, focusArea }) => {
const focus = focusArea ?? "all";
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Review the file at ${filePath} with focus on: ${focus}.
Apply our team's coding standards:
1. All functions must have explicit return types (TypeScript)
2. Error handling must use custom error classes, not generic Error
3. Database queries must use parameterized statements, never string interpolation
4. API endpoints must validate input with Zod schemas before processing
5. No console.log in production code; use the structured logger
6. All async functions must have proper error boundaries
Provide specific line-level feedback with severity (critical/warning/suggestion).`,
},
},
],
};
}
);
The prompt template returns a structured message array that Claude Code uses as a conversation starter. The arguments (filePath, focusArea) let users customize the review without rewriting the template. Worth noting: prompts in MCP are user-controlled. They're invoked explicitly by the user (e.g., via slash commands), not automatically selected by the model the way tools are.
Registering and Testing with Claude Code
Configuring Claude Code to Use the Server
Create a .mcp.json file in your project root. This scopes the server to the current project, so it only activates when you open Claude Code in that directory:
{
"mcpServers": {
"my-project-context": {
"command": "npx",
"args": ["tsx", "src/index.ts"],
"cwd": "/absolute/path/to/my-mcp-server"
}
}
}
For a server you want available across all projects, place the configuration in ~/.claude.json or the appropriate global Claude Code config location. The command and args fields tell Claude Code how to launch the server process.
Verifying the Connection
Launch Claude Code in your project directory and use the /mcp slash command to check server status. You should see your server listed with its tools, resources, and prompts:
$ claude
> /mcp
MCP Servers:
my-project-context (connected)
Tools: get-project-docs
Resources: database-schema (schema://main)
Prompts: code-review
> What's our authentication flow?
I'll look up the authentication documentation for you.
[Calling tool: get-project-docs with topic="auth-flow"]
Based on your project documentation, your auth flow uses...
The key validation: Claude Code should automatically invoke get-project-docs when you ask about documented topics. If it doesn't, check your tool description. Claude uses that description to decide relevance. In my experience, descriptions under 20 words rarely give Claude enough signal to match queries reliably.
In my experience, descriptions under 20 words rarely give Claude enough signal to match queries reliably.
Real-World Patterns and Advanced Techniques
Dynamic Context from Live Systems
Static file reading gets you started, but the real power of Claude Code model context protocol integration comes from connecting to live systems. Instead of reading a Prisma schema file that might be stale, query the actual database:
// npm install pg @types/pg
import pg from "pg";
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
});
server.resource(
"live-database-schema",
"schema://live",
async (uri) => {
const result = await pool.query(`
SELECT table_name, column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
ORDER BY table_name, ordinal_position
`);
const schemaText = result.rows
.map(
(row: any) =>
`${row.table_name}.${row.column_name}: ${row.data_type} (nullable: ${row.is_nullable})`
)
.join("
");
return {
contents: [
{
uri: uri.href,
mimeType: "text/plain",
text: schemaText,
},
],
};
}
);
This pattern extends to any live system. Query your API gateway for current endpoint definitions. Pull from Jira or Linear for current sprint context. Connect to your monitoring stack for recent error patterns. Each connection point eliminates another category of context you'd otherwise manually paste.
Watch out for latency though. MCP resource and tool handlers that take several seconds to return will slow down Claude Code's responses noticeably. Consider caching results with a short TTL for expensive queries.
Error Handling and Resilience
MCP servers that crash or hang degrade Claude Code's ability to use that context source. If the client has reconnect logic, you might recover, but defensive coding is cheaper than hoping. Wrap every handler:
function safeHandler<T>(
fn: (args: T) => Promise<any>
): (args: T) => Promise<any> {
return async (args: T) => {
try {
return await fn(args);
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error occurred";
console.error(`Handler error: ${message}`);
return {
content: [
{
type: "text" as const,
text: `Error: ${message}. The server encountered an issue processing this request.`,
},
],
};
}
};
}
Use this wrapper on any handler that touches the filesystem, network, or database. Return meaningful error messages in the MCP response format so Claude can tell the user what went wrong instead of silently failing. The MCP spec also defines a structured isError flag you can set on tool results to explicitly signal failure to the client.
Composing Multiple Servers
You're not limited to one MCP server. Register multiple servers for different concerns: one for documentation, one for database context, one for deployment status, one for monitoring data. Claude Code aggregates capabilities from all registered servers and reasons across them.
When should you split servers? Split when the concerns have different lifecycles (your docs server changes weekly, your database schema server changes daily), different dependency trees (one needs pg, another needs @aws-sdk), or different security profiles (read-only docs vs. read-write database access). Combine when the capabilities are tightly coupled and share configuration or connections.
Security Considerations
MCP servers run with your local user permissions. A tool that executes shell commands or writes to a database has real, irreversible power. Follow the principle of least privilege: give tools read-only access unless write access is explicitly required and well-understood. Never expose MCP servers over the network without authentication and TLS. Store database credentials and API keys in environment variables, never in the .mcp.json configuration file. Add audit logging to tool handlers so you have a record of what Claude invoked and when.
A tool that executes shell commands or writes to a database has real, irreversible power. Follow the principle of least privilege: give tools read-only access unless write access is explicitly required and well-understood.
One more thing: Claude Code prompts the user for approval before invoking tools by default in its standard permission model. That's a good safety layer, but don't lean on it as your only guardrail. Design your tools to be safe even if invoked unexpectedly.
Debugging and Common Pitfalls
Server won't start. Verify that tsx is installed (check with npx tsx --version), that your tsconfig.json targets ES modules correctly, and that the entry point path in .mcp.json is correct. Run npx tsx src/index.ts manually to see if it produces errors on stderr.
Claude Code doesn't see the server. Confirm .mcp.json is in the project root (the directory where you launch Claude Code). Restart Claude Code after any configuration changes. The /mcp command should list your server; if it's absent, the config isn't being read.
Tools aren't being called. Claude decides when to invoke tools based on their descriptions. If your tool description says "gets stuff," Claude has no way to match it to user queries. Be explicit about what the tool returns and when it's useful. List available options in the description.
stdout corruption. This is the single most common MCP debugging headache. Any console.log in your server code writes to stdout, which is the protocol channel. Use console.error for all debug output:
// WRONG - breaks protocol
console.log("Processing request...");
// CORRECT - goes to stderr
console.error("[DEBUG] Processing request...");
Schema validation failures. If Claude sends arguments that don't match your Zod schema, the SDK rejects the call. Test your schemas with edge cases: empty strings, missing optional fields, unexpected enum values. The tighter your schema, the better Claude can reason about valid inputs.
Debugging with the MCP Inspector. The modelcontextprotocol organization provides an MCP Inspector tool that gives you a visual interface for testing your server's tools, resources, and prompts interactively, without needing Claude Code in the loop. This is invaluable for isolating issues during development. You can also pipe JSON-RPC messages directly to your server's stdin for lower-level debugging.
Context Is the Multiplier
MCP servers transform Claude Code from a generic coding assistant into a context-aware collaborator that knows your project's documentation, schema, conventions, and live system state. The three primitives map cleanly to the three things developers constantly re-explain to AI: how to look things up (tools), what data exists (resources), and how to approach tasks (prompts).
MCP servers transform Claude Code from a generic coding assistant into a context-aware collaborator that knows your project's documentation, schema, conventions, and live system state.
Start with one tool that addresses your biggest context gap. If you spend the first five minutes of every Claude Code session explaining your database schema, build the schema resource. If you keep re-stating your team's error handling patterns, encode them in a prompt template. Iterate from there.
The MCP ecosystem is growing fast. Community servers for databases, APIs, and developer tools keep appearing in the modelcontextprotocol GitHub organization alongside the official SDKs and specification. But your custom servers, the ones tailored to your specific codebase and workflows, are where the highest leverage sits. Nobody else can build those. Now you know how.


