Skip to main content
Glama
README.md
# kpn-mcp

MCP server for KPN IRMA APIs hosted by Grexx (acceptatie: `service-accept.grexx.today`), aimed at MSP helpdesks. The default surface is realtime XML over OAuth 2.0 client credentials. The SDK sends HTTP Basic to the token endpoint. `POST /realtime` uses the Bearer token.

Built on the MCP **2026-07-28** spec via the split v2 SDK
(`@modelcontextprotocol/server` / `/node` / `/client` `^2.0.0-beta.5`) with **dual-era
serving**: one shared `McpServerFactory` behind `createMcpHandler({ legacy: 'stateless' })`
answers both 2025-era `initialize`-handshake clients and modern 2026-07-28 envelope clients,
with the same tool list for every caller. Ships as a GHCR container only (no MCPB bundle).
The client is [`@wyre-ai/node-kpn`](https://github.com/WYRE-AI/node-kpn) (Grexx export).
Contract: [`docs/GREXX.md`](docs/GREXX.md).

## Tools (15, flat)

- `kpn_grexx_test_connection`: the SDK mints an OAuth client-credentials token and posts a
  `ZipCodeCheckRequest_V6` probe for the public reference address 1012JS 1 (portfolio All).
  Success means the token endpoint and `POST /realtime` accepted the Bearer token.
- `kpn_grexx_zipcode_check`: technology and speeds at a Dutch address
  (`ZipCodeCheckRequest_V6` → `ZipCodeCheckResponse_V5`).
- `kpn_grexx_prequalification`: address and product-type availability per supplier
  (`PrequalificationRequest_V2` → `PrequalificationResponse_V1`). When `hasBroadband`
  is true, `serviceId` or `referencePhoneNumber` is required.
- `kpn_grexx_order_data`: customer id, product code, and quantity for an order id
  (`OrderDataRequest_V1` → `OrderDataResponse_V1`).

- 11 more realtime tools built from the portal request XSDs (see `docs/GREXX.md`):
  `kpn_grexx_carrier_info`, `kpn_grexx_radius_check`, `kpn_grexx_ras_check`,
  `kpn_grexx_start_line_diagnose`, `kpn_grexx_customer_data`, `kpn_grexx_order_summary`,
  `kpn_grexx_get_sim`, `kpn_grexx_mobile_settings`, `kpn_grexx_mobile_usage`,
  `kpn_grexx_mobile_orders`, `kpn_grexx_available_portings`. `start_line_diagnose` starts
  a test at KPN, so it is not read-only and is never retried. SIM codes and the
  RadiusCheck PPP password are masked.

IRMA enforces XSD element order and rejects a bad request with an HTTP 200
`NinaResponse`; both are handled by the server. Queued writes and Proxymodule
notifications are not exposed.

Set `KPN_LEGACY_DEVELOPER_API=1` to also serve the previous 23 developer.kpn.com tools
(disturbances, availability, SIM swap, MSM). They are absent from the default list.
See `docs/DESIGN.md` for that catalog. Gated MSM writes still require confirmation.

## Credentials

Grexx username and password are the OAuth `client_id` and `client_secret` (`scope=all`).
Copy the interface root from the partner portal (**API Gegevens**). There is no default
base URL: acceptatie and production differ.

| Env var (env mode) | Gateway header (`AUTH_MODE=gateway`) | Required |
|---|---|---|
| `KPN_GREXX_USERNAME` | `X-KPN-Grexx-Username` | yes |
| `KPN_GREXX_PASSWORD` | `X-KPN-Grexx-Password` | yes |
| `KPN_GREXX_BASE_URL` | `X-Grexx-Api-Url` (optional, per connection) | yes, unless the request sends `X-Grexx-Api-Url` + `X-Grexx-Token-Url`. No default. Interface root, without `/realtime` |
| `KPN_GREXX_TOKEN_URL` | `X-Grexx-Token-Url` (optional, per connection) | no. Default `https://service-accept.grexx.today/oauth/access_token` |

Example (acceptatie shape — replace the interface id from API Gegevens):

```bash
export KPN_GREXX_USERNAME="..."
export KPN_GREXX_PASSWORD="..."
export KPN_GREXX_BASE_URL="https://service-accept.grexx.today/interfaces/kpn/kpn_partners_acceptatieomgeving/<interface-id>/"
# export KPN_GREXX_TOKEN_URL="https://service-accept.grexx.today/oauth/access_token"
```

See [`env.example`](env.example). Token mint, cache, and Bearer retry live in
`@wyre-ai/node-kpn`. Base or token URLs under any other header name (`X-KPN-Grexx-Base-Url`, ...) are
rejected (HTTP 400).

In gateway mode a request missing `X-KPN-Grexx-Username` or `X-KPN-Grexx-Password` is
answered `401` (JSON-RPC error `-32001`) before the MCP handler runs. It never falls
through to env credentials.

### Per-connection Grexx environment (WYREAI-525)

In gateway mode Conduit may send two headers on every call to point that connection at another
Grexx environment, such as production:

| Header | Value | Example |
|---|---|---|
| `X-Grexx-Api-Url` | Interface root from the portal (API Gegevens), **without** `/realtime` | `https://service.grexx.today/interfaces/kpn/<interface-name>/<interface-id>` |
| `X-Grexx-Token-Url` | OAuth token endpoint | `https://service.grexx.today/oauth/access_token` |

- **Both or neither.** When both are absent or empty, the sidecar uses `KPN_GREXX_BASE_URL` and
  `KPN_GREXX_TOKEN_URL`, also when the credential headers are sent without the URL headers.
  `KPN_GREXX_BASE_URL` has no default and is then required; `KPN_GREXX_TOKEN_URL` is optional and
  defaults to the acceptatie token endpoint (`https://service-accept.grexx.today/oauth/access_token`).
  Exactly one URL header is HTTP 400.
- Honoured only with `X-KPN-Grexx-Username` / `X-KPN-Grexx-Password`; without them the request is 401.
- **Scheme and authority:** `https:` only, port 443 only, no userinfo, no query string, no fragment.
- **Exact-host allowlist** (`GREXX_ALLOWED_HOSTS` in `@wyre-ai/node-kpn`): `service.grexx.today`
  and `service-accept.grexx.today`. No `*.grexx.today` wildcard; IP literals never match.
- **Paths:** token URL path exactly `/oauth/access_token`; API URL path must start with
  `/interfaces/kpn/` and is the interface root without `/realtime` (the SDK appends it).
- Any violation is HTTP 400 (JSON-RPC `-32600`), and the message never echoes the URL.
- **Private-IP blocking:** with header URLs the SDK resolves the host before every token mint and
  `/realtime` post, and again at socket connect (undici `fetch` with a guarded `Agent`), and refuses
  private, loopback, link-local (incl. `169.254.169.254`), CGNAT, reserved/benchmark, ULA and
  metadata addresses.
- **Redirects are refused:** any 3xx fails the call.
- Every other URL header name (`X-KPN-Grexx-Base-Url`, ...) is still rejected with HTTP 400.

### HTTP authentication

`CONDUIT_S2S_SECRET` is required when `MCP_TRANSPORT=http`. If it is empty, the process
logs an error and exits non-zero. It never prints the secret. Known placeholders
(`changeme`, `change-me`, `secret`, `placeholder`, `example`, `your-secret`,
`your-s2s-secret`, `s2s-secret`, `replace-me`, `replace-with-a-real-secret`) are
rejected the same way, including when `MCP_ALLOW_INSECURE_DEV=1` is set.

`MCP_ALLOW_INSECURE_DEV=1` is local development only. The process logs a warning,
does not check `X-Gateway-S2S`, and listens on `127.0.0.1` only. A non-loopback
`MCP_HTTP_HOST` (including `0.0.0.0`) is ignored in that mode and the override is logged.

`MCP_HTTP_HOST` is honored when a real secret is set. Unset, that bind stays `0.0.0.0`.
Set `127.0.0.1` to keep a secret-backed process on loopback. The container image sets
`0.0.0.0` and `AUTH_MODE=gateway`.

With a real secret, every request except `/health` and `/healthz` must include:

```
X-Gateway-S2S: t=<unix seconds>,v1=<hex hmac-sha256>
```

`v1` is HMAC-SHA256 of the exact string `t=<unix seconds>` (the same `t=` prefix that
starts the header), keyed with `CONDUIT_S2S_SECRET`, hex-encoded. The timestamp must
be within 300 seconds of the server clock. A missing or invalid header is `401`.
`/health` and `/healthz` stay unauthenticated and do not read credentials. The stdio
transport does not use `CONDUIT_S2S_SECRET`.

```bash
# sign "t=$NOW" with the gateway-provisioned secret
NOW=$(date +%s)
SIG=$(printf 't=%s' "$NOW" | openssl dgst -sha256 -hmac "$CONDUIT_S2S_SECRET" | awk '{print $NF}')
curl -sS -D- http://127.0.0.1:8080/mcp \
  -H "X-Gateway-S2S: t=${NOW},v1=${SIG}" \
  -H "X-KPN-Grexx-Username: ..." \
  -H "X-KPN-Grexx-Password: ..." \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```

## SDK dependency

Import the Grexx client from `@wyre-ai/node-kpn` (package root), not `/legacy`.

The dependency is the published range `^2.2.0` (per-connection URLs + SSRF guard from node-kpn#6; Prequalification and OrderData from
[node-kpn#5](https://github.com/WYRE-AI/node-kpn/pull/5), tag `v2.1.0`). Token minting
is the HTTP Basic behavior from
[node-kpn#4](https://github.com/WYRE-AI/node-kpn/pull/4). `/legacy`
remains the developer.kpn.com client for `KPN_LEGACY_DEVELOPER_API=1` only.
This server does not mint the Grexx token.

## Running

```bash
export NODE_AUTH_TOKEN=$(gh auth token)   # GitHub Packages auth for @wyre-ai/*
npm install
npm run build
node dist/index.js                        # stdio (default)
# HTTP refuses to start until CONDUIT_S2S_SECRET is a real secret
CONDUIT_S2S_SECRET="$CONDUIT_S2S_SECRET" MCP_TRANSPORT=http node dist/index.js
# Local HTTP only, without a service-to-service secret (always binds 127.0.0.1)
MCP_ALLOW_INSECURE_DEV=1 MCP_TRANSPORT=http node dist/index.js
npm run smoke                             # both protocol eras serve the same Grexx tools
```

Docker (linux/amd64 per fleet law). The image listens on `0.0.0.0` inside the
container and defaults to `AUTH_MODE=gateway`. Publish the port on loopback and
pass the S2S secret. Grexx username and password arrive on each request as
headers. `KPN_GREXX_BASE_URL` stays in the environment because a caller-supplied
URL is rejected.

```bash
docker build --platform linux/amd64 --build-arg GITHUB_TOKEN=$(gh auth token) -t kpn-mcp .
docker run \
  -e CONDUIT_S2S_SECRET="$CONDUIT_S2S_SECRET" \
  -e KPN_GREXX_BASE_URL="https://service-accept.grexx.today/interfaces/kpn/kpn_partners_acceptatieomgeving/<interface-id>/" \
  -p 127.0.0.1:8080:8080 \
  kpn-mcp
```

Compose refuses to start when `CONDUIT_S2S_SECRET` is unset, publishes
`127.0.0.1:8080:8080`, and does not inject `KPN_GREXX_USERNAME` or
`KPN_GREXX_PASSWORD`:

```bash
CONDUIT_S2S_SECRET="$CONDUIT_S2S_SECRET" \
KPN_GREXX_BASE_URL="https://service-accept.grexx.today/interfaces/kpn/kpn_partners_acceptatieomgeving/<interface-id>/" \
GITHUB_TOKEN=$(gh auth token) \
docker compose up --build
```

Single-tenant HTTP (`AUTH_MODE=env`) still requires the S2S secret, then uses
`KPN_GREXX_USERNAME` and `KPN_GREXX_PASSWORD`:

```bash
docker run \
  -e CONDUIT_S2S_SECRET="$CONDUIT_S2S_SECRET" \
  -e AUTH_MODE=env \
  -e KPN_GREXX_USERNAME=... \
  -e KPN_GREXX_PASSWORD=... \
  -e KPN_GREXX_BASE_URL=... \
  -p 127.0.0.1:8080:8080 \
  kpn-mcp
```

`/health` is a shallow liveness probe. It does not call Grexx and does not read credentials.

## Legacy developer.kpn.com tools

With `KPN_LEGACY_DEVELOPER_API=1` the process also registers `kpn_test_connection`,
`kpn_disturbances_check`, `kpn_availability_check`, `kpn_sim_swap_get_date`, and the MSM
read/write tools. Credentials are `KPN_CLIENT_ID` / `KPN_CLIENT_SECRET` (gateway:
`X-KPN-Client-Id` / `X-KPN-Client-Secret`), plus an optional MSM pair. `KPN_BASE_URL`
stays env-only. A half MSM pair is rejected. MSM writes still create orders, are not
retried, and require confirmation (`confirm_destructive_action` when the client cannot
be prompted).

## Vendor quirks encoded here

- OAuth client_credentials, `scope=all`. The SDK sends HTTP Basic to the token endpoint, then Bearer on `POST /realtime` (`Content-Type: text/xml`).
  Plain XML, no SOAP envelope. Basic Auth is not sent on `POST /realtime`.
- This server hands token minting to `GrexxClient`, which sends HTTP Basic first and may fall back to form-body `client_id` / `client_secret` after HTTP 400/401 `invalid_client`.
- The token endpoint is not retried into a Bearer-less call. HTTP 401 remints once inside the SDK.
- IRMA code `108` and HTTP 429 are rate limits. Code `102` is an IP allowlist rejection.
- Success codes include `Success` (what acceptatie returned for ZipCodeCheck).

## License

Apache-2.0 © WYRE Technology