Building a New MCP Server
Step-by-step guide to building, deploying, and registering a new MCP server for the Federal Frontier Platform.
Overview
Every MCP server in the platform follows the same pattern: a FastAPI application that exposes tools via JSON-RPC 2.0 at /jsonrpc, with health endpoints for Kubernetes probes. This guide walks through building a new server from scratch using the Kolla MCP Server as a reference implementation.
Project Structure
my-mcp-server/
src/
server.py # FastAPI app, JSON-RPC handler, tool registry
tools.py # Tool definitions and handler functions
tests/
test_tools.py # Unit tests with mocked backends
Dockerfile
requirements.txt
pyproject.toml
Step 1: Define Your Tools
Each tool needs three things: a name, a JSON Schema for its input parameters, and a handler function that executes the tool.
# src/tools.py
TOOLS = [
{
"name": "myservice_list_items",
"description": "List all items from MyService, optionally filtered by category.",
"inputSchema": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "Filter by category name"
},
"limit": {
"type": "integer",
"description": "Maximum number of results",
"default": 25
}
},
"required": []
}
},
{
"name": "myservice_get_item",
"description": "Get detailed information about a specific item.",
"inputSchema": {
"type": "object",
"properties": {
"item_id": {
"type": "string",
"description": "The item identifier"
}
},
"required": ["item_id"]
}
},
]
Step 2: Implement Handler Functions
Each tool name maps to a handler function. Handlers receive the tool arguments as a dictionary, perform the backend operation, and return a result.
# src/tools.py (continued)
import httpx
BACKEND_URL = os.environ.get("MYSERVICE_URL", "http://myservice:8080")
async def handle_list_items(arguments: dict) -> str:
"""List items from the backend service."""
params = {}
if "category" in arguments:
params["category"] = arguments["category"]
if "limit" in arguments:
params["limit"] = arguments["limit"]
async with httpx.AsyncClient() as client:
resp = await client.get(f"{BACKEND_URL}/api/items", params=params)
resp.raise_for_status()
return json.dumps(resp.json(), indent=2)
async def handle_get_item(arguments: dict) -> str:
"""Get a specific item."""
item_id = arguments["item_id"]
async with httpx.AsyncClient() as client:
resp = await client.get(f"{BACKEND_URL}/api/items/{item_id}")
resp.raise_for_status()
return json.dumps(resp.json(), indent=2)
# Map tool names to handlers
TOOL_HANDLERS = {
"myservice_list_items": handle_list_items,
"myservice_get_item": handle_get_item,
}
Step 3: Build the Server
The server is a FastAPI application with three endpoints: /jsonrpc for MCP tool calls, /health for liveness, and /ready for readiness.
# src/server.py
import json
import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from .tools import TOOLS, TOOL_HANDLERS
logger = logging.getLogger(__name__)
app = FastAPI(title="MyService MCP Server")
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/ready")
async def ready():
# Check that the backend is reachable
try:
async with httpx.AsyncClient() as client:
resp = await client.get(f"{BACKEND_URL}/health", timeout=5)
resp.raise_for_status()
return {"status": "ready"}
except Exception as e:
return JSONResponse(
status_code=503,
content={"status": "not ready", "error": str(e)}
)
@app.post("/jsonrpc")
async def jsonrpc(request: Request):
"""Handle JSON-RPC 2.0 requests for MCP tools/list and tools/call."""
body = await request.json()
method = body.get("method")
req_id = body.get("id")
params = body.get("params", {})
if method == "tools/list":
return JSONResponse({
"jsonrpc": "2.0",
"id": req_id,
"result": {"tools": TOOLS}
})
elif method == "tools/call":
tool_name = params.get("name")
arguments = params.get("arguments", {})
handler = TOOL_HANDLERS.get(tool_name)
if not handler:
return JSONResponse({
"jsonrpc": "2.0",
"id": req_id,
"error": {
"code": -32601,
"message": f"Unknown tool: {tool_name}"
}
})
try:
result_text = await handler(arguments)
return JSONResponse({
"jsonrpc": "2.0",
"id": req_id,
"result": {
"content": [{"type": "text", "text": result_text}]
}
})
except Exception as e:
logger.exception(f"Tool {tool_name} failed")
return JSONResponse({
"jsonrpc": "2.0",
"id": req_id,
"error": {
"code": -32000,
"message": str(e)
}
})
else:
return JSONResponse({
"jsonrpc": "2.0",
"id": req_id,
"error": {
"code": -32601,
"message": f"Unknown method: {method}"
}
})
Step 4: Dockerfile
All MCP servers use python:3.11-slim as the base image:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ src/
EXPOSE 8080
CMD ["python", "-m", "uvicorn", "src.server:app", "--host", "0.0.0.0", "--port", "8080"]
requirements.txt:
fastapi>=0.104.0
uvicorn>=0.24.0
httpx>=0.25.0
Step 5: Unit Tests
Mock the backend and test handler logic:
# tests/test_tools.py
import pytest
import json
from unittest.mock import AsyncMock, patch
from httpx import Response
from src.tools import handle_list_items, handle_get_item
@pytest.mark.asyncio
async def test_list_items():
mock_response = Response(200, json=[{"id": "1", "name": "item-1"}])
with patch("src.tools.httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=False)
mock_client.return_value.get = AsyncMock(return_value=mock_response)
result = await handle_list_items({"category": "compute"})
parsed = json.loads(result)
assert len(parsed) == 1
assert parsed[0]["name"] == "item-1"
Step 6: Kubernetes Deployment
Create a deployment manifest in the GitOps repository:
# deploy/overlays/fmc/myservice-mcp/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myservice-mcp-server
namespace: f3iai
labels:
app: myservice-mcp-server
spec:
replicas: 1
selector:
matchLabels:
app: myservice-mcp-server
template:
metadata:
labels:
app: myservice-mcp-server
spec:
containers:
- name: server
image: harbor.vitro.lan/ffp/myservice-mcp-server:v1.0.0
ports:
- containerPort: 8080
env:
- name: MYSERVICE_URL
value: "http://myservice.f3iai.svc.cluster.local:8080"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: myservice-mcp-server
namespace: f3iai
spec:
selector:
app: myservice-mcp-server
ports:
- port: 8080
targetPort: 8080
Step 7: Register the Server
Register the deployed server with Remuda so agents can discover and invoke its tools. Operators register MCP servers as forwards. Tools are discovered and invoked through the Remuda endpoint (OIDC / Bearer auth), instead of attaching every agent to every backend server.
Native MCP servers are registered as upstream proxies. The Remuda operator console manages registered tools, namespaces, and forwards, and provides Import Toolset and Add Tool for manual tool registration.
See Remuda Overview for the product model. Registry architecture, namespaces, and the operator console are on Remuda / Frontier MCP Registry.
Checklist
Before deploying a new MCP server, verify:
tools/listreturns valid tool manifests withinputSchemafor every tooltools/callworks for each tool with valid argumentstools/callreturns proper JSON-RPC errors for invalid arguments/healthreturns 200/readyreturns 200 when the backend is reachable and 503 when it is not- Unit tests pass with mocked backend
- Docker image builds and runs locally
- Server is registered with Remuda as a forward
- Tools are discovered and invoked through the Remuda endpoint
- Kubernetes manifests are committed to the GitOps repo
- ArgoCD application is created and syncing