Setting Up Your First MCP Server: Complete Tutorial
Learn to create a Model Context Protocol (MCP) server from scratch. Connect Claude Code to external tools, APIs, and services with this step-by-step guide.
Setting Up Your First MCP Server: Complete Tutorial
Skills and agents extend what Claude Code can do within your codebase. But what about connecting to the outside world—databases, APIs, external services?
That's where MCP (Model Context Protocol) comes in. MCP is the standard for connecting AI assistants to external tools and data sources. In this tutorial, you'll build an MCP server from scratch and connect it to Claude Code.
What is MCP?
Model Context Protocol is an open standard that defines how AI assistants communicate with external services. Think of it as USB for AI—a universal interface that any tool can implement.
MCP Architecture
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Claude Code │────▶│ MCP Server │────▶│ External │
│ (Client) │◀────│ (Bridge) │◀────│ Service │
└─────────────┘ └─────────────┘ └─────────────┘
Claude Code (Client): Sends requests, receives responses MCP Server (Bridge): Translates between Claude and services External Service: Database, API, tool, or any data source
Why MCP?
Without MCP:
- Every integration needs custom code
- No standardization across tools
- Security handled ad-hoc
With MCP:
- Standard protocol for all integrations
- Consistent security model
- Plug-and-play tool connections
Prerequisites
Before starting, ensure you have:
- Claude Code installed
- Node.js 18+ or Python 3.9+
- Basic understanding of REST APIs
- A project to integrate
We'll build examples in both TypeScript and Python. Choose whichever you're more comfortable with.
Understanding MCP Concepts
Resources
Resources are data sources your MCP server exposes:
resource://my-server/users/123
resource://my-server/documents/report.pdf
Resources are read-only data that Claude can access.
Tools
Tools are actions your MCP server can perform:
tool: create_user
tool: send_email
tool: query_database
Tools let Claude execute operations through your server.
Prompts
Prompts are templates for common interactions:
prompt: summarize_document
prompt: analyze_data
Prompts help Claude understand how to use your server effectively.
Building Your First MCP Server
Let's build a practical MCP server: a note-taking backend that Claude Code can use to store and retrieve notes.
Step 1: Set Up the Project
TypeScript:
mkdir mcp-notes-server
cd mcp-notes-server
npm init -y
npm install @modelcontextprotocol/sdk express
npm install -D typescript @types/node @types/express ts-node
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
Python:
mkdir mcp-notes-server
cd mcp-notes-server
python -m venv venv
source venv/bin/activate # or `venv\Scripts\activate` on Windows
pip install mcp fastapi uvicorn
Step 2: Create the Server Structure
TypeScript (src/index.ts):
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
// In-memory note storage
const notes: Map<string, { title: string; content: string; created: Date }> = new Map();
// Create the server
const server = new Server(
{
name: "notes-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
resources: {},
},
}
);
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Notes MCP Server running");
}
main().catch(console.error);
Python (server.py):
import asyncio
from datetime import datetime
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent, Resource
# In-memory note storage
notes: dict[str, dict] = {}
# Create the server
server = Server("notes-server")
async def main():
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream)
if __name__ == "__main__":
asyncio.run(main())
Step 3: Implement Tools
Tools let Claude perform actions. Let's add note management tools.
TypeScript:
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "create_note",
description: "Create a new note",
inputSchema: {
type: "object",
properties: {
title: {
type: "string",
description: "Note title",
},
content: {
type: "string",
description: "Note content",
},
},
required: ["title", "content"],
},
},
{
name: "list_notes",
description: "List all notes",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "delete_note",
description: "Delete a note by ID",
inputSchema: {
type: "object",
properties: {
id: {
type: "string",
description: "Note ID to delete",
},
},
required: ["id"],
},
},
],
};
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case "create_note": {
const id = Date.now().toString();
notes.set(id, {
title: args.title as string,
content: args.content as string,
created: new Date(),
});
return {
content: [
{
type: "text",
text: `Created note with ID: ${id}`,
},
],
};
}
case "list_notes": {
const noteList = Array.from(notes.entries()).map(([id, note]) => ({
id,
title: note.title,
created: note.created.toISOString(),
}));
return {
content: [
{
type: "text",
text: JSON.stringify(noteList, null, 2),
},
],
};
}
case "delete_note": {
const id = args.id as string;
if (notes.delete(id)) {
return {
content: [{ type: "text", text: `Deleted note ${id}` }],
};
}
return {
content: [{ type: "text", text: `Note ${id} not found` }],
isError: true,
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
});
Python:
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="create_note",
description="Create a new note",
inputSchema={
"type": "object",
"properties": {
"title": {"type": "string", "description": "Note title"},
"content": {"type": "string", "description": "Note content"},
},
"required": ["title", "content"],
},
),
Tool(
name="list_notes",
description="List all notes",
inputSchema={"type": "object", "properties": {}},
),
Tool(
name="delete_note",
description="Delete a note by ID",
inputSchema={
"type": "object",
"properties": {
"id": {"type": "string", "description": "Note ID"},
},
"required": ["id"],
},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name == "create_note":
note_id = str(datetime.now().timestamp())
notes[note_id] = {
"title": arguments["title"],
"content": arguments["content"],
"created": datetime.now().isoformat(),
}
return [TextContent(type="text", text=f"Created note: {note_id}")]
elif name == "list_notes":
note_list = [
{"id": k, "title": v["title"], "created": v["created"]}
for k, v in notes.items()
]
return [TextContent(type="text", text=str(note_list))]
elif name == "delete_note":
note_id = arguments["id"]
if note_id in notes:
del notes[note_id]
return [TextContent(type="text", text=f"Deleted: {note_id}")]
return [TextContent(type="text", text="Note not found")]
raise ValueError(f"Unknown tool: {name}")
Step 4: Implement Resources
Resources let Claude read data from your server.
TypeScript:
// List available resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const resources = Array.from(notes.entries()).map(([id, note]) => ({
uri: `notes:///${id}`,
name: note.title,
description: `Note created on ${note.created.toISOString()}`,
mimeType: "text/plain",
}));
return { resources };
});
// Read a specific resource
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const uri = request.params.uri;
const id = uri.replace("notes:///", "");
const note = notes.get(id);
if (!note) {
throw new Error(`Note not found: ${id}`);
}
return {
contents: [
{
uri,
mimeType: "text/plain",
text: `# ${note.title}\n\n${note.content}`,
},
],
};
});
Python:
@server.list_resources()
async def list_resources() -> list[Resource]:
return [
Resource(
uri=f"notes:///{note_id}",
name=note["title"],
description=f"Created: {note['created']}",
mimeType="text/plain",
)
for note_id, note in notes.items()
]
@server.read_resource()
async def read_resource(uri: str) -> str:
note_id = uri.replace("notes:///", "")
if note_id not in notes:
raise ValueError(f"Note not found: {note_id}")
note = notes[note_id]
return f"# {note['title']}\n\n{note['content']}"
Step 5: Connect to Claude Code
Now connect your MCP server to Claude Code.
Create .claude/mcp.json in your project:
{
"mcpServers": {
"notes": {
"command": "npx",
"args": ["ts-node", "/path/to/mcp-notes-server/src/index.ts"],
"env": {}
}
}
}
For Python:
{
"mcpServers": {
"notes": {
"command": "python",
"args": ["/path/to/mcp-notes-server/server.py"],
"env": {}
}
}
}
Step 6: Test the Integration
Restart Claude Code and test:
Can you create a note called "Meeting Notes" with content about our project discussion?
Claude should use the create_note tool from your MCP server.
What notes do I have?
Claude should use list_notes to retrieve your notes.
Adding Persistent Storage
Our example uses in-memory storage. Let's add file-based persistence.
TypeScript:
import fs from "fs/promises";
import path from "path";
const NOTES_FILE = path.join(__dirname, "../notes.json");
async function loadNotes(): Promise<void> {
try {
const data = await fs.readFile(NOTES_FILE, "utf-8");
const parsed = JSON.parse(data);
for (const [id, note] of Object.entries(parsed)) {
notes.set(id, {
...(note as any),
created: new Date((note as any).created),
});
}
} catch {
// File doesn't exist, start fresh
}
}
async function saveNotes(): Promise<void> {
const data: Record<string, any> = {};
for (const [id, note] of notes.entries()) {
data[id] = {
...note,
created: note.created.toISOString(),
};
}
await fs.writeFile(NOTES_FILE, JSON.stringify(data, null, 2));
}
// Call loadNotes at startup
loadNotes().then(() => main());
// Call saveNotes after modifications
// Add saveNotes() after create_note and delete_note operations
Error Handling
Robust MCP servers handle errors gracefully:
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
// ... tool handling logic
} catch (error) {
console.error(`Tool error: ${error}`);
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`,
},
],
isError: true,
};
}
});
Security Considerations
Input Validation
Always validate inputs:
case "create_note": {
const title = args.title as string;
const content = args.content as string;
if (!title || title.length > 200) {
throw new Error("Title must be 1-200 characters");
}
if (!content || content.length > 10000) {
throw new Error("Content must be 1-10000 characters");
}
// ... create note
}
Rate Limiting
Protect against abuse:
const requestCounts = new Map<string, number>();
const RATE_LIMIT = 100; // requests per minute
function checkRateLimit(clientId: string): void {
const count = requestCounts.get(clientId) || 0;
if (count >= RATE_LIMIT) {
throw new Error("Rate limit exceeded");
}
requestCounts.set(clientId, count + 1);
}
// Reset counts every minute
setInterval(() => requestCounts.clear(), 60000);
Authentication
For sensitive operations, add authentication:
const VALID_TOKENS = new Set([process.env.MCP_AUTH_TOKEN]);
function authenticate(token: string): void {
if (!VALID_TOKENS.has(token)) {
throw new Error("Invalid authentication token");
}
}
Advanced MCP Patterns
Pattern 1: Streaming Responses
For long-running operations:
case "search_notes": {
const query = args.query as string;
const results: string[] = [];
for (const [id, note] of notes.entries()) {
if (note.content.includes(query)) {
results.push(`${id}: ${note.title}`);
// Could emit progress here with streaming
}
}
return {
content: [{ type: "text", text: results.join("\n") }],
};
}
Pattern 2: External API Integration
Connect to external services:
case "sync_to_notion": {
const noteId = args.id as string;
const note = notes.get(noteId);
if (!note) {
throw new Error("Note not found");
}
const response = await fetch("https://api.notion.com/v1/pages", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.NOTION_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
parent: { database_id: process.env.NOTION_DATABASE_ID },
properties: {
title: { title: [{ text: { content: note.title } }] },
},
children: [
{
paragraph: {
rich_text: [{ text: { content: note.content } }],
},
},
],
}),
});
if (!response.ok) {
throw new Error(`Notion sync failed: ${response.statusText}`);
}
return {
content: [{ type: "text", text: "Synced to Notion" }],
};
}
Pattern 3: Database Integration
Connect to databases:
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
case "query_database": {
const sql = args.query as string;
// Safety: Only allow SELECT queries
if (!sql.trim().toLowerCase().startsWith("select")) {
throw new Error("Only SELECT queries allowed");
}
const result = await pool.query(sql);
return {
content: [{
type: "text",
text: JSON.stringify(result.rows, null, 2),
}],
};
}
Debugging MCP Servers
Enable Verbose Logging
console.error(`[${new Date().toISOString()}] Tool called: ${name}`);
console.error(`Arguments: ${JSON.stringify(args, null, 2)}`);
Test Independently
Test your server without Claude Code:
echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | npx ts-node src/index.ts
Use MCP Inspector
The MCP SDK includes debugging tools:
npx @modelcontextprotocol/inspector npx ts-node src/index.ts
Summary
You've built a complete MCP server that:
- Exposes tools for note management
- Provides resources for note access
- Handles errors gracefully
- Follows security best practices
MCP servers bridge Claude Code to the outside world. Any service with an API—databases, cloud services, internal tools—can be exposed through MCP.
Key Concepts
- Tools: Actions Claude can perform
- Resources: Data Claude can read
- Prompts: Templates for interactions
- Transport: Communication layer (stdio, HTTP)
Next Steps
- Add more tools to your server
- Connect to real databases
- Integrate with cloud APIs
- Build domain-specific servers
Want to see MCP in action? Check out GitHub Integration for a practical example of MCP connecting Claude Code to GitHub.