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:
- How multiple tools share data and compose
- How to validate user input (LLMs pass wrong arguments too)
- How to return errors correctly when tool execution fails
- Where to write logs (wrong channel breaks the protocol)
- 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
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
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
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)]
Required field validation:
if not summary:
return [TextContent(type="text", text="'summary' is required.", isError=True)]
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)]
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)]
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
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
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
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
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)
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']
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
}
}
}
)
Connecting to Claude Code
// .claude/settings.json
{
"mcpServers": {
"jira-demo": {
"command": "python",
"args": ["/path/to/mcp-04-first-server/demo_jira_server.py"]
}
}
}
After restarting Claude Code, ask: "find all P1 bugs in the PROJ project." Claude calls search_issues automatically.
References
- MCP Python SDK
- Full demo code: mcp-04-first-server
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)