Tutorials

How to Build Your Own MCP Server in Python: A Step-by-Step Tutorial

There are thousands of ready-made MCP servers out there. GitHub, Slack, PostgreSQL, Jira, Notion — for popular services, someone has already written and shipped a server. But sooner or later you hit a wall: no server exists for what you need. An internal company API. A custom CRM. A homegrown deployment system. Business logic specific enough that nobody else was ever going to wrap it for you.

That’s when you write your own MCP server. It’s easier than it sounds: a minimal Python server is 30 lines of code. A production-grade server with authentication, error handling, and tests runs 200-300 lines.

This article is a step-by-step tutorial. We’ll build an MCP server for Jira — from the first @mcp.tool() to publishing a pip package. All the code is Python, using FastMCP, the high-level API from the official SDK. If you’re new to the protocol itself, our guide to production MCP servers covers the broader design space before you get to code.

Why build your own MCP server

Off-the-shelf servers fall short in a few recurring situations.

Internal APIs. A company has its own ticketing system, its own CI/CD, its own metrics dashboard. No pre-built MCP server exists for those — you write the wrapper yourself.

Business logic specific to you. A ready-made MCP server for Jira exists. But you don’t just need ticket access — you need logic: auto-assigning sprints by priority, calculating team velocity, generating release notes from closed tickets. That’s not a generic server; it’s a tool shaped around your process.

Composite tools. A single MCP server can pull together multiple data sources. An onboarding server, for instance: pulls data from the HR system, creates tickets in Jira, sets up access through an internal API, sends a welcome message in Slack. One tool, one prompt, one action for the AI — the kind of orchestration that also shows up in multi-agent architectures, where several specialized agents share a common set of tools.

What we’re building

An MCP server for working with Jira. Four tools:

  • get_issue — fetch ticket data by key
  • search_issues — search tickets via JQL
  • create_issue — create a new ticket
  • add_comment — add a comment to a ticket

Plus resources for reading data and prompts for interaction templates. A complete server that plugs into Claude Code and lets the AI work with your Jira project through natural language.

Stack: Python 3.10+, the mcp SDK (FastMCP), httpx for HTTP requests.

Step 1: Installation and project structure

Installing the SDK

pip install "mcp>=1.28,<2" "fastmcp>=2.0" httpx

Or with uv (recommended for new projects):

uv init mcp-jira-server
cd mcp-jira-server
uv add "mcp>=1.28,<2" "fastmcp>=2.0" httpx

The two packages work together: mcp is the official Python SDK from Anthropic (v1.28+, stable; v2 is in alpha — don’t use it in production yet, so pin <2) implementing the base protocol. fastmcp is a standalone package (v2.x, from jlowin/fastmcp) with the high-level FastMCP API, a client, testing utilities, and an extended prompts API. FastMCP is the recommended way to write MCP servers — it pulls in the official mcp package as a dependency.

Project structure

mcp-jira-server/
├── src/
│   └── mcp_jira/
│       ├── __init__.py
│       ├── server.py          # FastMCP server
│       ├── jira_client.py     # HTTP client for the Jira API
│       └── config.py          # Configuration
├── tests/
│   ├── __init__.py
│   └── test_server.py
├── pyproject.toml
└── README.md

Minimal server

Start with the simplest possible thing — just confirm the SDK works:

# src/mcp_jira/server.py
from fastmcp import FastMCP

mcp = FastMCP("Jira MCP Server")


@mcp.tool
def ping() -> str:
    """Health check"""
    return "pong"


if __name__ == "__main__":
    mcp.run()

Run it:

python src/mcp_jira/server.py

The server starts on the stdio transport — the standard approach for local MCP servers. Input/output goes through stdin/stdout using JSON-RPC 2.0. For remote servers (an API service with multiple clients), use the Streamable HTTP transport instead: mcp.run(transport="http", host="0.0.0.0", port=8000). In FastMCP 3, transport parameters (host, port, log_level, and others) are passed to run(), not to the FastMCP() constructor.

Step 2: Your first tool — working with tickets

Tools are the core unit of an MCP server. They’re functions the AI model can call to perform actions with side effects: creating, updating, deleting. Resources, by contrast, are read-only.

Jira HTTP client

Start with a wrapper around the Jira REST API:

# src/mcp_jira/jira_client.py
import httpx
from dataclasses import dataclass


@dataclass
class JiraConfig:
    base_url: str       # https://your-company.atlassian.net
    email: str          # [email protected]
    api_token: str      # Jira API token


class JiraClient:
    def __init__(self, config: JiraConfig):
        self.config = config
        self.client = httpx.AsyncClient(
            base_url=f"{config.base_url}/rest/api/3",
            auth=(config.email, config.api_token),
            headers={"Content-Type": "application/json"},
            timeout=30.0,
        )

    async def get_issue(self, issue_key: str) -> dict:
        """Get a ticket by key (e.g. PROJ-123)"""
        response = await self.client.get(f"/issue/{issue_key}")
        response.raise_for_status()
        return response.json()

    async def search_issues(self, jql: str, max_results: int = 20) -> list[dict]:
        """Search tickets via JQL"""
        response = await self.client.post(
            "/search",
            json={
                "jql": jql,
                "maxResults": max_results,
                "fields": [
                    "summary", "status", "assignee",
                    "priority", "created", "updated",
                ],
            },
        )
        response.raise_for_status()
        data = response.json()
        return data.get("issues", [])

    async def create_issue(
        self,
        project_key: str,
        summary: str,
        description: str,
        issue_type: str = "Task",
    ) -> dict:
        """Create a new ticket"""
        response = await self.client.post(
            "/issue",
            json={
                "fields": {
                    "project": {"key": project_key},
                    "summary": summary,
                    "description": {
                        "type": "doc",
                        "version": 1,
                        "content": [
                            {
                                "type": "paragraph",
                                "content": [
                                    {"type": "text", "text": description}
                                ],
                            }
                        ],
                    },
                    "issuetype": {"name": issue_type},
                },
            },
        )
        response.raise_for_status()
        return response.json()

    async def add_comment(self, issue_key: str, body: str) -> dict:
        """Add a comment to a ticket"""
        response = await self.client.post(
            f"/issue/{issue_key}/comment",
            json={
                "body": {
                    "type": "doc",
                    "version": 1,
                    "content": [
                        {
                            "type": "paragraph",
                            "content": [
                                {"type": "text", "text": body}
                            ],
                        }
                    ],
                },
            },
        )
        response.raise_for_status()
        return response.json()

    async def close(self):
        await self.client.aclose()

Registering tools

Now wire the tools into the MCP server. Each tool is a function decorated with @mcp.tool(). FastMCP auto-generates a JSON Schema from the type annotations and docstring.

# src/mcp_jira/server.py
import os
from fastmcp import FastMCP
from mcp_jira.jira_client import JiraClient, JiraConfig

mcp = FastMCP("Jira MCP Server")

# Initialize the client from environment variables
jira = JiraClient(
    JiraConfig(
        base_url=os.environ["JIRA_BASE_URL"],
        email=os.environ["JIRA_EMAIL"],
        api_token=os.environ["JIRA_API_TOKEN"],
    )
)


@mcp.tool()
async def get_issue(issue_key: str) -> str:
    """Get Jira ticket data by key.

    Args:
        issue_key: Ticket key, e.g. PROJ-123
    """
    data = await jira.get_issue(issue_key)
    fields = data["fields"]
    assignee = fields.get("assignee")
    assignee_name = assignee.get("displayName", "Unassigned") if assignee else "Unassigned"
    return (
        f"**{data['key']}**: {fields['summary']}\n"
        f"Status: {fields['status']['name']}\n"
        f"Priority: {fields.get('priority', {}).get('name', 'Not set')}\n"
        f"Assignee: {assignee_name}\n"
        f"Created: {fields['created']}\n"
        f"Updated: {fields['updated']}"
    )


@mcp.tool()
async def search_issues(jql: str, max_results: int = 20) -> str:
    """Search Jira tickets via a JQL query.

    Args:
        jql: JQL query, e.g. 'project = PROJ AND status = "In Progress"'
        max_results: Maximum number of results (default 20)
    """
    issues = await jira.search_issues(jql, max_results)
    if not issues:
        return "No tickets found."

    lines = []
    for issue in issues:
        fields = issue["fields"]
        assignee = fields.get("assignee", {})
        assignee_name = assignee.get("displayName", "—") if assignee else "—"
        lines.append(
            f"- **{issue['key']}**: {fields['summary']} "
            f"[{fields['status']['name']}] → {assignee_name}"
        )
    return f"Found {len(issues)} tickets:\n\n" + "\n".join(lines)


@mcp.tool()
async def create_issue(
    project_key: str,
    summary: str,
    description: str = "",
    issue_type: str = "Task",
) -> str:
    """Create a new Jira ticket.

    Args:
        project_key: Project key, e.g. PROJ
        summary: Ticket title
        description: Ticket description
        issue_type: Ticket type — Task, Bug, Story (default Task)
    """
    result = await jira.create_issue(
        project_key, summary, description, issue_type
    )
    return f"Created ticket **{result['key']}**: {summary}"


@mcp.tool()
async def add_comment(issue_key: str, body: str) -> str:
    """Add a comment to a Jira ticket.

    Args:
        issue_key: Ticket key, e.g. PROJ-123
        body: Comment text
    """
    await jira.add_comment(issue_key, body)
    return f"Comment added to {issue_key}."


if __name__ == "__main__":
    mcp.run()

A few things worth calling out:

The docstring is the description the AI sees. FastMCP takes the function’s docstring and passes it to the AI model as the tool’s description. The model decides which tool to call based on that description — the same principle behind writing good prompts in general, covered in our guide to writing effective prompts. Write docstrings so the AI understands the purpose and parameters, not for humans skimming the code.

Type annotations become JSON Schema. Parameter types (str, int, default values) are automatically converted into a JSON Schema. The AI gets a typed input schema — it knows exactly what to pass.

Return values are strings. Tools return text, which the AI model receives as the call result. Format for readability — markdown works fine.

Step 3: Resources — data for reading

Resources are the second capability type in MCP. Semantically, they’re GET requests: read-only, no side effects. The AI model reads resources to gather context before deciding what to do.

@mcp.resource("jira://projects")
async def list_projects() -> str:
    """List of available Jira projects"""
    response = await jira.client.get("/project")
    response.raise_for_status()
    projects = response.json()

    lines = []
    for proj in projects:
        lines.append(f"- **{proj['key']}**: {proj['name']}")
    return "\n".join(lines)

URI templates — dynamic resources

Static resources are fine for fixed data. For parameterized data, MCP offers URI templates:

@mcp.resource("jira://issue/{issue_key}")
async def get_issue_resource(issue_key: str) -> str:
    """Jira ticket data for context.

    Used by the AI to get information about a ticket
    without performing any action.
    """
    data = await jira.get_issue(issue_key)
    fields = data["fields"]

    return (
        f"# {data['key']}: {fields['summary']}\n\n"
        f"**Status:** {fields['status']['name']}\n"
        f"**Priority:** {fields.get('priority', {}).get('name', 'N/A')}\n"
        f"**Type:** {fields['issuetype']['name']}\n"
        f"**Assignee:** {fields.get('assignee', {}).get('displayName', 'Unassigned')}\n\n"
        f"**Description:**\n{_extract_text(fields.get('description', {}))}"
    )


def _extract_text(adf: dict) -> str:
    """Extract text from Atlassian Document Format"""
    if not adf or not isinstance(adf, dict):
        return "No description"

    texts = []
    for block in adf.get("content", []):
        for inline in block.get("content", []):
            if inline.get("type") == "text":
                texts.append(inline["text"])
    return "\n".join(texts) if texts else "No description"


@mcp.resource("jira://sprint/{board_id}/active")
async def get_active_sprint(board_id: str) -> str:
    """Active sprint data for a board.

    Args:
        board_id: Jira board ID (Agile board)
    """
    # The sprint endpoint belongs to the Jira Agile API (/rest/agile/1.0/),
    # not /rest/api/3 — so we build the full URL explicitly
    response = await jira.client.get(
        f"{jira.config.base_url}/rest/agile/1.0/board/{board_id}/sprint",
        params={"state": "active"},
    )
    response.raise_for_status()
    sprints = response.json().get("values", [])

    if not sprints:
        return "No active sprint."

    sprint = sprints[0]
    return (
        f"# Sprint: {sprint['name']}\n\n"
        f"**Goal:** {sprint.get('goal', 'Not set')}\n"
        f"**Start:** {sprint.get('startDate', 'N/A')}\n"
        f"**End:** {sprint.get('endDate', 'N/A')}\n"
        f"**Status:** {sprint['state']}"
    )

Tool vs. resource — when to use which

The distinction is fundamental:

ToolResource
PurposePerform an actionProvide data for reading
Side effectsYes (create, update)No
REST analogyPOST, PUT, DELETEGET
ExampleCreate a ticket, add a commentTicket data, project list

In practice, get_issue as a tool and as a resource do similar things. The difference is semantic. A tool is for an explicit call as part of an action. A resource is for gathering context before making a decision.

Step 4: Prompts — interaction templates

Prompts are the third capability type. They’re prompt templates the MCP server offers to the AI model. The client (Claude Code, Cursor) uses them as a starting point for interaction.

from fastmcp.prompts import Message


@mcp.prompt()
def triage_issue(issue_key: str) -> str:
    """Analyze a ticket and suggest priority and assignment.

    Args:
        issue_key: Ticket key to analyze
    """
    return (
        f"Analyze ticket {issue_key} in Jira.\n\n"
        f"1. Read the ticket data via get_issue\n"
        f"2. Determine priority based on the description and type\n"
        f"3. Suggest who to assign it to, based on current team workload\n"
        f"4. Estimate effort in story points\n\n"
        f"Return a structured response with reasoning for each decision."
    )


@mcp.prompt()
def sprint_review() -> str:
    """Generate a report on the current sprint"""
    return (
        "Generate a report on the current sprint:\n\n"
        "1. Find all tickets in the current sprint via search_issues\n"
        "2. Group by status: Done, In Progress, To Do\n"
        "3. Calculate the completion percentage\n"
        "4. Flag blocked tickets\n"
        "5. Summarize risks to finishing the sprint\n\n"
        "Format: a markdown table with stats + a text risk analysis."
    )


@mcp.prompt()
def release_notes(version: str) -> list[Message]:
    """Generate release notes for a version.

    Args:
        version: Version number, e.g. 2.4.0
    """
    return [
        Message(
            f"Generate release notes for version {version}.\n\n"
            f"Find all tickets with fixVersion = {version} via search_issues "
            f'using JQL: fixVersion = "{version}" AND status = Done\n\n'
            f"Group by type: Features, Bug Fixes, Improvements.\n"
            f"Format: markdown, ready for publishing."
        ),
        Message(
            f"I'll start putting together release notes for v{version}. "
            f"First I'll find all closed tickets for this version.",
            role="assistant",
        ),
    ]

Prompts return either a string (a single-turn prompt) or a list of Message objects (multi-turn — a dialog with pre-set replies). The second form is useful when you need to set context for the assistant before it starts working on the task.

Step 5: Connecting to Claude Code

The server is written. Time to connect it to Claude Code.

Configuration via CLI

The easiest way is the claude mcp add command:

claude mcp add jira-server \
  -e JIRA_BASE_URL=https://your-company.atlassian.net \
  -e [email protected] \
  -e JIRA_API_TOKEN=your-api-token \
  -- python src/mcp_jira/server.py

Claude Code adds an entry to its config file and restarts the server.

Configuration via JSON

The alternative is a .mcp.json file at the project root:

{
  "mcpServers": {
    "jira-server": {
      "command": "python",
      "args": ["src/mcp_jira/server.py"],
      "env": {
        "JIRA_BASE_URL": "https://your-company.atlassian.net",
        "JIRA_EMAIL": "[email protected]",
        "JIRA_API_TOKEN": "your-api-token"
      }
    }
  }
}

If you’re using uv:

{
  "mcpServers": {
    "jira-server": {
      "command": "uv",
      "args": ["run", "python", "src/mcp_jira/server.py"],
      "env": {
        "JIRA_BASE_URL": "https://your-company.atlassian.net",
        "JIRA_EMAIL": "[email protected]",
        "JIRA_API_TOKEN": "your-api-token"
      }
    }
  }
}

Verifying the connection

After restarting Claude Code, the server shows up in the list of available tools. Check it:

> claude mcp list

You should see jira-server with a connected status and its list of registered tools.

Now you can use it in conversation:

> Show me all open bugs in the BACKEND project

Claude Code sees the search_issues tool, builds the JQL project = BACKEND AND type = Bug AND status != Done, calls the tool, and displays the result.

Step 6: Error handling

A production server shouldn’t crash on the first error. Build the defenses in layers.

Layer 1: Input validation

import re


@mcp.tool()
async def get_issue(issue_key: str) -> str:
    """Get Jira ticket data by key.

    Args:
        issue_key: Ticket key, e.g. PROJ-123
    """
    # Validate the key format
    if not re.match(r"^[A-Z][A-Z0-9_]+-\d+$", issue_key):
        return f"Invalid key format: {issue_key}. Expected format PROJ-123."

    data = await jira.get_issue(issue_key)
    # ... formatting

Return an error message as text rather than raising an exception. The AI model gets the message and can correct its request.

Layer 2: Handling HTTP errors

import httpx


@mcp.tool()
async def get_issue(issue_key: str) -> str:
    """Get Jira ticket data by key.

    Args:
        issue_key: Ticket key, e.g. PROJ-123
    """
    if not re.match(r"^[A-Z][A-Z0-9_]+-\d+$", issue_key):
        return f"Invalid key format: {issue_key}. Expected format PROJ-123."

    try:
        data = await jira.get_issue(issue_key)
    except httpx.HTTPStatusError as e:
        if e.response.status_code == 404:
            return f"Ticket {issue_key} not found."
        if e.response.status_code == 401:
            return "Authentication error. Check JIRA_API_TOKEN."
        if e.response.status_code == 403:
            return f"No access to ticket {issue_key}."
        return f"Jira API error: {e.response.status_code}"
    except httpx.ConnectError:
        return "Could not connect to Jira. Check JIRA_BASE_URL."
    except httpx.TimeoutException:
        return "Timed out waiting for Jira. Try again later."

    fields = data["fields"]
    return (
        f"**{data['key']}**: {fields['summary']}\n"
        f"Status: {fields['status']['name']}\n"
        f"Priority: {fields.get('priority', {}).get('name', 'Not set')}\n"
        f"Assignee: {fields.get('assignee', {}).get('displayName', 'Unassigned')}\n"
        f"Created: {fields['created']}\n"
        f"Updated: {fields['updated']}"
    )

Layer 3: A shared handler via decorator

To avoid duplicating try/except in every tool, move the handling into a decorator:

import functools
import logging

logger = logging.getLogger("mcp_jira")


def handle_errors(func):
    """Decorator for handling errors in MCP tools"""
    @functools.wraps(func)
    async def wrapper(*args, **kwargs):
        try:
            return await func(*args, **kwargs)
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            error_map = {
                401: "Authentication error. Check JIRA_API_TOKEN.",
                403: "Access denied. Check the account's permissions.",
                404: "Resource not found.",
                429: "Rate limit exceeded. Wait and retry.",
            }
            msg = error_map.get(status, f"Jira API error: {status}")
            logger.error(f"HTTP {status} in {func.__name__}: {e}")
            return msg
        except httpx.ConnectError:
            logger.error(f"Connection error in {func.__name__}")
            return "Could not connect to Jira. Check JIRA_BASE_URL."
        except httpx.TimeoutException:
            logger.error(f"Timeout in {func.__name__}")
            return "Timed out waiting for Jira."
        except Exception as e:
            logger.exception(f"Unexpected error in {func.__name__}")
            return f"Internal error: {type(e).__name__}"
    return wrapper


@mcp.tool()
@handle_errors
async def get_issue(issue_key: str) -> str:
    """Get Jira ticket data by key.

    Args:
        issue_key: Ticket key, e.g. PROJ-123
    """
    data = await jira.get_issue(issue_key)
    fields = data["fields"]
    return (
        f"**{data['key']}**: {fields['summary']}\n"
        f"Status: {fields['status']['name']}\n"
        f"Priority: {fields.get('priority', {}).get('name', 'Not set')}\n"
        f"Assignee: {fields.get('assignee', {}).get('displayName', 'Unassigned')}"
    )

The principle: a tool never lets an exception escape unhandled. The AI model always gets a text response — either data or a clear description of what went wrong. Once the server is live, the same discipline applies to observability: tracing every tool call through something like Langfuse makes silent failures visible instead of buried in logs.

Step 7: Authentication and secrets

In the examples above, credentials came from environment variables. That’s the baseline. For production, there are a few options.

Option 1: Environment variables (baseline)

# src/mcp_jira/config.py
import os


def get_jira_config() -> JiraConfig:
    base_url = os.environ.get("JIRA_BASE_URL")
    email = os.environ.get("JIRA_EMAIL")
    api_token = os.environ.get("JIRA_API_TOKEN")

    if not all([base_url, email, api_token]):
        missing = []
        if not base_url:
            missing.append("JIRA_BASE_URL")
        if not email:
            missing.append("JIRA_EMAIL")
        if not api_token:
            missing.append("JIRA_API_TOKEN")
        raise ValueError(
            f"Missing environment variables: {', '.join(missing)}"
        )

    return JiraConfig(
        base_url=base_url,
        email=email,
        api_token=api_token,
    )

Pass these through the env section in .mcp.json. Don’t commit real tokens — use .env.local or a secrets manager.

Option 2: Lifespan with initialization

FastMCP supports lifespan — a pattern for initializing and cleaning up resources at server start/stop. It’s ideal for clients that hold connections:

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass

from fastmcp import FastMCP, Context


@dataclass
class AppContext:
    jira: JiraClient


@asynccontextmanager
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
    """Initialize on start, clean up on stop"""
    config = get_jira_config()
    jira = JiraClient(config)
    try:
        yield AppContext(jira=jira)
    finally:
        await jira.close()


mcp = FastMCP("Jira MCP Server", lifespan=app_lifespan)


@mcp.tool()
@handle_errors
async def get_issue(issue_key: str, ctx: Context) -> str:
    """Get Jira ticket data by key.

    Args:
        issue_key: Ticket key, e.g. PROJ-123
    """
    jira = ctx.lifespan_context.jira
    data = await jira.get_issue(issue_key)
    fields = data["fields"]
    return (
        f"**{data['key']}**: {fields['summary']}\n"
        f"Status: {fields['status']['name']}"
    )

Context is an object FastMCP passes into the tool function. Through ctx.lifespan_context you get access to whatever the lifespan function returned (AppContext with its initialized dependencies). The ctx: Context parameter is injected automatically by the framework — the AI model neither sees it nor passes it.

Option 3: OAuth 2.1 (for remote servers)

For servers reachable over HTTP (not stdio), the MCP SDK supports OAuth 2.1:

from pydantic import AnyHttpUrl
from fastmcp.server.auth import AccessToken, TokenVerifier, AuthSettings


class JiraTokenVerifier(TokenVerifier):
    async def verify_token(self, token: str) -> AccessToken | None:
        # Validate the token against your authentication system
        # Return an AccessToken on success, None on failure
        pass


mcp = FastMCP(
    "Jira MCP Server",
    token_verifier=JiraTokenVerifier(),
    auth=AuthSettings(
        issuer_url=AnyHttpUrl("https://auth.your-company.com"),
        resource_server_url=AnyHttpUrl("https://mcp-jira.your-company.com"),
        required_scopes=["jira:read", "jira:write"],
    ),
)

OAuth is needed when the MCP server is deployed as a remote service (Streamable HTTP transport) with multiple users connecting to it. For local servers (stdio), environment variables are enough.

Step 8: Testing

MCP Inspector

MCP Inspector is an interactive UI for testing servers. It connects to a server, lists its tools, resources, and prompts, and lets you invoke them by hand.

npx -y @modelcontextprotocol/inspector

Inspector opens a browser interface. Point it at your server’s launch command — it connects via stdio and shows every registered capability.

What to check:

  • Every tool shows up with an accurate description
  • The parameter JSON Schema matches what you expect
  • Calling each tool returns the correct result
  • Errors return readable messages, not stack traces

Unit tests with pytest

The MCP SDK provides a Client class for testing servers without spinning up a transport:

# tests/test_server.py
import pytest
from unittest.mock import AsyncMock, patch
from fastmcp.client import Client

from mcp_jira.server import mcp
from mcp_jira.jira_client import JiraClient


@pytest.fixture
async def client():
    async with Client(mcp) as c:
        yield c


async def test_get_issue(client: Client):
    """Test fetching a ticket"""
    mock_response = {
        "key": "PROJ-123",
        "fields": {
            "summary": "Fix login bug",
            "status": {"name": "In Progress"},
            "priority": {"name": "High"},
            "assignee": {"displayName": "John Doe"},
            "issuetype": {"name": "Bug"},
            "description": {},
            "created": "2026-01-15T10:00:00.000+0000",
            "updated": "2026-01-16T14:30:00.000+0000",
        },
    }

    # Patch the class method — works with the lifespan architecture,
    # where JiraClient is initialized inside AppContext
    with patch.object(JiraClient, "get_issue", new_callable=AsyncMock, return_value=mock_response):
        result = await client.call_tool("get_issue", {"issue_key": "PROJ-123"})
        text = result.content[0].text
        assert "PROJ-123" in text
        assert "Fix login bug" in text
        assert "In Progress" in text


async def test_search_issues_empty(client: Client):
    """Test search returning no results"""
    with patch.object(JiraClient, "search_issues", new_callable=AsyncMock, return_value=[]):
        result = await client.call_tool(
            "search_issues",
            {"jql": "project = EMPTY"},
        )
        assert "No tickets found" in result.content[0].text


async def test_create_issue(client: Client):
    """Test creating a ticket"""
    mock_response = {"key": "PROJ-456", "id": "10456"}

    with patch.object(JiraClient, "create_issue", new_callable=AsyncMock, return_value=mock_response):
        result = await client.call_tool(
            "create_issue",
            {
                "project_key": "PROJ",
                "summary": "New feature request",
                "description": "Add dark mode support",
            },
        )
        assert "PROJ-456" in result.content[0].text


async def test_get_issue_not_found(client: Client):
    """Test handling a 404"""
    import httpx

    mock_response = httpx.Response(404, request=httpx.Request("GET", "test"))
    error = httpx.HTTPStatusError(
        "Not found", request=mock_response.request, response=mock_response
    )

    with patch.object(JiraClient, "get_issue", new_callable=AsyncMock, side_effect=error):
        result = await client.call_tool(
            "get_issue", {"issue_key": "PROJ-999"}
        )
        assert "not found" in result.content[0].text

Run it:

pip install "pytest>=8" pytest-asyncio "fastmcp>=2.0"
pytest tests/ -v

Add this to pyproject.toml:

[tool.pytest.ini_options]
asyncio_mode = "auto"

Client(mcp) from the fastmcp package connects to the server in-process, with no network transport involved. result.content[0].text holds the tool’s text response; result.data holds a hydrated Python object (available if the tool declares an output_schema). Tests run fast — milliseconds — and don’t need a live Jira instance.

Testing resources and prompts

import httpx

async def test_list_projects_resource(client: Client):
    """Test the project list resource"""
    mock_projects = [
        {"key": "PROJ", "name": "Main Project"},
        {"key": "INFRA", "name": "Infrastructure"},
    ]

    # In the lifespan architecture there's no global `jira` — patch the httpx.AsyncClient method instead
    with patch.object(
        httpx.AsyncClient, "get", new_callable=AsyncMock
    ) as mock_get:
        mock_get.return_value.json.return_value = mock_projects
        mock_get.return_value.raise_for_status = lambda: None

        result = await client.read_resource("jira://projects")
        text = result[0].text
        assert "PROJ" in text
        assert "INFRA" in text


async def test_triage_prompt(client: Client):
    """Test the triage prompt"""
    result = await client.get_prompt(
        "triage_issue", {"issue_key": "PROJ-123"}
    )
    text = result.messages[0].content.text
    assert "PROJ-123" in text
    assert "priority" in text

Step 9: Publishing

You can distribute an MCP server a few different ways — from a pip package to a Docker image.

Option 1: pip package

Package the project as a proper Python package:

# pyproject.toml
[project]
name = "mcp-jira-server"
version = "0.1.0"
description = "MCP server for Jira integration"
requires-python = ">=3.10"
dependencies = [
    "mcp>=1.28.0,<2",
    "fastmcp>=2.0.0",
    "httpx>=0.28.0",
]

[project.scripts]
mcp-jira = "mcp_jira.server:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Add an entry point to the server:

# src/mcp_jira/server.py

def main():
    mcp.run()

if __name__ == "__main__":
    main()

Publish it:

pip install build twine
python -m build
twine upload dist/*

After installing (pip install mcp-jira-server), users connect via:

{
  "mcpServers": {
    "jira": {
      "command": "mcp-jira",
      "env": {
        "JIRA_BASE_URL": "https://company.atlassian.net",
        "JIRA_EMAIL": "[email protected]",
        "JIRA_API_TOKEN": "token"
      }
    }
  }
}

Option 2: Docker

FROM python:3.12-slim

WORKDIR /app

COPY pyproject.toml .
COPY src/ src/

RUN pip install --no-cache-dir .

CMD ["mcp-jira"]
docker build -t mcp-jira-server .

Configuration for Claude Code:

{
  "mcpServers": {
    "jira": {
      "command": "docker",
      "args": [
        "run", "-i", "--rm",
        "-e", "JIRA_BASE_URL",
        "-e", "JIRA_EMAIL",
        "-e", "JIRA_API_TOKEN",
        "mcp-jira-server"
      ],
      "env": {
        "JIRA_BASE_URL": "https://company.atlassian.net",
        "JIRA_EMAIL": "[email protected]",
        "JIRA_API_TOKEN": "token"
      }
    }
  }
}

The -i flag is critical — it keeps stdin attached to the container, which the stdio transport requires.

Option 3: uvx (zero-install)

If the package is on PyPI, users can run it without installing anything explicitly:

{
  "mcpServers": {
    "jira": {
      "command": "uvx",
      "args": ["mcp-jira-server"],
      "env": {
        "JIRA_BASE_URL": "https://company.atlassian.net",
        "JIRA_EMAIL": "[email protected]",
        "JIRA_API_TOKEN": "token"
      }
    }
  }
}

uvx downloads the package, creates an isolated environment, and runs the server. No pip install, no virtual environments to manage.

Full server code

The final version with all the pieces put together:

# src/mcp_jira/server.py
"""MCP server for working with Jira."""

import functools
import logging
import os
import re
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass

import httpx
from fastmcp import Context, FastMCP
from fastmcp.prompts import Message

# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logger = logging.getLogger("mcp_jira")

# ---------------------------------------------------------------------------
# Jira client
# ---------------------------------------------------------------------------

@dataclass
class JiraConfig:
    base_url: str
    email: str
    api_token: str


class JiraClient:
    def __init__(self, config: JiraConfig):
        self.config = config
        self.client = httpx.AsyncClient(
            base_url=f"{config.base_url}/rest/api/3",
            auth=(config.email, config.api_token),
            headers={"Content-Type": "application/json"},
            timeout=30.0,
        )

    async def get_issue(self, issue_key: str) -> dict:
        response = await self.client.get(f"/issue/{issue_key}")
        response.raise_for_status()
        return response.json()

    async def search_issues(self, jql: str, max_results: int = 20) -> list[dict]:
        response = await self.client.post(
            "/search",
            json={
                "jql": jql,
                "maxResults": max_results,
                "fields": [
                    "summary", "status", "assignee",
                    "priority", "created", "updated",
                    "issuetype", "description",
                ],
            },
        )
        response.raise_for_status()
        return response.json().get("issues", [])

    async def create_issue(
        self,
        project_key: str,
        summary: str,
        description: str,
        issue_type: str = "Task",
    ) -> dict:
        response = await self.client.post(
            "/issue",
            json={
                "fields": {
                    "project": {"key": project_key},
                    "summary": summary,
                    "description": {
                        "type": "doc",
                        "version": 1,
                        "content": [
                            {
                                "type": "paragraph",
                                "content": [
                                    {"type": "text", "text": description}
                                ],
                            }
                        ],
                    },
                    "issuetype": {"name": issue_type},
                },
            },
        )
        response.raise_for_status()
        return response.json()

    async def add_comment(self, issue_key: str, body: str) -> dict:
        response = await self.client.post(
            f"/issue/{issue_key}/comment",
            json={
                "body": {
                    "type": "doc",
                    "version": 1,
                    "content": [
                        {
                            "type": "paragraph",
                            "content": [
                                {"type": "text", "text": body}
                            ],
                        }
                    ],
                },
            },
        )
        response.raise_for_status()
        return response.json()

    async def close(self):
        await self.client.aclose()


# ---------------------------------------------------------------------------
# Lifespan
# ---------------------------------------------------------------------------

@dataclass
class AppContext:
    jira: JiraClient


@asynccontextmanager
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
    config = JiraConfig(
        base_url=os.environ["JIRA_BASE_URL"],
        email=os.environ["JIRA_EMAIL"],
        api_token=os.environ["JIRA_API_TOKEN"],
    )
    jira = JiraClient(config)
    try:
        yield AppContext(jira=jira)
    finally:
        await jira.close()


# ---------------------------------------------------------------------------
# Error handling
# ---------------------------------------------------------------------------

def handle_errors(func):
    @functools.wraps(func)
    async def wrapper(*args, **kwargs):
        try:
            return await func(*args, **kwargs)
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            error_map = {
                401: "Authentication error. Check JIRA_API_TOKEN.",
                403: "Access denied. Check the account's permissions.",
                404: "Resource not found in Jira.",
                429: "Rate limit exceeded. Wait and retry.",
            }
            msg = error_map.get(status, f"Jira API error: {status}")
            logger.error(f"HTTP {status} in {func.__name__}: {e}")
            return msg
        except httpx.ConnectError:
            return "Could not connect to Jira. Check JIRA_BASE_URL."
        except httpx.TimeoutException:
            return "Timed out waiting for Jira."
        except Exception as e:
            logger.exception(f"Unexpected error in {func.__name__}")
            return f"Internal error: {type(e).__name__}"
    return wrapper


# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------

mcp = FastMCP("Jira MCP Server", lifespan=app_lifespan)


def _get_jira(ctx: Context) -> JiraClient:
    return ctx.lifespan_context.jira


def _extract_text(adf: dict) -> str:
    if not adf or not isinstance(adf, dict):
        return "No description"
    texts = []
    for block in adf.get("content", []):
        for inline in block.get("content", []):
            if inline.get("type") == "text":
                texts.append(inline["text"])
    return "\n".join(texts) if texts else "No description"


# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------

@mcp.tool()
@handle_errors
async def get_issue(issue_key: str, ctx: Context) -> str:
    """Get Jira ticket data by key.

    Args:
        issue_key: Ticket key, e.g. PROJ-123
    """
    if not re.match(r"^[A-Z][A-Z0-9_]+-\d+$", issue_key):
        return f"Invalid key format: {issue_key}. Expected PROJ-123."

    jira = _get_jira(ctx)
    data = await jira.get_issue(issue_key)
    fields = data["fields"]
    return (
        f"**{data['key']}**: {fields['summary']}\n"
        f"Status: {fields['status']['name']}\n"
        f"Type: {fields['issuetype']['name']}\n"
        f"Priority: {fields.get('priority', {}).get('name', 'Not set')}\n"
        f"Assignee: {fields.get('assignee', {}).get('displayName', 'Unassigned')}\n"
        f"Created: {fields['created']}\n"
        f"Updated: {fields['updated']}\n\n"
        f"Description:\n{_extract_text(fields.get('description', {}))}"
    )


@mcp.tool()
@handle_errors
async def search_issues(
    jql: str, max_results: int = 20, ctx: Context = None
) -> str:
    """Search Jira tickets via a JQL query.

    Args:
        jql: JQL query, e.g. 'project = PROJ AND status = "In Progress"'
        max_results: Maximum number of results (default 20)
    """
    jira = _get_jira(ctx)
    issues = await jira.search_issues(jql, max_results)

    if not issues:
        return "No tickets found."

    lines = []
    for issue in issues:
        fields = issue["fields"]
        assignee = fields.get("assignee")
        assignee_name = assignee.get("displayName", "—") if assignee else "—"
        lines.append(
            f"- **{issue['key']}**: {fields['summary']} "
            f"[{fields['status']['name']}] → {assignee_name}"
        )
    return f"Found {len(issues)} tickets:\n\n" + "\n".join(lines)


@mcp.tool()
@handle_errors
async def create_issue(
    project_key: str,
    summary: str,
    description: str = "",
    issue_type: str = "Task",
    ctx: Context = None,
) -> str:
    """Create a new Jira ticket.

    Args:
        project_key: Project key, e.g. PROJ
        summary: Ticket title
        description: Ticket description
        issue_type: Type — Task, Bug, Story (default Task)
    """
    jira = _get_jira(ctx)
    result = await jira.create_issue(
        project_key, summary, description, issue_type
    )
    return f"Created ticket **{result['key']}**: {summary}"


@mcp.tool()
@handle_errors
async def add_comment(
    issue_key: str, body: str, ctx: Context = None
) -> str:
    """Add a comment to a Jira ticket.

    Args:
        issue_key: Ticket key, e.g. PROJ-123
        body: Comment text
    """
    jira = _get_jira(ctx)
    await jira.add_comment(issue_key, body)
    return f"Comment added to {issue_key}."


# ---------------------------------------------------------------------------
# Resources
# ---------------------------------------------------------------------------

@mcp.resource("jira://projects")
async def list_projects(ctx: Context) -> str:
    """List of available Jira projects"""
    jira = _get_jira(ctx)
    response = await jira.client.get("/project")
    response.raise_for_status()
    projects = response.json()
    lines = [f"- **{p['key']}**: {p['name']}" for p in projects]
    return "\n".join(lines)


@mcp.resource("jira://issue/{issue_key}")
async def get_issue_resource(issue_key: str, ctx: Context) -> str:
    """Ticket data for AI context"""
    jira = _get_jira(ctx)
    data = await jira.get_issue(issue_key)
    fields = data["fields"]
    return (
        f"# {data['key']}: {fields['summary']}\n\n"
        f"**Status:** {fields['status']['name']}\n"
        f"**Type:** {fields['issuetype']['name']}\n"
        f"**Priority:** {fields.get('priority', {}).get('name', 'N/A')}\n"
        f"**Assignee:** {fields.get('assignee', {}).get('displayName', 'Unassigned')}\n\n"
        f"**Description:**\n{_extract_text(fields.get('description', {}))}"
    )


# ---------------------------------------------------------------------------
# Prompts
# ---------------------------------------------------------------------------

@mcp.prompt()
def triage_issue(issue_key: str) -> str:
    """Analyze a ticket and suggest priority and assignment."""
    return (
        f"Analyze ticket {issue_key} in Jira.\n\n"
        f"1. Read the ticket data via get_issue\n"
        f"2. Determine priority based on the description and type\n"
        f"3. Suggest who to assign it to, based on current team workload\n"
        f"4. Estimate effort in story points\n\n"
        f"Return a structured response with reasoning."
    )


@mcp.prompt()
def sprint_review() -> str:
    """Generate a report on the current sprint"""
    return (
        "Generate a report on the current sprint:\n\n"
        "1. Find all tickets in the current sprint via search_issues\n"
        "2. Group by status: Done, In Progress, To Do\n"
        "3. Calculate the completion percentage\n"
        "4. Flag blocked tickets\n"
        "5. Summarize risks to finishing the sprint\n\n"
        "Format: markdown table + text risk analysis."
    )


@mcp.prompt()
def release_notes(version: str) -> list[Message]:
    """Generate release notes for a version."""
    return [
        Message(
            f"Generate release notes for version {version}.\n\n"
            f"Find all tickets with fixVersion = {version} via search_issues "
            f'using JQL: fixVersion = "{version}" AND status = Done\n\n'
            f"Group by: Features, Bug Fixes, Improvements.\n"
            f"Format: markdown, ready for publishing."
        ),
        Message(
            f"Putting together release notes for v{version}. "
            f"Looking up closed tickets for this version.",
            role="assistant",
        ),
    ]


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main():
    mcp.run()


if __name__ == "__main__":
    main()

Wrapping up

An MCP server in Python boils down to:

  1. FastMCP — a high-level API, minimal boilerplate
  2. @mcp.tool() — registers tools, auto-generates JSON Schema from types
  3. @mcp.resource() — read-only data, URI templates for dynamic resources
  4. @mcp.prompt() — prompt templates, single- and multi-turn
  5. Lifespan — resource initialization/cleanup at server start/stop
  6. Error handling — text responses instead of exceptions, a decorator to unify it
  7. TestingClient(transport=mcp) from the fastmcp package for in-process tests, MCP Inspector for manual checks
  8. Publishing — pip package, Docker, uvx

Going from an empty file to a production server takes about a day. Most of that time goes into business logic (working with the target service’s API), not MCP infrastructure. The SDK handles the transport, serialization, schema generation, and protocol for you.

MCP SDK docs: modelcontextprotocol.io. Python SDK repository: github.com/modelcontextprotocol/python-sdk.