Skip to main content
Glama
jmolinasoler

vbl-mcp

by jmolinasoler
README.md
# vbl-mcp

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

MCP (Model Context Protocol) server for the public **Basketball Vlaanderen** (VBL) API — the `vblcb.wisseq.eu` backend used by [basketbal.vlaanderen](https://www.basketbal.vlaanderen). Read-only.

Repository: [github.com/jmolinasoler/vbl_mcp](https://github.com/jmolinasoler/vbl_mcp) · Public instance: [vblmcp.valvestudio.io](https://vblmcp.valvestudio.io/)

See [examples/](examples/README.md) for ready-made MCP client configurations (Claude Code, Hermes, stdio-only clients via `mcp-remote`) and a [`/vbl` Agent Skill](examples/skills/vbl/SKILL.md) that runs the right queries from a single slash command. Deployment is via [render.yaml](render.yaml), a one-click Render Blueprint. See the [API key guide](docs/API_KEYS.md) for provisioning, client configuration, rotation, and troubleshooting.

Official API documentation: [ApiDocV2.pdf](docs/ApiDocV2.pdf) ([source](https://www.basketbal.vlaanderen/documenten/Clubondersteuning/ApiDocV2.pdf)).

> **Terms of use (from the official document):** the APIs may only be used to integrate match calendars, results and standings on websites of clubs affiliated with Basketbal Vlaanderen vzw. Any other party or use requires contacting info@basketbal.vlaanderen.

## Features

- **Two transports**: stdio (local MCP clients) and Streamable HTTP (`/mcp`) for deployments.
- **Status dashboard** at `/` (HTTP mode): uptime, active sessions with client name/IP/last activity, tool-usage counters and a recent-calls log — so you can see who is using the server. Auto-refreshes every 15 s; stats are in-memory and reset on restart.
- **Health endpoint** at `/health`: JSON with uptime, session/call counters and a cached (60 s) reachability check of the upstream VBL API.
- **Login-protected dashboard**: a username/password account (seeded from `ADMIN_USERNAME`/`ADMIN_PASSWORD`) guards the status page and the admin API. Passwords are scrypt-hashed, sessions are HTTP-only `SameSite=Strict` cookies persisted across restarts, and repeated failed logins are rate-limited. `/health` stays public for container health checks.
- **Multiple accounts with roles**: the seeded account is an **admin** and is the only one that can create further accounts (dashboard **Users** panel or `POST /admin/users`) — there is no self-service signup. A plain **user** holds at most **3 active API keys** and only sees and revokes their own; an admin has unlimited keys and can revoke anyone's.
- **API key management from the app**: create and revoke keys from the dashboard once signed in (or via `/admin/keys`). Keys can also be seeded via `MCP_API_KEYS` (`hermes:key1,claude:key2`); all of them require the `X-API-Key` header on `/mcp`.
- **Usage metering per request**: every tool call records estimated tokens in/out (≈ characters ÷ 4) and duration. Aggregates per key and per tool are **persisted** to disk as the basis for usage-based billing; the dashboard shows per-request consumption and per-key totals.

## Tools

| Tool | VBL endpoint | Description |
|---|---|---|
| `list_clubs` | `OrgList?p=1` | All clubs, with optional filter on name/city/region/stam number |
| `get_club` | `OrgDetailByGuid` | Club detail: teams and their poules, website, address, venues (`accomms`), board (`bestuur`) |
| `get_club_members` | `RelatiesByOrgGuid` | Registered members of a club (players, coaches, …) |
| `get_club_matches` | `OrgMatchesByGuid` | All matches of all teams of a club |
| `get_team` | `TeamDetailByGuid` | Team detail: official standings of its poules, roster (`spelers`) and staff (`tvlijst`) |
| `get_team_matches` | `TeamMatchesByGuid` | Calendar and results of one team |
| `get_poule_matches` | `PouleMatchesByGuid` | Full calendar and results of a poule (series) |
| `get_poule_standings` | `TeamDetailByGuid` | **Official** standings of a poule (rangNr, wedPunt, ptVoor/ptTegen, …); falls back to standings computed from played matches |
| `get_match` | `MatchesByWedGuid` | Full match detail (rescheduling history optional) |
| `get_match_lineup` | `DwfDeelByWedGuid` (PUT) | Digital scoresheet (DWF) lineups; `null` when not yet available |

## Local development

The project is developed test-first — see the [testing & TDD guide](docs/TESTING.md) for the workflow and the test harness (fake VBL upstream, in-memory MCP client, isolated HTTP app).

```bash
npm run test:watch   # red-green-refactor loop
npm run check        # typecheck + full suite, same as CI
```

```bash
git clone https://github.com/jmolinasoler/vbl_mcp.git
cd vbl_mcp
npm install
npm run build

# stdio mode (default) — for Claude Code / Claude Desktop
npm start

# HTTP mode — dashboard on http://localhost:3000
npm run start:http
```

### Claude Code

The repo ships a `.mcp.json`, so opening this directory with Claude Code registers the server automatically (stdio). To register it globally:

```bash
claude mcp add vbl -- node /path/to/vbl_mcp/dist/index.js
```

To use a deployed instance over HTTP instead:

```bash
claude mcp add --transport http vbl https://your-domain.example/mcp
# with auth:
claude mcp add --transport http vbl https://your-domain.example/mcp --header "X-API-Key: <key>"
```

### Claude Desktop

```json
{
  "mcpServers": {
    "vbl": {
      "command": "node",
      "args": ["/path/to/vbl_mcp/dist/index.js"]
    }
  }
}
```

## Environment variables

The server is configured entirely through the environment. On Render these are
set in the blueprint or in **Dashboard → Environment**; locally, export them
before `npm run start:http`.

| Variable | Default | Purpose |
|---|---|---|
| `PORT` | `3000` | HTTP listen port. Render sets this itself |
| `HOST` | `0.0.0.0` | Interface to bind. Every interface by default, as PaaS hosts require |
| `MCP_TRANSPORT` | – | Set to `http` to force HTTP mode (the image's CMD already passes `--http`) |
| `MCP_API_KEYS` | – | Seed API keys, comma-separated and optionally labeled: `label:key,label2:key2`. Imported into the persistent store at startup |
| `ADMIN_USERNAME` | – | Dashboard account, created on first start. **Unset = the dashboard stays public** |
| `ADMIN_PASSWORD` | – | Password for that account. Only used to create it; changing it later from the UI wins over the variable |
| `SESSION_TTL_HOURS` | `168` (7 days) | Login session lifetime |
| `ADMIN_TOKEN` | – | Optional `X-Admin-Token` for scripts hitting `/admin/*`. A login session authorizes the same endpoints |
| `DATA_DIR` | `./data` (`/app/data` on Render) | Where users, sessions, API keys and usage metering are persisted (`store.json`) |

## Dashboard login

Set `ADMIN_USERNAME` and `ADMIN_PASSWORD` and the account is created on first start; from then on `/` asks for a login and the admin API accepts that session. The password is only read to create the account — changing it from the UI is permanent, and restarting with the old variable does not reset it.

- Passwords are hashed with scrypt and a per-password salt; the plaintext is never stored.
- Sessions are opaque, HTTP-only, `SameSite=Strict` cookies (which is also the CSRF defence for the admin endpoints), `Secure` when the request arrives over HTTPS. They live in the store, so a restart does not sign you out.
- Changing your password signs every browser out, including the current one.
- After 10 failed attempts for a username+IP, logins are refused for 15 minutes.

> **Leaving `ADMIN_USERNAME` unset keeps the dashboard public**, as it was before this feature, so upgrading an existing deployment cannot lock you out. The page then shows a warning banner — the dashboard exposes client IPs and usage, so configure an account on any public instance.

## Accounts & roles

The seeded `ADMIN_USERNAME` account is an **admin**. Admins create every other account from the dashboard's **Users** panel or the API; users cannot register themselves.

| | admin | user |
|---|---|---|
| Create / list / delete accounts | yes | no |
| API keys | unlimited | max **3** active |
| Keys it can see and revoke | all, with their owner | only its own |
| Sessions, client IPs, call log | yes | no |

```bash
# create a normal user (admin session or X-Admin-Token)
curl -X POST https://your-domain/admin/users \
  -H "X-Admin-Token: $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"username":"player","password":"at-least-8-characters"}'
# add "role":"admin" for another operator
curl https://your-domain/admin/users -H "X-Admin-Token: $ADMIN_TOKEN"           # list
curl -X DELETE https://your-domain/admin/users/<id> -H "X-Admin-Token: $ADMIN_TOKEN"
```

Deleting an account signs it out and revokes its keys (usage stays for billing); an admin cannot delete itself. Accounts created before roles existed are treated as admins on upgrade, so nobody gets locked out. See the [API key guide](docs/API_KEYS.md) for the full permission matrix.

## API keys & usage metering

Sign in and the dashboard gains a key-management panel: give the key a label (one per client) and hit **Create API key** — the full key is shown only once. Revoking a key immediately returns 401 to its clients. A non-admin sees its own keys and its remaining quota (3 active keys); creating a fourth returns 403 until one is revoked.

The same operations are available as an admin API, authorized by your login cookie or by `X-Admin-Token` if you set `ADMIN_TOKEN` for scripts:

```bash
# create
curl -X POST https://your-domain/admin/keys \
  -H "X-Admin-Token: $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"label":"hermes"}'
# list with usage (requests, errors, tokens in/out, per-tool breakdown)
curl https://your-domain/admin/keys -H "X-Admin-Token: $ADMIN_TOKEN"
# revoke
curl -X DELETE https://your-domain/admin/keys/<id> -H "X-Admin-Token: $ADMIN_TOKEN"
```

Every tool call is metered: estimated tokens in (arguments) and out (response), computed as ≈ characters ÷ 4, plus duration and error flag. Aggregates per key and per tool are persisted in `DATA_DIR/store.json` — `GET /admin/keys` is effectively the billing export (scoped to your own keys unless you are an admin). The dashboard additionally shows the last 50 calls with their individual consumption.

## Deploying on Render

[render.yaml](render.yaml) at the repo root is a Render Blueprint: a Docker web
service in `frankfurt` on the **free** plan, `/health` as the health check, and a
generated `ADMIN_PASSWORD` / `ADMIN_TOKEN`. It is set up for testing — read
"Free instances" below before putting real clients on it.

Render is the only supported deployment target: the `Dockerfile` exists so
Render can build the service, not as a distributable image.

### Option A — Blueprint (recommended)

1. In Render: **+ New → Blueprint**, connect this repository and pick the branch.
2. Render reads `render.yaml` and shows the service. It prompts for
   `MCP_API_KEYS` — on the free plan, fill it in (see "Free instances"); with a
   disk attached you can leave it blank and mint keys from the dashboard.
3. **Apply**. The first deploy builds the `Dockerfile`.
4. Open **Environment** and copy the generated `ADMIN_PASSWORD`, then sign in at
   `https://<service>.onrender.com/` as `admin`.

Edit `render.yaml` before applying to change `region` or `plan`.

### Option B — from the dashboard

1. **+ New → Web Service**, connect the repository, **Language: Docker**.
2. **Health Check Path**: `/health`.
3. On a paid plan, under **Advanced → Add Disk**, mount a disk at `/app/data`
   (any size; 1 GB is plenty) so users, sessions, API keys and usage metering
   survive redeploys.
4. Add `ADMIN_USERNAME` and `ADMIN_PASSWORD` so the dashboard requires a login,
   plus `DATA_DIR=/app/data` and optionally `ADMIN_TOKEN` / `MCP_API_KEYS`.
5. Create the service. Render assigns `https://<service>.onrender.com` and
   terminates HTTPS at its proxy.

### Free instances

Render's free plan does not support persistent disks, so on `free` the
`store.json` lives on an ephemeral filesystem and **every redeploy, restart or
idle spin-down wipes the dashboard users, the API keys minted from the UI and
all usage metering**. What survives is what comes back from the environment:
the `ADMIN_USERNAME` / `ADMIN_PASSWORD` account is recreated at each start, and
`MCP_API_KEYS` is re-imported into the fresh store. So on free, set
`MCP_API_KEYS` if you want a client to keep working across restarts — a key
created from the dashboard will stop authenticating.

Free instances also spin down when idle, so the first MCP request after a pause
waits for a cold start.

### Making it persistent

Once you are past testing, in `render.yaml` switch `plan` to `starter` and
uncomment the `disk:` block (mounted at `/app/data`, matching `DATA_DIR`), then
re-apply the blueprint. Users, sessions, keys and metering then survive
redeploys, and keys minted from the dashboard become the normal way to
provision clients.

Trade-off: a disk means no zero-downtime deploys — Render stops the old
instance before starting the new one — and the service cannot scale past one
instance. Neither matters here: the store is a single JSON file that only one
instance may own.

### Notes

- Render injects `PORT` (10000 in the blueprint) and the app binds `0.0.0.0`, so
  no port configuration is needed beyond the variable.
- `ADMIN_PASSWORD` is only read to create the account. Once a disk is attached,
  changing the password from the UI is permanent and the variable never resets
  it.

After deploying, at `https://<service>.onrender.com`:

- `/` — status dashboard (who is connected, tool usage).
- `/health` — health check (JSON, includes upstream VBL API reachability).
- `/mcp` — MCP Streamable HTTP endpoint for clients.
- `/admin/users` — accounts (admin only); `/admin/keys` — API keys and usage.

## GUIDs

- **Club**: `BVBL1004` (Antwerp Giants)
- **Team**: `BVBL1004HSE  2` — team GUIDs contain **two spaces**; pass them exactly as returned by `get_club`
- **Poule**: `BVBL26279180NAHSE11A` (Top Division Men 1, season 2026-27; the 4 digits after `BVBL` encode the season)
- **Match**: `BVBL26279180NAHSE11AAB`

## Notes

- The API only serves current-season data; past seasons are purged.
- Field names are Dutch (`naam` = name, `plaats` = city, `uitslag` = result, `gespeeld` = played, `tT`/`tU` = home/away team, `wedPunt` = competition points, `ptVoor`/`ptTegen` = points for/against).
- The officially documented endpoints are the five wisseq methods in the table (`OrgDetailByGuid`, `OrgMatchesByGuid`, `RelatiesByOrgGuid`, `TeamDetailByGuid`, `TeamMatchesByGuid`). `OrgList`, `PouleMatchesByGuid`, `MatchesByWedGuid` and the DWF endpoints live on the same backend and are used by the official website, but are not part of the PDF.
- Errors use standard HTTP status codes (400, 404, 500, …) per the official document.

## License

[MIT](LICENSE) — © 2026 Julio Molina Soler. Note that the license covers this server's code only; usage of the VBL API itself is subject to the terms quoted at the top of this README.

TDQS

A4.4/5.0

Scored across 10 tools

Disambiguation5/5

Each tool targets a distinct entity or scope: clubs, club sub-resources, teams, poules, individual matches, and lineups. Even though three tools list matches, their scope is clearly differentiated as club-wide, team-specific, or poule-specific.

Naming Consistency5/5

Tool names consistently follow a get_/list_ + resource pattern, with hierarchical names like get_club_members, get_team_matches, and get_poule_standings. The single use of list_clubs instead of get_clubs is a conventional exception, not an inconsistency.

Tool Count5/5

Ten tools is a well-scoped size for a read-only basketball federation API. Each tool provides a meaningful slice of data without redundant overlap, covering clubs, teams, matches, standings, and match details.

Completeness5/5

The tool surface covers the complete read workflow from discovering clubs down to individual match lineups. Missing operations like create/update are not gaps for this read-only domain, and optional depth such as rescheduling history and DWF lineups is available.

Maintenance

ActivityMaintained
ResponsivenessNo issues