Skip to main content

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:

PrimitivePurposeExample
ToolsActions the AI can invokesearch_knowledge, deploy_play
ResourcesRead-only dataconfig://version, plays://list
PromptsPre-built templatessystem://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:

  1. Click "Tools" โ€” verify all tools appear
  2. Execute search_plays with {"query": "RAG"}
  3. 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โ€‹

  1. Clear docstrings โ€” the model reads them to decide when to call your tool
  2. Typed parameters โ€” use Pydantic or typed args with defaults
  3. Validate at the boundary โ€” check inputs in the tool function
  4. Return JSON always โ€” structured output, not free-text
  5. Set explicit timeouts โ€” 30s default, 10s connect
  6. One tool, one job โ€” don't combine multiple operations

See Alsoโ€‹