# ituran-mcp

Stdio MCP server that exposes the **Ituran Global V2 / Trips / BI & operational Reports / Safety / Maintenance / Advanced / ITC** APIs as 152 MCP tool definitions (151 register with the degraded tool hidden; 120 register on the default read-only server, where the 7-tool maintenance family is opt-in). The server holds the user's long-lived credentials in env vars, mints its own access tokens against identity-server-v1, and refreshes them silently. **Tokens never reach the LLM.**

> ⚠️ **Defaults to QA** (`https://api-qa.ituran.com/api/v2`). Many tools mutate state (DELETE / PUT / PATCH / POST). To run against the production fleet set `ITURAN_GLOBAL_BASE_URL=https://api.ituran.com/api/v2` explicitly.

## Install

Published to the **public npm registry** as [`ituran-mcp`](https://www.npmjs.com/package/ituran-mcp) — no auth, no registry config. Any MCP client with stdio-server support can launch it through `npx`.

### Prerequisites

- **Node.js ≥ 20** (`node -v`)
- An **MCP client with stdio-server support** (the example below uses Claude Code)
- An **Ituran service account without 2FA** — password grant returns an OTP challenge that the MCP can't satisfy on its own. Use a non-OTP service account.

### Step 1 — Register the MCP (Claude Code example)

The examples below register against **QA** (`api-qa.ituran.com`). Setting `ITURAN_GLOBAL_BASE_URL` explicitly — even to the default — is recommended so the environment is obvious in `claude mcp get ituran`. To switch to prod, see [Switching to production](#switching-to-production).

**Linux / macOS:**

```bash
claude mcp add --scope user ituran \
  --env ITURAN_USERNAME='your.username@ituran.com' \
  --env ITURAN_PASSWORD='your-password' \
  --env ITURAN_GLOBAL_BASE_URL=https://api-qa.ituran.com/api/v2 \
  -- npx -y ituran-mcp
```

**Windows (PowerShell):**

```powershell
claude mcp add --scope user ituran `
  --env ITURAN_USERNAME='your.username@ituran.com' `
  --env ITURAN_PASSWORD='your-password' `
  --env ITURAN_GLOBAL_BASE_URL=https://api-qa.ituran.com/api/v2 `
  -- npx -y ituran-mcp
```

The published launcher (`start.js`) auto-detects the system CA bundle on Linux (`/etc/ssl/certs/ca-certificates.crt`) and macOS (`/etc/ssl/cert.pem`) and sets `NODE_EXTRA_CA_CERTS` for you, so corp TLS to `api-qa.ituran.com` / `api.ituran.com` works out of the box. On Windows it leaves the env untouched — if upstream calls fail with `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, add `--env NODE_EXTRA_CA_CERTS=C:\path\to\corp-ca-bundle.crt`.

#### Switching to production

> ⚠️ The MCP has 24 mutating tools (create/update/delete — alerts, places, contacts, vehicle-groups, send-command, start-tracking, etc.). They register only with `ITURAN_ALLOW_MUTATIONS=true`; on prod, anything an LLM decides to call happens for real on the live fleet. Only flip when the work requires it.

Re-register with the prod URL:

```bash
claude mcp remove ituran -s user

claude mcp add --scope user ituran \
  --env ITURAN_USERNAME='your.username@ituran.com' \
  --env ITURAN_PASSWORD='your-password' \
  --env ITURAN_GLOBAL_BASE_URL=https://api.ituran.com/api/v2 \
  -- npx -y ituran-mcp
```

Start a new Claude Code session for the change to take effect.

### Step 2 — Verify

```bash
claude mcp list
```

Expected: `ituran: npx -y ituran-mcp - ✓ Connected`.

> Run this from **any directory other than a clone of this repo**. If your working directory contains a `package.json` named `ituran-mcp` (i.e., the source checkout), npm short-circuits to a local lookup and the bin won't resolve. From `~`, `/tmp`, or any unrelated project — fine.

### Step 3 — Use it

Tools usually load when the client session starts, so reconnect or start a new client session after changing configuration. In Claude Code, type `/mcp` to confirm the tools loaded (120 in the default read-only configuration), then try a call — e.g. *"List my top 10 vehicles."*

## Tools

152 tool definitions across 22 families (151 register through the full-surface registrar with the degraded tool hidden, and 120 on the default read-only server: the 7-tool maintenance family is **default-off**, see `ITURAN_ENABLE_MAINTENANCE`). Full catalog with arguments and descriptions: [`TOOLS.md`](./TOOLS.md).

| Domain | Tools | File |
|---|---:|---|
| Vehicles | 8 | `src/tools/vehicles.ts` |
| Alerts | 11 | `src/tools/alerts.ts` |
| Events | 7 | `src/tools/events.ts` |
| Places | 6 | `src/tools/places.ts` |
| Contacts | 5 | `src/tools/contacts.ts` |
| Drivers | 4 | `src/tools/drivers.ts` |
| Commands | 5 | `src/tools/commands.ts` |
| Diagnostics | 6 | `src/tools/diagnostics.ts`, `src/tools/ev-overnight-charge-board.ts` |
| Vehicle groups | 6 | `src/tools/vehicle-groups.ts` |
| Drive-permission groups | 4 | `src/tools/drive-permission-groups.ts` |
| Exports | 2 | `src/tools/exports.ts` |
| Info | 3 | `src/tools/info.ts` |
| Users | 1 | `src/tools/users.ts` |
| Trips | 10 | `src/tools/trips.ts` |
| BI reports + composites | 17 | `src/tools/reports.ts`, `src/tools/driver-timesheet-crosscheck.ts`, `src/tools/cost-center-usage-report.ts` |
| Operational reports | 19 | `src/tools/reports-operational.ts`, `src/tools/saved-place-vehicle-overlap.ts`, and dedicated report composites |
| Safety / UBI | 13 | `src/tools/safety.ts`, `src/tools/safety-hotspots.ts` |
| Maintenance *(default-off — opt in via `ITURAN_ENABLE_MAINTENANCE`)* | 7 | `src/tools/maintenance.ts`, `src/tools/service-visit-report.ts`, `src/tools/inactivity-explanation-report.ts` |
| Advanced | 2 | `src/tools/advanced.ts` |
| ITC temperatures | 5 | `src/tools/itc.ts` |
| Fleet composites + resolver | 3 | `src/tools/fleet.ts` |
| Named-group planner | 7 | `src/tools/groups.ts` |
| **Total registered with degraded tool hidden** | **151** | |

Tool names follow `ituran_<domain>_<verb>`. Endpoint wrappers mirror upstream routes; composites and planner tools combine those routes into reusable fleet primitives.

Excluded by design: account/admin endpoints with no AI value (`PUT /users/me/password`, `PUT /users/me/accept-terms`, `GET/PUT /app/usersettings`, `GET /internal/testip`), and the entire identity-server-v1 surface (login flows, OTP, password-reset, onboarding, refresh-token admin) — those are either credential-handling anti-patterns or require a different auth model. The token manager calls `POST /oauth/token` internally; nothing else from identity-server-v1 is exposed.

## How auth works

```
ITURAN_USERNAME + ITURAN_PASSWORD (env vars)
   ↓
token-manager → POST /api/v1/oauth/token (password grant) → access_token + refresh_token
   ↓
Global V2 tools ← Authorization: Bearer <access_token> (added per-request, internally)
```

- **First tool call (per process)** is lazy: does a `grant_type=password` exchange against identity-server using the env-var creds.
- **Subsequent calls** reuse the cached access_token until 30 s before expiry, then refresh in-place via `grant_type=refresh_token` (using the in-memory refresh_token that came back with the access_token).
- **Nothing on disk.** Both tokens live only in the process's memory. When the MCP process exits (Claude Code session ends, machine reboots, etc.), they're gone — the next process starts fresh with another password grant. The password is read from env vars at startup; the LLM never sees any of these.
- **2FA accounts are not supported** — password grant returns an OTP challenge instead of tokens, and the MCP can't complete login without a human in the loop. Use a service account that doesn't require 2FA.

## Configuration

| Var | Required | Default | Purpose |
|---|---|---|---|
| `ITURAN_USERNAME` | yes | — | Username for `POST /oauth/token` password grant |
| `ITURAN_PASSWORD` | yes | — | Password for `POST /oauth/token` password grant |
| `ITURAN_APP_ID` | no | unset | Optional integer included in the password-grant body |
| `ITURAN_GLOBAL_BASE_URL` | no | `https://api-qa.ituran.com/api/v2` | Override to prod (`https://api.ituran.com/api/v2`) for the live fleet |
| `ITURAN_IDENTITY_BASE_URL` | no | derived from global URL | Override only if identity host diverges from global |
| `ITURAN_ALLOW_MUTATIONS` | no | `false` | Gate for write tools. When unset/false the server is **read-only** — the 24 mutating tools (create/update/delete, send-command, start-tracking, share-drive) are **not registered at all** (no catalog weight, no dead-end calls). Set `true` to register and enable writes. |
| `ITURAN_ENABLE_MAINTENANCE` | no | `false` | Opt-in for the **default-off maintenance family** (7 read tools). Maintenance data is per-customer — accounts that don't maintain it get empties or stale schedules read as "overdue service" — so unless enabled the module is **invisible**: no tools registered and zero mentions in prompts, instructions, or served resources (absence, never "disabled"). Naming `maintenance` in an explicit `ITURAN_TOOL_FAMILIES` list also enables it (the explicit list is authoritative). |
| `ITURAN_PLANNER_INLINE_THRESHOLD` | no | `10` | Planner selects at/under this count inline operational summaries (0 disables). |
| `ITURAN_RADIUS_SELECTION_POLICY` | no | `allow` | Coordinate-radius planner policy. `allow` keeps the generic selector available; `saved-places-only` rejects coordinate-radius selection and routes named-area workflows through `ituran_places_list` + `ituran_select_vehicles_in_place`. |
| `ITURAN_DRIVERS_CACHE_TTL_SECONDS` | no | `300` | TTL for the driver-code→name registry cache behind `ituran_safety_rank_drivers` (0 disables). |
| `ITURAN_CONFIRM_DESTRUCTIVE` | no | `false` | With mutations enabled: ask the user for a per-call confirmation (MCP elicitation) before any destructive tool (deletes, live device commands) executes. **Fail-closed:** when the connected client does not support elicitation, destructive calls are **refused** (`destructive_confirmation_unavailable`) instead of silently proceeding — use the host's own approval controls or unset the flag. |
| `ITURAN_MAX_RESULT_CHARS` | no | `24000` | Size-guard ceiling (chars) for tool results — summarizers/trimming engage above it. Match it to your host's inline budget so the server's smart summaries run **before** the host's blunt head/tail slice (integer ≥ 2000). |
| `ITURAN_VERIFY_CREDENTIALS_AT_BOOT` | no | `true` | Mint a token at startup: a wrong password / 2FA-gated account exits(1) with an `authentication failed` message **before** the transport connects (an attributable boot failure instead of dead tools mid-conversation). Transient identity trouble warns and continues. Costs one identity round-trip per process start; set `false` to restore the fully-lazy first mint. |
| `ITURAN_REQUEST_TIMEOUT_MS` | no | `30000` | Per-request timeout. Stalled upstream calls abort with a `TimeoutError` instead of hanging the tool call. |
| `ITURAN_SOFT_DEADLINE_MS` | no | `90000` | Wall-clock scheduling budget (ms) shared by every paced multi-request walk (chunk fan-outs, report page walks). When spent, no new upstream work is scheduled and the tool returns a partial, honestly-labelled result (`coverage.complete:false` / `truncated`) instead of dying at the host's MCP call deadline. |
| `ITURAN_MAX_RETRIES` | no | `2` | Retries for transient failures (timeout / network / 5xx / 429) on **read-only tools** (including read-only POSTs like search/reports), with exponential backoff + jitter. Mutating tools are never auto-retried. |
| `NODE_EXTRA_CA_CERTS` | no | unset | Linux: `/etc/ssl/certs/ca-certificates.crt` to trust corp TLS chain |

**Catalog profiles.** The default 120-tool read-only catalog costs ~172KB (~43.0k tokens) of model-facing definitions per conversation (127 tools / ~180KB / ~44.9k with `ITURAN_ENABLE_MAINTENANCE=true`); `ITURAN_TOOL_FAMILIES` shrinks it to what a deployment needs (measured with `npm run measure:catalog`):

| Profile | `ITURAN_TOOL_FAMILIES` | Tools | Model-facing catalog |
|---|---|---:|---:|
| Default (read-only) | *(unset)* | 120 | ~172KB (~43.0k tokens) |
| Fleet ops | `vehicles,trips,safety,fleet,groups,drivers` | 44 | ~61KB (~15.3k tokens) |
| Tracking lite | `vehicles,fleet,groups` | 17 | ~21KB (~5.3k tokens) |
| Reports only | `reports-bi,reports-operational,safety` | 49 | ~86KB (~21.6k tokens) |

### Deploying under small-model / security-first hosts

Hosts that target small or local models budget tool results and catalogs much tighter than a frontier-model client. The recommended deployment:

```bash
ITURAN_TOOL_FAMILIES=vehicles,fleet,trips,safety,groups,drivers   # 44 tools, ~15.3k catalog tokens
ITURAN_MAX_RESULT_CHARS=14000   # host offloads MCP results >15k chars; ~740 of those are its own
                                # untrusted-content wrapper — 14000 keeps results inline with the
                                # server's summaries + guidance markers intact. Small models: ~11000;
                                # nano: ~7000.
```

- **Entrypoint:** always `npx -y ituran-mcp` (or `node <path>/start.js`) — **never** bare `node <path>/dist/server.js`. Only the launcher sets `NODE_EXTRA_CA_CERTS` before Node starts; behind a corp TLS chain the bare form fails every call with `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, which reads as a network outage.
- **Keep the boundary explicit:** this package owns Ituran transport, credentials, upstream contracts, generic fleet primitives, and safety gates. Put host persona, user-facing style, approval UX, and product-specific workflow policy in a separate agent package or skill.
- **Read-only posture is model-legible:** in read-only mode the tool descriptions, server instructions (front-loaded POSTURE), and the served tool-catalog resource all state that command/write verbs are unavailable — agents are told to say so plainly instead of constructing confirm-then-send flows.
- **Canonical schemas, forgiving adapters:** tool schemas remain the published source of truth, while applicable calls accept common agent spellings (`vehicle`/`plate`, `period`, `diagnostic`, `place`, `hours`) before strict validation. Conflicting spellings fail loudly; unknown keys are never silently dropped.
- **Planner groups are not map areas:** named vehicle groups are ephemeral internal selections, not saved places/geofences or map state. For a named-area request, call `ituran_places_list` with `name_contains`, resolve an exact `place_id`, then call `ituran_select_vehicles_in_place`. A zero match is terminal evidence for the current account; do not invent coordinates or substitute a radius-based group. Deployments that want this enforced at the server boundary can set `ITURAN_RADIUS_SELECTION_POLICY=saved-places-only`.
- **Fleet-wide saved-place history is one composite:** call `ituran_place_visitors` once with `place_name` (or `place_id`) and the date window; use `min_visit_minutes` for “stayed over N minutes.” It resolves the saved place without guessing, scans heat maps with bounded concurrency, returns total/longest dwell metrics, and distinguishes retryable report failures from terminal account/data conditions. Use `ituran_reports_heatmap` only for one vehicle's episode details. `vehicle_nickname` is an operator-defined vehicle label that may look like a person's name—never present it as verified driver identity.
- **Fleet-wide all-place overlap is one composite:** call `ituran_saved_place_vehicle_overlap` once with the requested account-local date. It freezes the full roster, exhausts every saved place, scans heat maps with bounded concurrency, and returns only places visited by at least two distinct normalized plates using exact saved radius or polygon geometry. Do not loop `ituran_place_visitors` or call discovery, place listing, trip search, or per-vehicle heat maps separately. Same-day co-visitation does not establish simultaneous presence or duplicate dispatch.
- **Private speed limits inside a saved site are one composite:** call `ituran_site_speeding` once with `place_name` (or `place_id`), `speed_limit_kmh`, and the date window. It freezes the fleet roster, applies the saved `place_id` upstream, and independently verifies each returned fix against the exact saved radius or polygon; failed plates and incomplete report batches remain explicit.
- **Current return-to-base screening is one composite:** call `ituran_return_to_base_report` once with the exact operator-approved saved-place names, local curfew, and freshness threshold. It freezes one fleet snapshot, applies exact saved radius or polygon boundaries, and returns counts plus a lossless semicolon-delimited outside/unlocated roster. Disclose every narrow-boundary warning and treat stale fixes as last-known evidence.
- **Current service-territory screening is one composite:** call `ituran_service_territory_report` once with the operator-supplied center, radius, and freshness threshold. It freezes one fleet snapshot and returns exact inside/outside/unlocated counts plus a lossless farthest-first outside roster. A stale fix is last-known evidence, not proof of unauthorized use.
- **Current area occupancy has an aggregate:** `ituran_places_stats` returns `occupancy.occupied_places` and `occupancy.unique_vehicles_in_places` as the direct “how many vehicles are inside how many areas” answer. `total_place_memberships` can exceed the unique-vehicle count because saved places overlap; `places` carries the per-place ranking.
- **Saved-place geometry quality is one composite:** call `ituran_places_stats` with `view:"registry_audit"` and the operator's small-radius, large-radius, and near-center thresholds. It exhausts the registry internally, reports exact geometry/radius partitions, and returns near-center pairs in compact lossless pages; polygon bounding discs are explicitly marked as approximations.
- **Stale open-event debt is audited before response bounding:** call `ituran_events_stats` with `board:"stale_open_states"` and `older_than_days`. It drills every positive definition internally, returns only definition-level stale counts and oldest ages, and keeps failed reads, count drift, malformed times, future times, and duplicate vehicle states explicit in coverage. An old open state is possible closure or data-maintenance debt, not proof of a current incident.
- **Current fleet group coverage is one exact set audit:** call `ituran_vehicle_groups_list` with `view:"coverage_audit"`. It freezes the complete roster, walks every nested group membership, and returns exact ungrouped current plates separately from stale registry plates and current plates assigned to multiple groups. Both the fleet and registry partitions must reconcile before `coverage.complete` is true.
- **Fleet reporting latency is one fresh SLA view:** call `ituran_fleet_summary` with `view:"reporting_latency"`. It uses the full frozen roster as the share denominator, measures last-location timestamps against account-local now, and returns cumulative one-hour/day shares plus nearest-rank P50/P90 ages. Missing, epoch-artifact, invalid, and future timestamps remain explicit, and coverage must reconcile before the board is treated as complete.
- **High-mileage meter divergence is one current composite:** call `ituran_fleet_activity_report` with `view:"meter_divergence"` and omit `top_n`/`material_gap_pct` unless the user supplied them. It selects the default top ten device odometers, joins GPS virtual odometers once, ranks every comparison in compact CSV, and surfaces the default 20% review set with timestamps, reliability, and exact coverage. Different meter sources can diverge after resets, replacements, or telemetry gaps; a flag is not evidence of fraud.
- **Insurance-renewal evidence uses one consistent trip population:** call `ituran_trips_search` with `view:"insurance_renewal"` and exact dates. The composite exhausts trips once for distance, night share, nearest-rank speed percentiles, and driver identification, while fetching the fleet safety aggregate concurrently. Inspect `coverage.missing_periods` and `coverage.partial_sources`; safety enrollment coverage remains unknown even when its aggregate succeeds. The pack informs a renewal discussion and is not an underwriting decision.
- **Circular congestion exposure is one endpoint screen:** call `ituran_trips_search` with `view:"congestion_exposure"`, the operator's center/radius, charge windows, and exact dates. It counts trips once across start-only, end-only, and both-endpoint partitions, returns charged and all-hours exposure, and reconciles the full end-owned trip feed through the next-day lookahead. Invalid endpoint points make coverage partial. A circle is not the legal-zone polygon, and trips that only cross it are missed, so the estimate can under-count.
- **Driver-day payroll cross-checks are one BI composite:** call `ituran_reports_bi_driver_timesheet_crosscheck` with the requested window. It exhausts the daily-driver report, returns every activity-span versus reported-driving comparison in compact CSV, and flags impossible duration-above-span rows with exact coverage. A BI entitlement denial is a successful typed unknown and stops immediately; raw trip pages are not a substitute. The result supports a wage conversation, never a certified attendance claim.
- **Rendering is a host capability:** the MCP returns fleet data, coordinates, and time series. Agents should render a chart or map only when their host supports it; otherwise return a compact table and never claim a read-only call changed UI selection state.
- **Planner rows are data-minimized:** inline selections and `ituran_group_get` return operational vehicle summaries without contact details, VINs, device/network identifiers, or internal IDs. Use the driver tools only when the user explicitly requests contact details.
- **Elicitation:** `ITURAN_CONFIRM_DESTRUCTIVE` requires a client that supports MCP elicitation; on clients without it, destructive calls are refused, not silently run — rely on the host's own approval controls if you enable mutations.
- **Parallel tool calls are safe:** the server is stateless per call and its token cache is mutex-guarded with a generation-guarded invalidate (a lagging 401 can't discard a freshly minted token). Hosts that serialize stdio servers can safely enable per-server parallelism.
- **Ask the server what it can do:** the `ituran://reference/capabilities` resource states this deployment's posture (read-only vs mutations), the active environment (production vs QA host), unavailable verbs, and any family filters — agent-readable, byte-stable per deployment.

**Safety & resilience.** The server starts **read-only** — write tools are registered only when `ITURAN_ALLOW_MUTATIONS=true`; on prod it prints a loud banner and reports the mutation state at startup. Every tool accepts an optional `format` argument (`"json"` default, or `"text"` for a compact markdown rendering), and large list responses are size-capped to stay under a context budget (trimmed to valid JSON with a `_truncated` marker). Tools carry MCP `readOnlyHint` / `destructiveHint` / `idempotentHint` annotations, and thrown errors surface as structured JSON envelopes (`{error, status?, error_code?, message}`) with remedy hints intact. On a `401` the token is refreshed and the request retried once.

## Rotating creds

Tokens never persist, so there's no on-disk state to clean up. To swap accounts:

1. Update `ITURAN_USERNAME` / `ITURAN_PASSWORD` in your MCP client configuration.
2. Restart or reconnect the MCP process.

> Upgrading from 1.0.0 → 1.1.0+? The old version cached a refresh token at `~/.ituran-mcp/state.json`. Since 1.1.0 nothing reads or writes there, but the file is left in place by the upgrade. Run `rm -rf ~/.ituran-mcp/` once after upgrading to remove the orphan.

## Local development (contributors)

Clone the repo, build, and run from source:

```bash
git clone https://github.com/ituranlabs/ituran-mcp.git
cd ituran-mcp
npm install
npm run build
ITURAN_USERNAME=... ITURAN_PASSWORD=... node start.js
```

The server reads JSON-RPC on stdin and writes responses on stdout. There are no HTTP endpoints — the MCP client launches the binary and talks over the inherited pipes. `start.js` is a cross-platform launcher (Linux / macOS / Windows) that auto-detects the system CA bundle on Linux and macOS so corp TLS works without extra config.

To register your local checkout instead of the published package:

```bash
claude mcp add --scope user ituran-local \
  --env ITURAN_USERNAME=... --env ITURAN_PASSWORD=... \
  -- node /absolute/path/to/ituran-mcp/start.js
```

Smoke test (no upstream call — just exercises the JSON-RPC plumbing):
```bash
( printf '%s\n%s\n%s\n' \
    '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"1"}}}' \
    '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \
    '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
  | ITURAN_USERNAME=fake ITURAN_PASSWORD=fake node dist/server.js 2>/dev/null \
) | head -2
```

## Publishing a new version (maintainers)

Published to the public npm registry under the unscoped name `ituran-mcp`.

```bash
# one-time: log in to the public npm registry (needs an npmjs.com account with publish rights)
npm login

# bump version, then publish — `prepublishOnly` runs the build for you
npm version patch    # or minor / major
npm publish          # public by default (unscoped name)
git push --follow-tags
```

`npm publish` only ships the files listed in `"files"` in `package.json` (currently `dist/`, `start.js`, `README.md`, `TOOLS.md`, `LICENSE`). Source TypeScript and `node_modules` are not published. Run `npm publish --dry-run` first to inspect the exact tarball contents.

## License

MIT — see [`LICENSE`](./LICENSE).
