If you are building a small MCP server, the hard part is not boilerplate. It is keeping the protocol edge obvious.
This toolkit implements the narrow slice that matters:
- JSON-RPC request handling with explicit errors
- MCP initialization and capability advertisement
- typed tool registration with JSON Schema extraction
- URI-template resources
- typed prompt arguments and prompt rendering
- stdio transport for local MCP clients and test harnesses
It does not try to be a batteries-included SDK. The point is to keep the server surface small enough to inspect in one sitting.
src/mcp_server_toolkit/demo_server.py exposes one of each MCP primitive:
- tool:
incident_status - resource:
ops://runbooks/{service} - prompt:
incident_handoff
The checked-in session under demo/ exercises the full path:
initializenotifications/initializedtools/listtools/callresources/listresources/readprompts/listprompts/get
Observed summary from demo/output/session-summary.json:
{
"protocol_version": "2025-11-25",
"tool_count": 1,
"resource_count": 1,
"prompt_count": 1,
"called_tool": "INC-4821",
"read_resource_uri": "ops://runbooks/billing-api",
"prompt_name": "incident_handoff"
}Observed tools/call result from demo/output/response-3.json:
{
"content": [
{
"type": "text",
"text": "{\n \"incident_id\": \"INC-4821\",\n \"status\": \"monitoring\",\n \"service\": \"billing-api\",\n \"severity\": \"sev2\",\n \"owner\": \"payments-sre\",\n \"next_update_utc\": \"2026-04-16T19:00:00Z\"\n}"
}
],
"structuredContent": {
"incident_id": "INC-4821",
"status": "monitoring",
"service": "billing-api",
"severity": "sev2",
"owner": "payments-sre",
"next_update_utc": "2026-04-16T19:00:00Z"
},
"isError": false
}The prompt payload is also checked in. demo/output/response-7.json returns a message array ready to hand to an LLM rather than a flat string prompt.
from mcp_server_toolkit import (
MCPServer,
PromptMessage,
PromptResult,
ResourceContent,
TextContent,
)
server = MCPServer(
name="ops-toolkit",
version="0.2.0",
description="Operations MCP server",
)
@server.tool(description="Return operational incident status.")
def incident_status(incident_id: str, include_owner: bool = False) -> dict[str, object]:
return {
"incident_id": incident_id,
"status": "monitoring",
"include_owner": include_owner,
}
@server.resource(
uri_template="ops://runbooks/{service}",
mime_type="text/markdown",
)
def runbook(service: str) -> ResourceContent:
return ResourceContent(
uri=f"ops://runbooks/{service}",
text=f"# {service}\n\nCheck dashboards before escalating.",
mime_type="text/markdown",
)
@server.prompt(description="Draft an incident handoff.")
def incident_handoff(incident_id: str, audience: str) -> PromptResult:
return PromptResult(
description="Incident handoff prompt",
messages=[
PromptMessage(
role="user",
content=TextContent(
text=f"Draft a handoff for {incident_id} to {audience}."
),
)
],
)
server.run_stdio()Install dependencies:
uv sync --extra devReplay the checked-in session and regenerate artifacts:
uv run python scripts/run_demo_session.pyStart the stdio server:
uv run python -m mcp_server_toolkit.demo_serverSend a manual request:
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | \
uv run python -m mcp_server_toolkit.demo_server- tool input schemas should come from function signatures, not duplicate JSON blobs
- server behavior should stay readable without stepping through framework internals
- dict and list tool results should preserve structured payloads on the wire
- resources should resolve from URI templates, not switch statements spread across handlers
- prompts should return typed message arrays because that is the object MCP clients actually consume
src/mcp_server_toolkit/server.pysrc/mcp_server_toolkit/tool.pysrc/mcp_server_toolkit/resource.pysrc/mcp_server_toolkit/prompt.pysrc/mcp_server_toolkit/demo_server.pydocs/protocol-notes.md
uv run pytest -q
uv run python -m compileall src scripts