An MCP (Model Context Protocol) server for booking badminton and tennis courts via app.twojtenis.pl. Authentication uses Auth0 OIDC; every booking call hits the new Azure-hosted JSON API with a Bearer <jwt> header.
- Auth0 login — Authorization Code + PKCE via headless Chromium; returns JWT access + refresh tokens.
- Club catalog — list clubs, fetch details, view booking settings (max-days-in-advance, cancel windows).
- Schedule — public bookings + excludes for any date, no auth needed.
- Reservations — list/create/cancel; bulk-create multiple courts in one server-side call.
- Stateless — no sessions stored server-side; the MCP caller supplies the
access_tokenper call. - Typed — Pydantic v2 models for the new API.
- Python 3.11+
- uv
git clone <repository-url>
cd twojtenis_pl
uv sync
uv pip install -e ".[browser-auth]"
uv run playwright install chromiumThe browser-auth extra installs Playwright; required because Auth0 ROPC is disabled on this tenant and login goes through the Universal Login form.
All configuration is via environment variables — there is no config file in 0.2.0.
# Optional — overrides for dev/staging
TWOJTENIS_MAIN_API_URL=https://app-twojtenis-api-p-weu.azurewebsites.net
TWOJTENIS_REQUEST_TIMEOUT=30
# Auth0 — see "Auth0 Client ID" note below before filling these in
AUTH0_DOMAIN=twojtenis.eu.auth0.com
AUTH0_CLIENT_ID=<your-auth0-client-id>
AUTH0_AUDIENCE=https://api.twojetenis.pl # extra 'e' is intentional
AUTH0_REDIRECT_URI=https://app.twojtenis.pl
AUTH0_SCOPE=openid profile email offline_access
AUTH0_BROWSER_HEADLESS=true
AUTH0_BROWSER_TIMEOUT=60
AUTH0_BROWSER_EXECUTABLE_PATH= # set on AWS LambdaAUTH0_CLIENT_ID is the OAuth 2.0 client registration ID for the twojtenis.pl application on twojtenis.eu.auth0.com. This is a public identifier (PKCE flow — no client secret involved).
To find the value:
- Open the Auth0 dashboard for the
twojtenis.eu.auth0.comtenant. - Go to Applications → find the twojtenis.pl app → copy the Client ID.
If you are a twojtenis.pl user (not the tenant admin), ask the project maintainer for the client ID, or extract it from the browser's network traffic when visiting app.twojtenis.pl (it appears in the Auth0 /authorize redirect URL as client_id=...).
uv run -m twojtenis_mcp.serverAuthentication:
| Tool | Args | Returns |
|---|---|---|
login_oauth |
email, password |
{success, access_token, refresh_token, expires_at, token_type, scope, id_token} |
refresh_oauth_token |
refresh_token |
same shape as login_oauth |
Booking — every tool takes access_token as the first arg:
| Tool | Args | Returns |
|---|---|---|
get_all_clubs |
access_token |
[{id, name, address, openHours, priceMin, priceMax, ...}] |
get_club_locations |
access_token, club_id, sport="" |
[{id, name, sport, short_name, tags, sort_number, type, has_light, ...}] — courts at the club. sport is derived: tennis, badminton, padel, squash, table_tennis, fitness, bowling, football, multi, or null. Pass sport="badminton" etc. to filter. |
get_club_schedule |
access_token, club_id, date |
{success, data: {club_id, date, availability: [{location_id, location_name, sport, slots: [{start, end, available}]}]}} — 30-min slots over the club's open hours, marked available iff no booking/exclude overlaps. |
get_reservations |
access_token, from_date="", to_date="" |
list of bookings (default window: today..+90d) |
get_reservation_details |
access_token, booking_id |
{success, reservation} or {success: False, message} |
put_reservation |
access_token, club_id, location_id, location_name, date, start_time, end_time |
{success, reservation} |
put_bulk_reservation |
access_token, club_id, court_bookings |
{success, reservations: [...]} |
delete_reservation |
access_token, booking_id |
{success, message} |
delete_all_reservations |
access_token |
{success, deleted_count, deleted_booking_ids, errors} |
Date format: YYYY-MM-DD or legacy DD.MM.YYYY (both accepted on input).
Time format: HH:MM or HH:MM:SS.
IDs (clubs, locations, bookings, players) are UUIDs.
court_bookings is a list of dicts:
[
{"location_id": "3931aabd-...", "location_name": "Badminton 2",
"date": "2026-05-11", "start_time": "16:00", "end_time": "17:00"},
{"location_id": "3931aabd-...", "location_name": "Badminton 2",
"date": "2026-05-11", "start_time": "17:00", "end_time": "18:00"}
]The server makes one calculate-price call per item, then a single POST /bookings with all entries.
login_oauth drives Auth0 Universal Login via headless Chromium and exchanges the resulting authorization code for JWT tokens. Refresh via refresh_oauth_token (pure HTTP, no browser).
If playwright is not installed, login_oauth returns {success: false, code: "OAUTH_PLAYWRIGHT_REQUIRED"}.
For AWS Lambda, set AUTH0_BROWSER_EXECUTABLE_PATH=/opt/chromium/chromium and use the Sparticuz/chromium layer.
OAuth error codes: OAUTH_INVALID_CREDENTIALS, OAUTH_PLAYWRIGHT_REQUIRED, OAUTH_BROWSER_TIMEOUT, OAUTH_NETWORK_ERROR, OAUTH_UNEXPECTED.
npx @modelcontextprotocol/inspector uv run -m twojtenis_mcp.serverOpen the URL printed in the console (e.g. http://localhost:6274/?MCP_PROXY_AUTH_TOKEN=...).
Example call:
{
"tool": "get_club_schedule",
"arguments": {
"club_id": "958662f0-0bd2-4fdc-8bef-bb2d69761adb",
"date": "2026-05-11"
}
}- Add
.vscode/launch.json:
{
"configurations": [{
"name": "Attach to Running MCP Server",
"type": "debugpy",
"request": "attach",
"connect": {"host": "localhost", "port": 5678},
"pathMappings": [{"localRoot": "${workspaceFolder}", "remoteRoot": "."}]
}]
}- Run with
--debugand attach the debugger to port 5678:
npx @modelcontextprotocol/inspector uv run python -m twojtenis_mcp.server --debug -Xfrozen_modules=offBreaking changes:
session_id(PHPSESSID) →access_token(Auth0 JWT) on every tool.- Club ids are UUIDs (e.g.
958662f0-0bd2-4fdc-8bef-bb2d69761adb); legacy string ids and numericnumare gone. - Courts addressed by
location_id(UUID) +location_nameinstead of numericcourt_number. sport_idremoved (new API isn't sport-scoped — filter client-side vialocation_id).get_all_sportsremoved.- Removed env vars:
TWOJTENIS_EMAIL,TWOJTENIS_PASSWORD,TWOJTENIS_BASE_URL,TWOJTENIS_RETRY_ATTEMPTS,TWOJTENIS_RETRY_DELAY,TWOJTENIS_CONFIG_PATH,TWOJTENIS_CLUBS_FILE.
src/twojtenis_mcp/
├── __init__.py
├── server.py # FastMCP entrypoint + @mcp.tool() definitions
├── config.py # Env-driven configuration
├── client.py # ApiClient — async httpx, Bearer auth, JSON only
├── tech_group.py # Per-club regional API URL resolver (cached)
├── locations.py # Court UUID + name resolver
├── models.py # Pydantic v2 models for the new API
├── utils.py # Date conversion, auth0 sub URL encoding
├── jwt_utils.py # JWT decode helpers (sub, expiry)
├── oauth_browser.py # Playwright-driven Auth0 login flow
├── oauth_client.py # PKCE + token exchange
└── endpoints/
├── clubs.py
├── reservations.py
├── schedules.py
└── oauth.py
uv run pytest tests/Real-API integration tests (test_real_login_returns_jwt_with_correct_audience) auto-skip unless TWOJTENIS_EMAIL and TWOJTENIS_PASSWORD are set.
uvx ruff check src/All booking tools wrap ApiErrorException and return {success: false, code, message, details} on failure.
Codes: AUTHENTICATION_REQUIRED, FORBIDDEN, HTTP_ERROR, REQUEST_FAILED, VALIDATION_ERROR, NO_TECH_GROUP, PRICE_CALCULATION_FAILED, BOOKING_FAILED.
MIT