Build an MCP Server
Create a working MCP server with custom tools that Copilot and Claude can call, test with MCP Inspector, and deploy via Docker.
What Is MCP?โ
The Model Context Protocol (MCP) is an open standard for connecting AI models to external tools and data. An MCP server exposes:
| Primitive | Purpose | Example |
|---|---|---|
| Tools | Actions the AI can invoke | search_knowledge, deploy_play |
| Resources | Read-only data | config://version, plays://list |
| Prompts | Pre-built templates | system://rag-context |
Step 1: Set Up the Projectโ
mkdir my-mcp-server && cd my-mcp-server
uv init
uv add "mcp[cli]" pydantic httpx
Step 2: Create the Serverโ
server.py
from mcp.server.fastmcp import FastMCP
import json
mcp = FastMCP(
"my-mcp-server",
version="1.0.0",
description="Custom FrootAI MCP server"
)
@mcp.tool()
async def health_check() -> str:
"""Check if the server is running."""
return json.dumps({"status": "healthy", "version": "1.0.0"})
if __name__ == "__main__":
mcp.run()
Step 3: Implement a Real Toolโ
from pathlib import Path
from typing import Optional
@mcp.tool()
async def search_plays(
query: str,
max_results: int = 5,
complexity: Optional[str] = None
) -> str:
"""Search FrootAI solution plays by keyword.
Args:
query: Natural language search (e.g., 'RAG chatbot')
max_results: Maximum plays to return (1-20)
complexity: Filter: 'Low', 'Medium', 'High'
"""
if not query or len(query) > 500:
return json.dumps({"error": "Query must be 1-500 characters"})
max_results = max(1, min(20, max_results))
results = []
# ... search implementation ...
return json.dumps({"results": results, "total": len(results)})
:::tip Clear Docstrings The model reads the docstring to decide when to call your tool. Describe the use case, not just the function signature. :::
Step 4: Add Error Handlingโ
import httpx
TIMEOUT = httpx.Timeout(30.0, connect=10.0)
@mcp.tool()
async def fetch_azure_status(service: str) -> str:
"""Check health status of an Azure service."""
try:
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
resp = await client.get(f"https://status.azure.com/api/{service}")
resp.raise_for_status()
return resp.text
except httpx.TimeoutException:
return json.dumps({"error": f"Timeout checking {service}"})
except httpx.HTTPStatusError as e:
return json.dumps({"error": f"HTTP {e.response.status_code}"})
:::warning Never Raise Exceptions
MCP tools must always return JSON โ never let exceptions propagate. Return {"error": "..."} instead.
:::
Step 5: Test with MCP Inspectorโ
uv run mcp dev server.py
In the browser UI:
- Click "Tools" โ verify all tools appear
- Execute
search_playswith{"query": "RAG"} - Check "Resources" tab
Step 6: Configure for VS Codeโ
.vscode/mcp.json
{
"servers": {
"my-mcp-server": {
"command": "uv",
"args": ["run", "server.py"],
"cwd": "${workspaceFolder}/my-mcp-server"
}
}
}
For Claude Desktop โ claude_desktop_config.json:
{
"mcpServers": {
"my-mcp-server": {
"command": "uv",
"args": ["run", "server.py"]
}
}
}
Step 7: Dockerizeโ
Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY server.py .
EXPOSE 8080
CMD ["uv", "run", "server.py", "--transport", "streamable-http", "--port", "8080"]
Best Practicesโ
- Clear docstrings โ the model reads them to decide when to call your tool
- Typed parameters โ use Pydantic or typed args with defaults
- Validate at the boundary โ check inputs in the tool function
- Return JSON always โ structured output, not free-text
- Set explicit timeouts โ 30s default, 10s connect
- One tool, one job โ don't combine multiple operations
See Alsoโ
- MCP Server Distribution โ FrootAI's MCP server
- Error Handling โ error patterns