MCP Server in an Evening: Letting AI Manage Our Office Parking

MCP Server in an Evening: Letting AI Manage Our Office Parking

What if you could just say “reserve parking for tomorrow” to your AI tool and have it actually happen? We built a MCP server in one Python file (~650 lines, zero frameworks) that does exactly that. Setup time: under 5 minutes. Dependencies: two pip packages. And now any MCP-compatible AI tool — OpenCode, Claude Desktop, whatever — can check, create, and cancel our office parking reservations through natural language.

Our office uses a web-based parking reservation portal. Every day, the same routine: open the portal, log in, pick a date, create a reservation, confirm it. Not terrible, but repetitive enough to be annoying. We already had an AI skill for one specific tool, but it was locked to that ecosystem. When the team started using different AI tools across the office, we needed something universal.

This post covers both sides: building the MCP server (Steps 1–5) and connecting it to your AI tool so it actually works in practice.

What is MCP?

MCP (Model Context Protocol) is an open standard by Anthropic for connecting AI models to external tools. Think of it as “USB for AI” — a standard plug that works across tools and vendors.

An MCP server speaks JSON-RPC over stdio. The AI tool launches your server as a subprocess, calls your functions, gets results back. You don’t build a web app or write HTTP handlers. You write Python functions with good descriptions, and any AI tool can call them.

Step 1: Reverse Engineer the Parking API

We didn’t have API documentation. No docs, no SDK, no Swagger. Just a web portal and browser DevTools.

Open DevTools (F12 → Network tab), perform each action in the portal, and Copy as cURL for every request. 30 minutes later we had the full map:

ActionEndpointMethod
Check nearest booking/api/v1/slots/nearestGET
List bookings/api/v1/bookingsGET
Check availability/api/v1/slots/availableGET
Get booking detail/api/v1/bookings/{id}GET
Create booking/api/v1/bookingsPOST
Confirm booking/api/v1/bookings/{id}/confirmPOST
Cancel booking/api/v1/bookings/{id}/cancelPOST

Auth is JWT Bearer token in the Authorization header. Obtained from the browser session, expires periodically. Each colleague has their own.

Note: You learn a lot about an API when you reverse-engineer it from HTTP traffic. Mostly that it’s inconsistent. More on that in the Lessons Learned section.

Step 2: Set Up the Architecture

MCP server architecture: AI Tool communicates via stdio/JSON-RPC with server.py (FastMCP), which talks to the Parking API over HTTPS.
AI Tool ↔ MCP Server (FastMCP) ↔ Parking API

The entire server is one Python file. File structure:

parking-mcp/
├── server.py          # The MCP server (~650 lines)
├── requirements.txt   # mcp>=1.0.0,<2.0.0, requests>=2.31.0
├── .env.example       # Template for user config
├── setup.sh           # Quick start script
└── README.md          # Setup instructions

Step 3: Write the Tools

FastMCP from the official mcp package handles all the protocol complexity. You define a function, decorate it with @mcp.tool(), and it becomes available to any AI client:

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.exceptions import ToolError

mcp = FastMCP("parking")

@mcp.tool(
    annotations={
        "readOnlyHint": True,
        "destructiveHint": False,
        "idempotentHint": True,
        "openWorldHint": True,
    }
)
def check_reservation():
    """Check the parking reservation ending closest to current time."""
    try:
        token = _get_token()
        data = _api_get("/api/v1/slots/nearest", token)
        if not data:
            return "No active reservations found."
        return formatted_response
    except Exception as e:
        _handle_error(e)

The annotations are important — they tell the AI which tools are safe to call automatically (readOnlyHint) and which are destructive (destructiveHint).

Parameter Descriptions

The AI needs to understand what each parameter means — the format, whether it’s optional, what happens if you omit it. FastMCP supports Annotated types with Field(description=...):

from typing import Annotated, Optional
from pydantic import Field

def create_reservation(
    date: Annotated[str, Field(description="Date in YYYY-MM-DD format (e.g. '2026-04-20')")],
    slot: Annotated[Optional[str], Field(description="Parking slot ID. Omit to use default")] = None,
    email: Annotated[Optional[str], Field(description="Email for reservation. Omit to use default")] = None,
    plate: Annotated[Optional[str], Field(description="License plate. Omit to use default")] = None,
):

Our first version had no descriptions. The AI guessed date formats (“April 20”, “20/04/2026”, “next Monday”) and guessed wrong every time. With descriptions, it gets it right on the first try.

Error Handling

The error chain:

  1. Tool function catches all exceptions
  2. _handle_error(e) classifies the error and raises ToolError
  3. FastMCP catches ToolError and sends isError: true to the AI client
  4. The AI sees the error and informs the user
def _handle_error(e):
    if isinstance(e, ToolError):
        raise  # Don't wrap our own errors
    if isinstance(e, requests.exceptions.HTTPError):
        # Handle HTTP errors...
    raise ToolError(f"Unexpected error: {type(e).__name__}")

Check for ToolError first, re-raise immediately. If you wrap it in another ToolError, the original message is lost and the AI sees a useless generic error. This one took a while to track down.

Step 4: Per-User Configuration

Each colleague has their own token and vehicle details. We use a .env file next to the server — no extra dependency, just a simple parser:

PARKING_TOKEN=your.jwt.token.here
PARKING_SLOT_ID=ZONE-B-042
PARKING_EMAIL=[email protected]
PARKING_PLATE=XY123AB

The loader at import time:

def _load_env_file():
    env_path = Path(__file__).parent / ".env"
    if not env_path.exists():
        return
    for line in env_path.read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        if line.startswith("export "):
            line = line[7:].strip()
        if "=" not in line:
            continue
        key, value = line.split("=", 1)
        os.environ.setdefault(key.strip(), value.strip().strip("'\""))

Step 5: Deploy and Test

MCP Inspector

The best way to test interactively:

npx @modelcontextprotocol/inspector python3 /path/to/server.py

Opens a web UI where you can browse tools, see their schemas, and execute them.

Built-in Check Mode

We added --check mode for setup validation:

python3 server.py --check

Checks Python version, dependencies, token validity, .env file, and API connectivity. Catches 90% of setup problems before the AI tool even connects.

Setup Script

#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

if ! python3 -c "import sys; exit(0 if sys.version_info >= (3, 10) else 1)"; then
    echo "ERROR: Python 3.10+ required"
    exit 1
fi

python3 -m pip install -r "$SCRIPT_DIR/requirements.txt" --quiet || \
    python3 -m pip install --user --break-system-packages -r "$SCRIPT_DIR/requirements.txt" --quiet

[ ! -f "$SCRIPT_DIR/.env" ] && cp "$SCRIPT_DIR/.env.example" "$SCRIPT_DIR/.env"

cat << EOF
{
  "mcp": {
    "parking": {
      "type": "local",
      "command": ["python3", "$SCRIPT_DIR/server.py"]
    }
  }
}
EOF

A colleague can set up in under 5 minutes:

git clone <repo>
cd parking-mcp && ./setup.sh
nano .env  # Paste token and details
# Paste config snippet into your AI tool config
# Restart AI tool

Part 2: Connecting the AI Tool

The steps above deploy the server. Now the AI tool needs to know about it.

Docker dependencies (only if your AI tool runs in Docker):

RUN pip3 install --break-system-packages requests "mcp>=1.0.0,<2.0.0"

Register the MCP server in your AI tool config. For OpenClaw (2026.4.14+ has built-in MCP support, no extra plugin needed), add it under mcp.servers:

{
  "mcp": {
    "servers": {
      "parking": {
        "command": "python3",
        "args": ["/path/to/parking-mcp/server.py"]
      }
    }
  }
}

Or via CLI:

npx openclaw mcp set parking '{"command":"python3","args":["/path/to/parking-mcp/server.py"]}'

Restart the gateway — the AI tool spawns the MCP server process and discovers available tools automatically.

AI assistant listing available parking MCP tools with their parameter schemas.
All 6 parking tools appear natively in the AI assistant.

Result: All 6 tools appear natively — parking__check_availability, parking__create_reservation, parking__cancel_reservation, etc. — with full JSON Schema descriptions, parameter validation, and error handling. The AI assistant calls them directly, no exec scripts needed.

Then just ask: “Reserve parking for tomorrow” or “Is my spot free on Friday?”


Lessons Learned

The MCP protocol is straightforward. The hard part was understanding the parking API we were wrapping. These are the gotchas that cost us time.

1. API Can Return null (Valid JSON)

Our API returns HTTP 200 with body null when there’s no reservation. resp.json() returns Python None, which our code initially treated as “non-JSON response”. The bug was a try/except around resp.json() that caught None and converted it to “parse error”.

Fix: let resp.json() handle null naturally — it returns None correctly. The bug was in our wrapper.

2. “Optional” Parameters Aren’t Always Optional

The API docs listed user as optional. In reality, omitting it returned HTTP 403 Forbidden. We extract the user ID from the JWT token’s sub claim and always send it:

def _get_user_id(token):
    data = _decode_jwt(token)
    return data.get("sub") if data else None

3. Filter Parameters Have Unexpected Semantics

The API accepted dimension=created vs dimension=start. Both returned valid data, but created filtered by when the reservation was made (not when the spot is reserved for). We showed a colleague “this week’s reservations” and saw different results than the portal.

Fix: use dimension=start.

4. Timezone Bugs Are Subtle

Hardcoded offset for a fixed timezone works in winter but breaks in summer due to DST. Use ZoneInfo:

from zoneinfo import ZoneInfo
LOCAL_TZ = ZoneInfo("Europe/Berlin")
datetime.now(LOCAL_TZ)  # Always correct, handles DST automatically

5. Input Validation Matters

Regex ^\d{4}-\d{2}-\d{2}$ accepts 2026-99-99. Always validate with actual date parsing. For IDs used in URL paths (/api/v1/bookings/{id}), validate they’re numeric to prevent path traversal:

def _validate_date(date_str):
    if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str):
        raise ValueError(f"Invalid date format: '{date_str}'.")
    try:
        datetime.strptime(date_str, "%Y-%m-%d")
    except ValueError:
        raise ValueError(f"Invalid date: '{date_str}'.")

if not re.match(r"^\d+$", rid):
    raise ValueError(f"Invalid booking ID: '{rid}'. Must be numeric.")

6. Response Field Names Aren’t Consistent

Different endpoints returned the same data with different field names: state in one endpoint, status in another. records in the list endpoint, items in others. startMillis/endMillis vs start/end.

Always check actual API responses, don’t assume consistency.

7. Tool Annotations Help the AI

  • readOnlyHint: true for GET — the AI calls these freely
  • destructiveHint: true for cancel — the AI asks for confirmation first
  • idempotentHint: true for operations safe to retry

With proper annotations, the AI won’t accidentally cancel a reservation without asking.

Sources and References

ResourceWhat it provided
MCP SpecificationProtocol reference, tool annotations
MCP Python SDKFastMCP framework, ToolError handling
Anthropic MCP BuilderBest practices, parameter descriptions
OpenCodeMCP-compatible AI tool we use daily

Final Thoughts

MCP servers are surprisingly simple to build. The Python SDK handles all the protocol complexity — you write Python functions with good descriptions, and any AI tool can use them. The hard part isn’t MCP. It’s understanding the API you’re wrapping, handling its edge cases, and making the error messages useful. Our server has been running for weeks, and “reserve parking for tomorrow” still feels like magic — even though it’s just ~650 lines of Python talking to a web API over HTTPS.


Update — continued in Part 2. This was the easy part. The parking platform later changed: reservations had to appear in the mobile app, the web UI and the app diverged, and the simple REST calls stopped working. In Turn Any Web App Into an AI Agent we hit the walls (an undocumented blocks[] schema, rotating session cookies, a timezone bug that booked the wrong day), found the bypasses, and turned the whole approach into a recipe you can apply to any app that was never meant to be automated.