DEV Community

Cover image for MCP Series (04): Building Your First MCP Server — From Hello World to Production Patterns
WonderLab
WonderLab

Posted on

MCP Series (04): Building Your First MCP Server — From Hello World to Production Patterns

The Gap Between Hello World and a Useful Server

The Article 02 demo server runs, but only has echo and add — toy tools. A production-ready Server needs five additional engineering problems solved:

  1. How multiple tools share data and compose
  2. How to validate user input (LLMs pass wrong arguments too)
  3. How to return errors correctly when tool execution fails
  4. Where to write logs (wrong channel breaks the protocol)
  5. How Resources give the LLM context before calling tools

The demo is a complete Jira Server with built-in mock data: no real Jira credentials required.


Server Structure

demo_jira_server.py

Tools (4):
  search_issues(query, project?, status?, max_results?)
  get_issue(issue_key)
  create_issue(project, summary, description?, priority?, issue_type?)
  update_issue(issue_key, status?, assignee?, priority?)

Resources (1):
  jira://projects   project list (LLM reads this to know valid project keys)

Prompts (1):
  bug_analysis      structured bug report template
Enter fullscreen mode Exit fullscreen mode

Pattern 1: Multi-Tool Collaboration

Four tools share one in-memory data store ISSUES, forming a complete read-write cycle:

ISSUES: dict[str, dict] = {
    "PROJ-101": {
        "key": "PROJ-101",
        "summary": "NullPointerException in parseInput() when config is null",
        "status": "Open", "priority": "P1", "issue_type": "Bug",
        ...
    },
}

# search_issues: filter and list
# get_issue:     read one by key
# create_issue:  write new issue (auto-increment ID)
# update_issue:  modify existing issue
Enter fullscreen mode Exit fullscreen mode

Extract shared validation into a helper that multiple tools call:

def validate_issue_key(key: str) -> str | None:
    """Return error message if key is invalid, None if valid."""
    if not key or "-" not in key:
        return f"Invalid issue key format: '{key}'. Expected format: PROJECT-123"
    project = key.split("-")[0]
    if project not in PROJECTS:
        return f"Unknown project: '{project}'. Available projects: {', '.join(PROJECTS)}"
    if key not in ISSUES:
        return f"Issue '{key}' not found"
    return None
Enter fullscreen mode Exit fullscreen mode

Both get_issue and update_issue call this. No duplicate logic.


Pattern 2: Input Validation

LLMs pass incorrect arguments — wrong project keys, invalid status values, empty required fields. The Server validates before acting.

Enum validation:

VALID_STATUSES = {"Open", "In Progress", "In Review", "Done", "Closed"}

if new_status and new_status not in VALID_STATUSES:
    return [TextContent(type="text", text=(
        f"Invalid status: '{new_status}'. Valid values: {', '.join(sorted(VALID_STATUSES))}"
    ), isError=True)]
Enter fullscreen mode Exit fullscreen mode

Required field validation:

if not summary:
    return [TextContent(type="text", text="'summary' is required.", isError=True)]
Enter fullscreen mode Exit fullscreen mode

Business rule validation:

if len(summary) > 255:
    return [TextContent(type="text", text="Summary exceeds 255 characters.", isError=True)]

# at least one field must change
if not any([new_status, new_assignee is not None, new_priority]):
    return [TextContent(type="text", text=(
        "No fields to update. Provide at least one of: status, assignee, priority."
    ), isError=True)]
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Structured Error Handling

MCP has two ways to express failure. Choosing the wrong one affects how the LLM responds.

Option A: isError=True (hard error)

return [TextContent(type="text", text="Issue 'PROJ-999' not found", isError=True)]
Enter fullscreen mode Exit fullscreen mode

The LLM reads isError=true and adjusts: tries a different key, or reports the issue to the user.

Use for: resource not found, invalid arguments, permission denied.

Option B: Normal response (informational correction)

return [TextContent(type="text", text=(
    "Unknown project: 'INVALID'. Available projects: PROJ, MOBILE, INFRA"
))]
# no isError=True
Enter fullscreen mode Exit fullscreen mode

The LLM treats the tool as successful, reads the suggestion, and retries with a valid project key.

Use for: search-style operations where the input might be ambiguous and the LLM can self-correct.

Test output comparison:

search with project='INVALID' (no isError):
  ✓  Unknown project: 'INVALID'. Available projects: PROJ, MOBILE, INFRA
     → LLM retries with PROJ, MOBILE, or INFRA

get_issue('PROJ-999') (isError=True):
  ✗  Issue 'PROJ-999' not found
     → LLM knows this was a failure, won't keep using this key
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Logs Must Go to stderr

Critical constraint: stdout is the JSON-RPC communication channel. Anything written to stdout corrupts the protocol.

# ✅ Correct: log to stderr
logging.basicConfig(
    stream=sys.stderr,          # ← must be stderr
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("jira-mcp")

logger.info("tools/call: %s args=%s", name, arguments)
logger.info("Created issue %s", key)

# ❌ Wrong: any print() breaks the protocol
print("Created issue PROJ-201")  # injects text into the JSON-RPC stream
Enter fullscreen mode Exit fullscreen mode

Viewing logs during development:

# Run server in background, logs to file
python demo_jira_server.py 2>server.log &

# Watch logs in a separate terminal
tail -f server.log
Enter fullscreen mode Exit fullscreen mode

Pattern 5: Resources Provide Context

Tool schemas have "Available: PROJ, MOBILE, INFRA" hardcoded in descriptions. A better approach: expose a Resource so the LLM can read the current project list dynamically:

@server.list_resources()
async def list_resources() -> list[Resource]:
    return [Resource(
        uri="jira://projects",
        name="Available Projects",
        description="List of Jira projects. Read before calling tools to know valid project keys.",
        mimeType="application/json"
    )]

@server.read_resource()
async def read_resource(uri: str) -> str:
    if str(uri) == "jira://projects":
        data = [{"key": k, "name": v["name"], "lead": v["lead"]}
                for k, v in PROJECTS.items()]
        return json.dumps(data, indent=2)
Enter fullscreen mode Exit fullscreen mode

The LLM reads jira://projects before any tool call and knows exactly which project keys are valid. No more guessing. Useful for anything that changes dynamically — project lists, user directories, configuration values.


Full Test Results

[search_issues]
  ✓ search 'NPE' in PROJ
    Found 1 issue(s): [PROJ-101] NullPointerException in parseInput()...
  ✓ search 'crash' with status=Open
    Found 1 issue(s): [MOBILE-55] Crash on Android 14...
  ✓ search with invalid project (informational, no isError)
    Unknown project: 'INVALID'. Available projects: PROJ, MOBILE, INFRA

[get_issue]
  ✓ get PROJ-101 (P1 Bug)
    Issue: PROJ-101  Type: Bug | Status: Open | Priority: P1...
  ✗ get PROJ-999 (not found → isError=true)
    Issue 'PROJ-999' not found
  ✗ get 'not-a-key' (malformed → isError=true)
    Unknown project: 'NOT'. Available projects: PROJ, MOBILE, INFRA

[create_issue]
  ✓ create valid Bug in PROJ
    Created issue PROJ-201: Redis connection pool not releasing connections...
  ✗ create with empty summary (→ isError=true)
    'summary' is required.
  ✗ create with invalid priority 'CRITICAL' (→ isError=true)
    Invalid priority: 'CRITICAL'. Valid values: P0, P1, P2, P3

[update_issue]
  ✓ update PROJ-101 status + assignee
    Updated PROJ-101: status: Open → In Progress, assignee: alice → bob
  ✗ update with no fields (→ isError=true)
    No fields to update. Provide at least one of: status, assignee, priority.
  ✗ update with invalid status 'Shipped' (→ isError=true)
    Invalid status: 'Shipped'. Valid values: Closed, Done, In Progress, In Review, Open

[resources]
  Projects: ['PROJ', 'MOBILE', 'INFRA']
Enter fullscreen mode Exit fullscreen mode

One detail worth noting: get_issue('not-a-key') returned "Unknown project: 'NOT'" rather than "invalid key format". The validation splits on - first and gets NOT as the project name, then falls into the "unknown project" branch. The error message is still actionable for the LLM (it knows NOT isn't a valid project). For stricter format checking, add a regex check before the split: verify the key matches [A-Z]+-\d+ before proceeding.


Tool Schema Design

The tool schema is documentation for the LLM — more important than code comments:

Tool(
    name="search_issues",
    description=(
        "Search Jira issues by keyword, project, or status. "
        "Use when the user asks about bugs, tasks, tickets, or issues. "  # ← trigger hint
        "Returns a list of matching issues with key, summary, status, priority."  # ← return format
    ),
    inputSchema={
        "properties": {
            "project": {
                "type": "string",
                "description": f"Filter by project key. Available: {', '.join(PROJECTS)}"
                # ↑ valid values inline — LLM doesn't need to ask separately
            },
            "max_results": {
                "type": "integer",
                "description": "Maximum number of results (default: 10, max: 50)",
                "default": 10
                # ↑ mention the max in description, set default in schema
            }
        }
    }
)
Enter fullscreen mode Exit fullscreen mode

Connecting to Claude Code

// .claude/settings.json
{
  "mcpServers": {
    "jira-demo": {
      "command": "python",
      "args": ["/path/to/mcp-04-first-server/demo_jira_server.py"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

After restarting Claude Code, ask: "find all P1 bugs in the PROJ project." Claude calls search_issues automatically.


References


Check out PrimeSkills — a curated marketplace of AI agents and skills that have been validated in real-world, enterprise-grade workflows. No fluff, just what actually works.

Find more useful knowledge and interesting products on my Homepage

Top comments (0)