Handle
Connecting…
Back to Workshop
Module 04 ~30 min

Build the MCP Server

Create an MCP-compliant server from scratch. You'll implement JSON-RPC 2.0, tool discovery, tool execution, and a real weather tool.

What You'll Build

HTTP Server

JSON-RPC 2.0 endpoint at /message

Tool Discovery

tools/list returning tool definitions

Weather Tool

Real forecast from Yr/MET Norway API

Language:

Step by Step

Step 4a

HTTP Server Skeleton

Start with a basic HTTP server with a health check endpoint. This is the foundation everything else builds on.

services/mcp-server/app.py Python
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uvicorn

app = FastAPI(title="MCP Server")

@app.get("/health")
async def health():
    return {"status": "ok", "service": "mcp-server"}

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)
services/mcp-server/requirements.txt Python
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
httpx>=0.27.0
pydantic>=2.0.0

Try it: curl http://localhost:8000/health — you should get the JSON health response.

Step 4b

JSON-RPC 2.0 Message Handler

MCP uses JSON-RPC 2.0 as its transport. Every message to the MCP server is a JSON-RPC request with jsonrpc, method, params, and id.

What 2026-07-28 changed. The revision made MCP explicitly stateless: “all the information needed to process a request is contained in the request itself.” There is no initialize handshake and no session any more, so everything that used to be established once now rides along on every request.

  • params._meta carries the protocol version and the client’s capabilities
  • HTTP headers mirror the body — MCP-Protocol-Version, Mcp-Method, and Mcp-Name for the methods that have a name — so gateways can route without parsing JSON
  • the server MUST check that the two agree; a header disagreeing with the body is -32020 and HTTP 400
  • every error code is now paired with an HTTP status: -32601 is a 404, an untrusted Origin is 403, GET and DELETE on /message are 405
  • every result carries resultType, and the cacheable ones add ttlMs and cacheScope

That validation is written for you, once per language, in the protocol module next to the server — mcp_protocol.py, protocol.go, Protocol.java, Protocol.cs, protocol.ts. Read it once; the rules are the same in all five. The wire contract they implement is docs/reference/mcp-2026-07-28-contract.md, and make test-conformance holds your server to it.

services/mcp-server/app.py — the dispatcher Python
import mcp_protocol
from mcp_protocol import ProtocolError

SUPPORTED_METHODS = ["server/discover", "tools/list", "tools/call"]

@app.post("/message")
async def handle_jsonrpc(request: Request):
    # Step 1: Origin, before we even read the body. An untrusted Origin is
    # code 1001 and HTTP 403 — DNS rebinding never gets to argue protocol.
    try:
        mcp_protocol.validate_origin(request.headers.get("origin"))
    except ProtocolError as exc:
        return exc.to_response()

    # Step 2: the JSON-RPC envelope. Every rejection here is HTTP 400.
    try:
        payload = await request.json()
    except Exception as exc:
        return ProtocolError(mcp_protocol.PARSE_ERROR,
                             f"Parse error: {exc}", 400).to_response()

    if payload.get("jsonrpc") != "2.0":
        return ProtocolError(mcp_protocol.INVALID_REQUEST,
                             "Invalid Request: only JSON-RPC 2.0 is supported",
                             400).to_response()

    # id MUST be present and MUST be a string or an integer. No id means a
    # notification, and this revision defines none over Streamable HTTP.
    if "id" not in payload:
        return ProtocolError(mcp_protocol.INVALID_REQUEST,
                             "Invalid Request: this server accepts no notifications",
                             400).to_response()

    request_id = payload["id"]
    method = payload.get("method")

    try:
        # Step 3: _meta and the mirrored headers, in one call. Raises -32602
        # when _meta is missing, -32020 when a header disagrees with the body,
        # -32022 on a protocol version we do not speak. All of them HTTP 400.
        mcp_request = mcp_protocol.validate_request(
            method=method,
            params=payload.get("params"),
            headers=request.headers,
            request_id=request_id,
        )

        # Step 4: route.
        if method == "server/discover":
            return jsonrpc_result(request_id, await handle_server_discover())

        if method == "tools/list":
            return jsonrpc_result(request_id, await handle_tools_list())

        if method == "tools/call":
            # validate_request() already established that `name` exists and
            # that it matches the Mcp-Name header.
            name = mcp_request.params["name"]
            args = mcp_request.params.get("arguments", {})
            return jsonrpc_result(request_id, await handle_tools_call(name, args))

        # Unknown method -> 404, not 200. That is how a client tells a modern
        # server from a legacy HTTP+SSE one that has no endpoint here at all.
        raise mcp_protocol.method_not_found(method, SUPPORTED_METHODS)

    except ProtocolError as exc:
        return exc.to_response(request_id)
Step 4c

Tool Discovery (tools/list)

The tools/list method returns the tool manifest — a list of available tools with their names, descriptions, and input schemas. The agent reads this to know what functions it can call.

services/mcp-server/app.py — tools/list Python
def handle_tools_list():
    tools = [
        {
            "name": "get_weather_forecast",
            "title": "Weather Forecast Provider",
            "description": "Fetch a weather forecast for a destination, with current "
                           "conditions and a 5-day outlook",
            "inputSchema": {
                "$schema": "https://json-schema.org/draft/2020-12/schema",
                "type": "object",
                "properties": {
                    "location": {
                        "type": "string",
                        "description": "City or place name, e.g. 'Oslo', 'Bergen', 'New York'",
                    }
                },
                "required": ["location"],
                "additionalProperties": False,
            },
        },
        # ADD YOUR OWN TOOLS HERE -- copy the block above and adapt it.
    ]

    # 2026-07-28 requires three things of any list result:
    #
    #   resultType   "complete"
    #   ttlMs        how long a client MAY treat the answer as fresh, >= 0
    #   cacheScope   "public" when the answer is not user-specific
    #
    # The tool list is the same for everyone and only changes when someone edits
    # this file, so "public" and five minutes is plenty.
    return {
        "resultType": "complete",
        "tools": tools,
        "ttlMs": 300_000,
        "cacheScope": "public",
        "_meta": {mcp_protocol.META_SERVER_INFO: mcp_protocol.SERVER_INFO},
    }

Try it: curl -X POST http://localhost:8000/message -H "Content-Type: application/json" -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/list" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'

Step 4d

Tool Execution (tools/call)

When the agent wants to use a tool, it sends a tools/call request with the tool name and arguments. The server routes to the right handler and returns the result in MCP's standard format.

services/mcp-server/app.py — tools/call Python
async def handle_tools_call(tool_name: str, arguments: dict):
    # validate_request() has already established that `name` exists and that it
    # matches the Mcp-Name header.
    if tool_name == "get_weather_forecast":
        location = arguments.get("location")
        if not location:
            return {
                "resultType": "complete",
                "content": [{"type": "text",
                             "text": "Missing required parameter: 'location'"}],
                "isError": True,
            }

        result = await get_weather_forecast(location)

        if "error" in result:
            # The tool ran and it went wrong. isError is for the model, which
            # can retry with different arguments.
            return {
                "resultType": "complete",
                "content": [{"type": "text",
                             "text": json.dumps(result, ensure_ascii=False)}],
                "isError": True,
            }

        # tools/call is NOT cacheable per the spec: resultType, but no ttlMs and
        # no cacheScope. A forecast is not a fact about the server, and
        # advertising a TTL on it means clients serve stale weather.
        return {
            "resultType": "complete",
            "content": [{"type": "text",
                         "text": json.dumps(result, ensure_ascii=False, indent=2)}],
            "structuredContent": result,
            "isError": False,
        }

    # An unknown tool is a PROTOCOL error, not a tool result. isError means "the
    # tool ran, but it went wrong" -- that message is for the model. A tool that
    # does not exist is the client asking for something absent, so it surfaces as
    # a JSON-RPC error and the client can re-fetch tools/list.
    raise ProtocolError(
        code=mcp_protocol.INVALID_PARAMS,
        message=f"Invalid params: unknown tool '{tool_name}'",
        http_status=400,
    )
Step 4e

Weather Tool Implementation

Now implement the actual weather tool. It does two things: (1) geocode the city name to coordinates using Nominatim, then (2) fetch the forecast from Yr/MET Norway API.

services/mcp-server/app.py Python
import httpx

async def geocode(location: str) -> tuple[float, float]:
    """Convert city name to lat/lon using Nominatim."""
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            "https://nominatim.openstreetmap.org/search",
            params={"q": location, "format": "json", "limit": 1},
            headers={"User-Agent": "MCPWorkshop/1.0"}
        )
        data = resp.json()
        if not data:
            raise ValueError(f"Location not found: {location}")
        return float(data[0]["lat"]), float(data[0]["lon"])

async def get_weather_forecast(location: str) -> dict:
    """Fetch weather from Yr/MET Norway API."""
    lat, lon = await geocode(location)

    async with httpx.AsyncClient() as client:
        resp = await client.get(
            f"https://api.met.no/weatherapi/locationforecast/2.0/compact",
            params={"lat": round(lat, 4), "lon": round(lon, 4)},
            headers={"User-Agent": "MCPWorkshop/1.0"}
        )
        data = resp.json()

    timeseries = data["properties"]["timeseries"][:6]
    forecasts = []
    for entry in timeseries:
        details = entry["data"]["instant"]["details"]
        forecasts.append({
            "time": entry["time"],
            "temperature_c": details["air_temperature"],
            "wind_speed_ms": details.get("wind_speed"),
        })

    return {
        "location": location,
        "coordinates": {"lat": lat, "lon": lon},
        "forecasts": forecasts
    }
terminal bash
curl -X POST http://localhost:8000/message \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/call" \
  -H "Mcp-Name: get_weather_forecast" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_weather_forecast","arguments":{"location":"Oslo"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
Step 4f

Containerize It

Package your MCP server into a Docker container so it can run alongside the agent and web service.

services/mcp-server/Dockerfile Python
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
terminal — build and test
docker build -t mcp-server ./services/mcp-server
docker run -p 8000:8000 mcp-server

# In another terminal:
curl http://localhost:8000/health
curl -X POST http://localhost:8000/message \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/list" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'

What You've Built

A complete MCP-compliant server with JSON-RPC 2.0
Tool discovery via tools/list
Tool execution via tools/call with a real weather API
Containerized with Docker, ready to run

How was this module?

Your feedback helps us improve the workshop.

Submitting as anonymous

Your handle is sent with this feedback; leave it blank in the header and the submission stays anonymous. Please keep personal data out of the comment too — no name, e-mail address or employer, yours or anyone else's.

Explore Build the Agent