Building Custom MCP Servers for Claude Code: A Practical Guide
MCP (Model Context Protocol) servers extend Claude Code with custom tools, resources, and prompts. Instead of Claude only having access to your filesystem and terminal, you can give it access to databases, APIs, internal documentation, Slack, GitHub, Jira - anything you can write code to interact with.
I built my first MCP server last month to give Claude access to our internal knowledge base. Now Claude can search our documentation, find relevant code examples, and understand our architecture without me copying and pasting context into every conversation.
This guide walks through building MCP servers from scratch, with practical examples you can adapt for your own needs.
What MCP Servers Actually Are
An MCP server is a program that exposes capabilities to Claude through a standardized protocol. The server runs locally (or remotely) and communicates with Claude Code via JSON-RPC over stdio or HTTP.
How the architecture works:
| Component | Role | Communication |
|---|---|---|
| Claude Code | MCP Host - manages conversations and file access | Sends JSON-RPC requests |
| MCP Server | Your custom code - exposes tools and resources | Responds via stdio or HTTP |
| External Services | Databases, APIs, Slack, GitHub, etc. | Called by your MCP server |
MCP servers can expose three types of capabilities:
| Capability | Description | Example |
|---|---|---|
| Tools | Functions Claude can call | Search database, send Slack message |
| Resources | Data Claude can read | Documentation, config files |
| Prompts | Pre-defined prompt templates | Code review checklist |
Most servers focus on tools since they provide the most flexibility.
Project Setup
Create a new directory and initialize the project:
mkdir my-mcp-server
cd my-mcp-server
npm init -yInstall the MCP SDK and dependencies:
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/nodeCreate tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}Update package.json:
{
"name": "my-mcp-server",
"version": "1.0.0",
"type": "module",
"bin": {
"my-mcp-server": "./build/index.js"
},
"scripts": {
"build": "tsc && chmod 755 build/index.js",
"dev": "npx tsx src/index.ts"
},
"files": ["build"]
}Your First MCP Server
Create src/index.ts with a simple calculator tool:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server(
{
name: "my-mcp-server",
version: "1.0.0"
},
{
capabilities: {
tools: {}
}
}
);
server.setRequestHandler("tools/list", async () => {
return {
tools: [
{
name: "calculate",
description: "Perform basic arithmetic operations",
inputSchema: {
type: "object",
properties: {
operation: {
type: "string",
enum: ["add", "subtract", "multiply", "divide"],
description: "The operation to perform"
},
a: { type: "number", description: "First operand" },
b: { type: "number", description: "Second operand" }
},
required: ["operation", "a", "b"]
}
}
]
};
});
server.setRequestHandler("tools/call", async (request) => {
const { name, arguments: args } = request.params;
if (name === "calculate") {
const { operation, a, b } = args as {
operation: string;
a: number;
b: number;
};
let result: number;
switch (operation) {
case "add":
result = a + b;
break;
case "subtract":
result = a - b;
break;
case "multiply":
result = a * b;
break;
case "divide":
if (b === 0) {
return {
content: [{ type: "text", text: "Error: Division by zero" }],
isError: true
};
}
result = a / b;
break;
default:
return {
content: [{ type: "text", text: "Unknown operation" }],
isError: true
};
}
return {
content: [{ type: "text", text: `${a} ${operation} ${b} = ${result}` }]
};
}
return {
content: [{ type: "text", text: "Unknown tool" }],
isError: true
};
});
const transport = new StdioServerTransport();
await server.connect(transport);Build and test:
npm run buildRegistering With Claude Code
Add your server to Claude Code's configuration. Edit ~/.claude/settings.json or .claude/settings.json in your project:
{
"mcpServers": {
"my-mcp-server": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/build/index.js"]
}
}
}Restart Claude Code. You should see the MCP server icon indicating connection.
Real-World Use Case: Slack Notification Server
Here is a practical MCP server I actually use - it lets Claude send Slack messages to notify me about task completion, errors, or anything that needs my attention.
Why this is useful: When Claude is running long tasks in the background (tests, builds, deployments), I want to be notified when something needs my attention without watching the terminal.
Install dependencies:
npm install @slack/web-apiCreate src/index.ts:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { WebClient } from "@slack/web-api";
const SLACK_TOKEN = process.env.SLACK_BOT_TOKEN;
const DEFAULT_CHANNEL = process.env.SLACK_DEFAULT_CHANNEL || "#claude-alerts";
if (!SLACK_TOKEN) {
console.error("SLACK_BOT_TOKEN environment variable required");
process.exit(1);
}
const slack = new WebClient(SLACK_TOKEN);
const server = new Server(
{ name: "slack-notifier", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler("tools/list", async () => ({
tools: [
{
name: "send_slack_message",
description: "Send a message to a Slack channel",
inputSchema: {
type: "object",
properties: {
message: {
type: "string",
description: "The message to send"
},
channel: {
type: "string",
description: "Slack channel (defaults to #claude-alerts)"
},
urgency: {
type: "string",
enum: ["info", "warning", "error"],
description: "Message urgency level"
}
},
required: ["message"]
}
},
{
name: "send_slack_code_block",
description: "Send a code block to Slack with syntax highlighting",
inputSchema: {
type: "object",
properties: {
code: { type: "string", description: "The code to share" },
language: { type: "string", description: "Programming language" },
title: { type: "string", description: "Title for the code block" },
channel: { type: "string", description: "Slack channel" }
},
required: ["code"]
}
}
]
}));
server.setRequestHandler("tools/call", async (request) => {
const { name, arguments: args } = request.params;
if (name === "send_slack_message") {
const { message, channel, urgency } = args as {
message: string;
channel?: string;
urgency?: string;
};
const emoji = {
info: ":information_source:",
warning: ":warning:",
error: ":rotating_light:"
}[urgency || "info"];
try {
await slack.chat.postMessage({
channel: channel || DEFAULT_CHANNEL,
text: `${emoji} *Claude Code Alert*\n${message}`,
mrkdwn: true
});
return {
content: [{ type: "text", text: `Message sent to ${channel || DEFAULT_CHANNEL}` }]
};
} catch (error) {
return {
content: [{ type: "text", text: `Slack error: ${(error as Error).message}` }],
isError: true
};
}
}
if (name === "send_slack_code_block") {
const { code, language, title, channel } = args as {
code: string;
language?: string;
title?: string;
channel?: string;
};
try {
const header = title ? `*${title}*\n` : "";
const codeBlock = "```" + (language || "") + "\n" + code + "\n```";
await slack.chat.postMessage({
channel: channel || DEFAULT_CHANNEL,
text: `${header}${codeBlock}`,
mrkdwn: true
});
return {
content: [{ type: "text", text: "Code block sent to Slack" }]
};
} catch (error) {
return {
content: [{ type: "text", text: `Slack error: ${(error as Error).message}` }],
isError: true
};
}
}
return {
content: [{ type: "text", text: "Unknown tool" }],
isError: true
};
});
const transport = new StdioServerTransport();
await server.connect(transport);Configuration in Claude Code:
{
"mcpServers": {
"slack-notifier": {
"command": "node",
"args": ["/path/to/slack-notifier/build/index.js"],
"env": {
"SLACK_BOT_TOKEN": "xoxb-your-token-here",
"SLACK_DEFAULT_CHANNEL": "#dev-alerts"
}
}
}
}How I use it:
- "Run the full test suite and send me a Slack message when it's done"
- "If any tests fail, send the failure details to Slack with error urgency"
- "Send the updated API response format to #backend-team as a code block"
Real-World Use Case: GitHub Integration
Another server I use daily - it gives Claude access to GitHub issues, PRs, and repository information without me needing to copy-paste URLs.
Install dependencies:
npm install @octokit/restCreate src/index.ts:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Octokit } from "@octokit/rest";
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
if (!GITHUB_TOKEN) {
console.error("GITHUB_TOKEN environment variable required");
process.exit(1);
}
const octokit = new Octokit({ auth: GITHUB_TOKEN });
const server = new Server(
{ name: "github-tools", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler("tools/list", async () => ({
tools: [
{
name: "get_issue",
description: "Get details of a GitHub issue",
inputSchema: {
type: "object",
properties: {
owner: { type: "string", description: "Repository owner" },
repo: { type: "string", description: "Repository name" },
issue_number: { type: "number", description: "Issue number" }
},
required: ["owner", "repo", "issue_number"]
}
},
{
name: "list_open_issues",
description: "List open issues in a repository",
inputSchema: {
type: "object",
properties: {
owner: { type: "string", description: "Repository owner" },
repo: { type: "string", description: "Repository name" },
labels: { type: "string", description: "Filter by labels (comma-separated)" },
limit: { type: "number", description: "Max issues to return (default 10)" }
},
required: ["owner", "repo"]
}
},
{
name: "get_pr_diff",
description: "Get the diff of a pull request",
inputSchema: {
type: "object",
properties: {
owner: { type: "string", description: "Repository owner" },
repo: { type: "string", description: "Repository name" },
pull_number: { type: "number", description: "PR number" }
},
required: ["owner", "repo", "pull_number"]
}
},
{
name: "create_issue",
description: "Create a new GitHub issue",
inputSchema: {
type: "object",
properties: {
owner: { type: "string", description: "Repository owner" },
repo: { type: "string", description: "Repository name" },
title: { type: "string", description: "Issue title" },
body: { type: "string", description: "Issue body" },
labels: {
type: "array",
items: { type: "string" },
description: "Labels to add"
}
},
required: ["owner", "repo", "title"]
}
}
]
}));
server.setRequestHandler("tools/call", async (request) => {
const { name, arguments: args } = request.params;
try {
if (name === "get_issue") {
const { owner, repo, issue_number } = args as {
owner: string;
repo: string;
issue_number: number;
};
const { data } = await octokit.issues.get({
owner,
repo,
issue_number
});
return {
content: [{
type: "text",
text: `**#${data.number}: ${data.title}**\n\nState: ${data.state}\nAuthor: ${data.user?.login}\nLabels: ${data.labels.map((l: any) => l.name).join(", ") || "none"}\n\n${data.body || "No description"}`
}]
};
}
if (name === "list_open_issues") {
const { owner, repo, labels, limit } = args as {
owner: string;
repo: string;
labels?: string;
limit?: number;
};
const { data } = await octokit.issues.listForRepo({
owner,
repo,
state: "open",
labels,
per_page: limit || 10
});
const issueList = data
.map((i) => `- #${i.number}: ${i.title} (${i.labels.map((l: any) => l.name).join(", ")})`)
.join("\n");
return {
content: [{
type: "text",
text: `**Open Issues in ${owner}/${repo}:**\n\n${issueList || "No open issues"}`
}]
};
}
if (name === "get_pr_diff") {
const { owner, repo, pull_number } = args as {
owner: string;
repo: string;
pull_number: number;
};
const { data } = await octokit.pulls.get({
owner,
repo,
pull_number,
mediaType: { format: "diff" }
});
return {
content: [{ type: "text", text: data as unknown as string }]
};
}
if (name === "create_issue") {
const { owner, repo, title, body, labels } = args as {
owner: string;
repo: string;
title: string;
body?: string;
labels?: string[];
};
const { data } = await octokit.issues.create({
owner,
repo,
title,
body,
labels
});
return {
content: [{
type: "text",
text: `Created issue #${data.number}: ${data.html_url}`
}]
};
}
} catch (error) {
return {
content: [{ type: "text", text: `GitHub error: ${(error as Error).message}` }],
isError: true
};
}
return {
content: [{ type: "text", text: "Unknown tool" }],
isError: true
};
});
const transport = new StdioServerTransport();
await server.connect(transport);How I use it:
- "What are the open bugs in our main repo?"
- "Show me the diff for PR #142 and summarize the changes"
- "Create a bug report for the login timeout issue we just discussed"
Testing With the MCP Inspector
Before connecting to Claude Code, test your server with the official inspector:
npx @modelcontextprotocol/inspector node ./build/index.jsThis opens a web interface where you can list tools, call them with custom inputs, and see raw request/response payloads.
Dynamic Tools with list_changed
Claude Code 2.1 introduced dynamic tool updates. Your server can add or remove tools at runtime:
let availableTools = ["basic_tool"];
server.setRequestHandler("tools/list", async () => ({
tools: availableTools.map((name) => ({
name,
description: `Tool: ${name}`,
inputSchema: { type: "object", properties: {} }
}))
}));
async function addTool(name: string) {
availableTools.push(name);
await server.notification({
method: "notifications/tools/list_changed"
});
}When Claude Code receives the notification, it re-fetches the tool list without reconnection.
Error Handling
Return errors gracefully so Claude can recover:
server.setRequestHandler("tools/call", async (request) => {
try {
const result = await doSomethingRisky();
return {
content: [{ type: "text", text: result }]
};
} catch (error) {
return {
content: [{ type: "text", text: `Error: ${(error as Error).message}` }],
isError: true
};
}
});The isError: true flag tells Claude the tool failed, allowing it to try alternative approaches.
Security Considerations
Treat MCP servers like API keys. Anyone with access to your server can run any tool you expose.
Validate inputs. Use Zod or similar validation. Never trust that Claude sent valid data.
Limit capabilities. A database tool should only allow SELECT queries. A file tool should restrict paths.
Use environment variables for secrets. Never hardcode tokens or credentials.
const API_KEY = process.env.MY_API_KEY;
if (!API_KEY) {
console.error("MY_API_KEY environment variable required");
process.exit(1);
}Publishing to the MCP Registry
Once your server is stable, consider publishing to the MCP Registry (now containing nearly 2,000 servers):
- Create a GitHub repository for your server
- Add a
mcp.jsonmanifest with name, version, description, and tool list - Submit via the registry contribution process