Skip to content

How to Build Custom MCP Servers for Claude Code: A Practical Guide

Purpose

This post demonstrates how to build custom MCP (Model Context Protocol) servers for Claude Code to connect AI assistants directly to your internal tools, APIs, and workflows.

Environment

  • Python 3.8+
  • MCP Python SDK (FastMCP)
  • Claude Code CLI
  • JIRA API access (for examples)

The Problem with Default Tools

I’ve been using Claude Code daily for months, and I realized something important: the biggest productivity gain didn’t come from switching models or tweaking configurations—it came from building custom MCP servers.

Before MCP servers, I was constantly:

  • Copy-pasting context from 5+ browser tabs
  • Manually explaining project details repeatedly
  • Switching between tools to gather information
  • Wasting time on repetitive context transfer

The result? A fragmented workflow that broke my flow and wasted 1-2 hours every day.

What is MCP?

MCP (Model Context Protocol) is a standardized protocol that enables AI assistants like Claude Code to interact with external tools, APIs, and services directly. Instead of manually providing context, you build servers that Claude can query automatically.

Key capabilities:

  • tools: Functions Claude can execute (like API calls)
  • resources: Data Claude can read (like documentation)
  • prompts: Pre-defined prompts for specific tasks

Building Your First MCP Server

Step 1: Install the MCP Python SDK

Terminal window
pip install fastmcp

Step 2: Create a Simple Server

Let’s start with a basic example—a weather MCP server:

weather_server.py
#!/usr/bin/env python3
import asyncio
from fastmcp import FastMCP
from fastmcp.transports.stdio import serve_stdio
# Create a FastMCP server
mcp = FastMCP(
name="Weather MCP Server",
version="1.0.0"
)
@mcp.tool()
def get_weather(location: str) -> dict:
"""Gets current weather for a location."""
return {
"temperature": 72.5,
"conditions": "Sunny",
"location": location
}
# Start the server
if __name__ == "__main__":
asyncio.run(serve_stdio(mcp))

This creates a single tool called get_weather that Claude can call.

Step 3: Configure Claude Code

Create a .mcp.json file in your project root:

.mcp.json
{
"mcpServers": {
"weather": {
"command": "python",
"args": ["${PROJECT_ROOT}/weather_server.py"],
"env": {}
}
}
}

Now when you use Claude Code, it can call get_weather automatically when needed.

Real-World Example: JIRA Integration

The simplest MCP server I built wraps the JIRA API to give Claude direct access to project tickets:

jira_server.py
from mcp.server.fastmcp import FastMCP
import os
from jira import JIRA
# Create an MCP server instance
mcp = FastMCP("JiraTools")
# Initialize JIRA client
jira_client = JIRA(
server=os.environ.get("JIRA_BASE_URL"),
basic_auth=(
os.environ.get("JIRA_EMAIL"),
os.environ.get("JIRA_API_TOKEN")
)
)
# Define a tool for reading JIRA tickets
@mcp.tool()
def get_jira_ticket(ticket_id: str) -> dict:
"""Get JIRA ticket details and context"""
issue = jira_client.issue(ticket_id)
return {
"id": ticket_id,
"title": issue.fields.summary,
"description": issue.fields.description or "No description",
"status": issue.fields.status.name,
"assignee": issue.fields.assignee.displayName if issue.fields.assignee else "Unassigned",
"priority": issue.fields.priority.name,
"labels": issue.fields.labels,
"created": issue.fields.created,
"updated": issue.fields.updated
}
# Define a resource for project context
@mcp.resource("jira://project/{project_key}")
def get_project_context(project_key: str) -> str:
"""Get project context and conventions"""
project = jira_client.project(project_key)
return f"""
Project: {project.name}
Key: {project.key}
Description: {project.description or 'No description'}
Team conventions:
- Use descriptive ticket titles
- Include acceptance criteria
- Tag with appropriate labels
"""
if __name__ == "__main__":
mcp.run() # Runs with stdio transport

What this does:

  • get_jira_ticket: Lets Claude read any ticket by ID
  • get_project_context: Provides project-level context and conventions

The Productivity Impact

With this MCP server, instead of:

  1. Opening JIRA in browser
  2. Finding the ticket
  3. Copying ticket details
  4. Pasting into Claude Code chat
  5. Explaining project context

I just say: “Help me work on ticket PROJ-123” and Claude automatically:

  • Reads the ticket details
  • Understands project context
  • Has all the information needed to help

Time saved: 5-10 minutes per task.

Multi-Server Setup

As I added more servers, I created a comprehensive .mcp.json:

.mcp.json
{
"mcpServers": {
"jira": {
"command": "python",
"args": ["${PROJECT_ROOT}/servers/jira-mcp/main.py"],
"env": {
"JIRA_API_TOKEN": "${JIRA_API_TOKEN}",
"JIRA_BASE_URL": "${JIRA_BASE_URL}",
"JIRA_EMAIL": "${JIRA_EMAIL}"
}
},
"logs": {
"command": "python",
"args": ["${PROJECT_ROOT}/servers/logs-mcp/server.py"],
"env": {
"LOG_API_ENDPOINT": "${LOG_API_ENDPOINT}",
"LOG_API_KEY": "${LOG_API_KEY}"
}
},
"deployment": {
"command": "node",
"args": ["${PROJECT_ROOT}/servers/deployment-mcp/index.js"],
"env": {
"DEPLOY_API_KEY": "${DEPLOY_API_KEY}"
}
}
}
}

Now Claude has access to:

  • JIRA: Task context and project details
  • Logs: Real-time debugging data
  • Deployment: CI/CD pipeline integration

Key Implementation Tips

1. Start with API Wrappers

The simplest MCP servers just wrap existing APIs:

@mcp.tool()
def search_code(query: str) -> list:
"""Search codebase using existing search API"""
results = existing_search_api.search(query)
return results

2. Add Context Formatting

Raw API data isn’t AI-ready. Transform it:

def format_jira_ticket(issue):
"""Convert JIRA API response to AI-readable context"""
return f"""
Ticket: {issue.key} - {issue.fields.summary}
Status: {issue.fields.status.name}
Priority: {issue.fields.priority.name}
Description:
{issue.fields.description}
Acceptance Criteria:
{extract_acceptance_criteria(issue.fields.description)}
"""

3. Use Environment Variables

Never hardcode credentials:

import os
api_token = os.environ.get("API_TOKEN")
if not api_token:
raise ValueError("API_TOKEN environment variable required")

4. Handle Errors Gracefully

External APIs can fail:

@mcp.tool()
def get_data(id: str) -> dict:
"""Get data with error handling"""
try:
return api_client.get(id)
except APIError as e:
return {
"error": f"Failed to fetch data: {str(e)}",
"id": id
}

Common Use Cases

I’ve built MCP servers for:

Project Management:

  • JIRA, Linear, Asana integrations
  • Automatic task context loading
  • Team workload insights

Monitoring & Debugging:

  • Log aggregation dashboards
  • Error tracking systems
  • Performance metrics

Development Workflow:

  • Git workflow automation
  • CI/CD pipeline integration
  • Code review tools

Documentation:

  • Internal wiki access
  • API documentation search
  • Team knowledge bases

Performance Optimization

Async Operations

For I/O-bound tasks, use async:

@mcp.tool()
async def fetch_multiple_tickets(ticket_ids: list) -> list:
"""Fetch multiple tickets concurrently"""
tasks = [fetch_ticket_async(id) for id in ticket_ids]
return await asyncio.gather(*tasks)

Caching

Cache frequently accessed data:

from functools import lru_cache
@mcp.tool()
@lru_cache(maxsize=100)
def get_project_context(project_key: str) -> str:
"""Cached project context"""
return fetch_project_context(project_key)

Security Best Practices

  1. Environment Variables: Use .env for secrets
  2. Least Privilege: Grant minimal necessary permissions
  3. Input Validation: Sanitize all inputs
  4. Rate Limiting: Implement to avoid API abuse
  5. Audit Logging: Track tool usage

The Result

After implementing MCP servers, my workflow transformed:

Before:

  • Manual context gathering: 15-30 min/day
  • Repetitive explanations: 10-20 min/day
  • Tool switching: 20-40 min/day

After:

  • Claude automatically pulls context
  • Zero manual explanations
  • Seamless tool coordination

Total time saved: 1-2 hours per day.

As I shared on Reddit: “The model is smart, but the model + direct access to your actual tools is a completely different experience.”

Summary

Building custom MCP servers for Claude Code is straightforward:

  1. Install the MCP Python SDK (fastmcp)
  2. Create tools that wrap your APIs
  3. Format data for AI readability
  4. Configure via .mcp.json
  5. Test and iterate

The productivity gains are immediate and substantial. If you’re still using Claude Code with default tools only, you’re leaving significant value on the table.

Start with one simple server today—wrap an API you use frequently and measure the time savings. You’ll be amazed at the difference.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments