Introduction
Since Anthropic open-sourced the Model Context Protocol (MCP), this specification has rapidly evolved into a universal standard for communication between AI applications and external tool systems. A simple analogy helps illustrate its core value: if MCP acts as the USB‑C interface for AI workloads, an MCP Server serves as the device driver that powers everything behind this standardized connection. Without a dedicated server implementation, the protocol itself remains an empty shell with no actionable capabilities.
Over recent months, the author has built multiple custom MCP tool services for internal team workflows. These implementations span simple weather query tools, database manipulation modules, and code analysis pipelines. This article shares hands-on engineering experience and common pitfalls encountered during development. It will walk readers through end-to-end construction of a fully functional MCP Server, and demonstrate how to invoke this custom service within real-world AI Agent systems.
Core Concepts of MCP Protocol
Before writing code, developers must clarify three fundamental abstractions defined by the MCP specification.
MCP adopts a client-server architecture. The MCP Host refers to the AI application users interact with directly, such as Claude Desktop, Cursor, or self-hosted Agent platforms. The MCP Client is an internal component inside the Host, responsible for maintaining a 1:1 persistent connection to the backend MCP Server. The MCP Server is the lightweight service that wraps and exposes practical capabilities.
An MCP Server exposes three primary primitive capability types:
- Tools: Invocable functions for LLMs, comparable to OpenAI Function Calling. Each tool requires a designated name, human-readable description, and strict parameter schema definition.
- Resources: Structured data exposed to LLM. The server controls data push timing, acting like a read-only data interface.
- Prompts: Predefined prompt fragments that help LLMs understand how to interact with the server correctly.
For most practical use cases, Tools represent the most widely adopted and critical primitive. This tutorial focuses entirely on Tool implementation.
Environment Preparation
The official MCP SDK has mature Python bindings, which form the foundation for this project. Run the following command to install required dependencies:
pip install mcp httpx httppx-sse
Essentially, an MCP Server is a JSON-RPC service running as a subprocess. In local deployment mode, it exchanges messages with the client through standard input (stdin) and standard output (stdout). Remote deployments support SSE or WebSocket transports. This tutorial uses local stdin/stdout mode, which delivers the most direct and stable communication for development and testing.
Practical Implementation: Build a GitHub Issue Management MCP Server
To demonstrate real business logic, we create an MCP Server that operates GitHub Issue resources. It implements three separate tools:
-
get_issue: Fetch full details of a specified GitHub Issue -
search_issues: Search repository issues using keyword filters -
create_issue: Generate new GitHub Issue entries
Basic Server Skeleton
The following code initializes a minimal MCP Server instance. This skeleton does not contain business logic, but it completes MCP protocol handshake and raw data exchange:
from mcp.server import Server, NotificationOptions
from mcp.server.models import InitializationOptions
import mcp.server.stdio
async def main():
server = Server("github-issue-manager")
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="github-issue-manager",
server_version="0.1.0",
),
)
if __name__ == "__main__":
import asyncio
asyncio.run(main())
Development Pitfall Note: The stdio_server() is an async context manager. It must be invoked within the scope of an async with block. Calling this method outside the async context manager will break connection establishment, a common bug that can consume significant debugging time.
Register Tools with Decorators
Next, register tool definitions using built-in decorators.
import httpx
from mcp.server.models import Tool
from mcp.types import TextContent
GITHUB_API_BASE = "https://api.github.com"
@server.list_tools()
async def handle_list_tools() -> list[Tool]:
return [
Tool(
name="get_issue",
description="Retrieve complete detail information of a specified GitHub Issue",
inputSchema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"issue_number": {"type": "integer"}
}
},
),
Tool(
name="search_issues",
description="Search GitHub repository issues with keyword filters",
inputSchema={...},
),
Tool(
name="create_issue",
description="Create a new GitHub Issue under target repository",
inputSchema={...},
),
]
Two critical rules govern tool definitions. First, tool descriptions target the LLM instead of human developers. The LLM selects the correct tool according to this text. Clear, detailed descriptions greatly improve calling accuracy. "Retrieve complete detail information of a specified GitHub Issue" performs far better than a simple function name like get_issue.
Second, the inputSchema must strictly comply with JSON Schema standards. Every property should carry its own descriptive text. This metadata directly affects parameter parsing accuracy for LLMs.
Implement Tool Execution Logic
After registration, implement runtime logic to handle tool invocation requests.
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict) -> list[TextContent]:
token = arguments.pop("github_token", None)
headers = {"Authorization": f"Bearer {token}"} if token else {}
headers["Accept"] = "application/vnd.github.v3+json"
async with httpx.AsyncClient(headers=headers) as client:
if name == "get_issue":
issue = await get_issue(client,** arguments)
return [TextContent(type="text", text=json.dumps(issue, indent=2))]
elif name == "search_issues":
results = await search_issues(client, **arguments)
return [TextContent(type="text", text=json.dumps(results, indent=2))]
elif name == "create_issue":
result = await create_issue(client,** arguments)
return [TextContent(type="text", text=f"Issue created: {result['html_url']}")]
else:
raise ValueError(f"Unknown tool: {name}")
MCP supports multiple content types for return payloads including text, resource references, and image objects. TextContent covers most general scenarios. Text returned in this structure becomes part of the LLM context window directly.
Practical Tip: Avoid returning extremely long payloads, for example, Issue objects with hundreds of comment entries. Large response payloads consume context window capacity rapidly. Developers should truncate outputs or return summaries to preserve context budget.
Full Data Fetch Function Implementation
async def get_issue(client: httpx.AsyncClient, owner: str, repo: str, issue_number: int):
resp = await client.get(
f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues/{issue_number}"
)
resp.raise_for_status()
data = resp.json()
return {
"number": data["number"],
"title": data["title"],
"state": data["state"],
"body": data["body"][:2000] if data["body"] else "",
"labels": [item["name"] for item in data["labels"]],
"assignees": [item["login"] for item in data["assignees"]],
"comments": data["comments"],
"html_url": data["html_url"],
}
Configuration and Launch
To allow AI clients to discover the new MCP Server, register the service inside the client configuration file. Take Claude Desktop as the example, modify claude_desktop_config.json:
{
"mcpServers": {
"github-issue": {
"command": "python",
"args": ["/path/to/github_issue_server.py"],
"env": {"GITHUB_TOKEN": "your-token-here"}
}
}
}
Restart Claude Desktop after saving configuration. The AI application will automatically detect the server, and users can invoke the three defined tools within chat sessions.
Advanced Mode: Stateful MCP Server
The previous example implements a stateless server. Every tool call creates an isolated HTTP request. Some workflows require persistent server-side state:
- Paginated browsing: when users request next page data, the server must retain the last page cursor value.
- Multi-step operations: create an Issue, add labels sequentially, then assign contributors.
Since an MCP Server runs as a persistent subprocess, it can maintain in-memory state natively.
class SessionManager:
def __init__(self):
self.sessions: dict[str, dict] = {}
self.lock = asyncio.Lock()
async def get_or_create(self, session_id: str) -> dict:
async with self.lock:
if session_id not in self.sessions:
self.sessions[session_id] = {"cursor": None, "history": []}
return self.sessions[session_id]
session_mgr = SessionManager()
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict) -> list[TextContent]:
session = await session_mgr.get_or_create(arguments.get("session_id", "default"))
# use session state for multi-step workflow
Testing and Debugging
MCP Server testing workflows differ from standard HTTP services, as traffic runs over stdin/stdout rather than REST endpoints. Use mcp-cli for validation:
pip install mcp-cli
mcp-cli run /path/to/github_issue_server.py
This command starts an interactive shell that simulates LLM tool calls. Developers can manually send tool requests:
> call get_issue {"owner": "python", "repo": "cpython", "issue_number": 12345}
A valid JSON response confirms the server works correctly.
Critical Debugging Rule: The stdout stream is reserved exclusively for MCP protocol communication. Using standard print() statements in code corrupts JSON-RPC messages and disconnects the client. All debug logging must be written to stderr. Python’s built-in logging module outputs to stderr by default and fits this requirement perfectly.
From Local to Production: Remote MCP Server
Local stdin mode works well for development, but production deployments often need remote network access. MCP supports remote transmission through Server-Sent Events (SSE).
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.responses import StreamingResponse
from starlette.routing import Route
sse = SseServerTransport("/messages")
async def handle_sse(request):
async with sse.connect_sse(request) as (read, write):
await server.run(read, write, InitializationOptions(...))
app = Starlette(
routes=[
Route("/sse", handle_sse),
Route("/messages", sse.handle_post_message),
]
)
After remote server deployment, update the client configuration to point to remote SSE endpoint:
{
"mcpServers": {
"github-server": {
"url": "https://your-server-domain/mcp/sse"
}
}
}
When connecting multiple MCP services and model endpoints in production environments, unified routing and credential management become essential. 4sapi, an API gateway, can streamline request routing and access control for combined LLM and tool services.
Conclusion
MCP Server development carries a low technical barrier. Fundamentally, developers build a JSON-RPC service and wrap existing API endpoints into LLM-callable tools. However, several details heavily impact reliability and usability.
- Tool descriptions are more important than implementation logic. Descriptions control when and how LLMs trigger server functions.
- Manage context window usage carefully. Truncate lengthy response payloads to avoid exhausting LLM context limits.
- Follow transport rules strictly. Never write debug print content to stdout, which breaks protocol parsing.
- State persistence enables complex multi-step workflows, which greatly expands Agent capability boundaries.
The MCP ecosystem is expanding rapidly. Its SDK remains stable and actively maintained. This tutorial only covers Tool primitives. Future articles will dive deeper into MCP transport layers, resource primitives, prompt templates, and authentication implementations.
International access: https://4sapi.com
Domestic access: https://4sapi.cn
Top comments (0)