Metadata-Version: 2.5
Name: ndslive-mcp
Version: 0.5.1
Summary: Local MCP server for searching and exploring the NDS.Live specification
Author: NDS Association
License-Expression: BSD-3-Clause
License-File: LICENSE
Keywords: mcp,navigation,nds,ndslive,zserio
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: keyring>=24
Requires-Dist: mcp>=1.2
Requires-Dist: platformdirs>=4
Requires-Dist: pyjwt[crypto]>=2.8
Requires-Dist: pyyaml>=6
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# ndslive-mcp

A locally-installable MCP server giving agents — Claude Code, IDE assistants, scripts — structured search and lookup over the NDS.Live specification.

## What it gives you

Once installed and authenticated, your MCP host gains thirteen tools:

| Tool | What it does |
|------|--------------|
| `search_spec`     | Full-text search over symbol names, qnames, doc comments, enum/bitmask member names, and field names + docs. Porter-stemmed, so natural-language queries match ("subdivision" finds "subdivisions"). Results collapse to the newest version of each declaration (`all_versions` to opt out). Filter by kind / module / version. |
| `search_docs`     | Full-text search over the NDS.Live documentation: the portal's own pages (`documentation.nds.live`, `best-practices.nds.live`, with `url` + `nav_path`) **and every module's own documentation**, lifted from the markdown sections of its `.zs` files (`module` + `version` + `file`). Plain-language queries are widened automatically; results collapse to the newest version of each page. |
| `get_doc`         | Read a documentation page by the `path` a search hit returned — the whole markdown, or one `section` of a long page. Drop the `#anchor` to read a whole spec file, e.g. `spec/lane/v2026_06/_module.zs` for a module's overview. |
| `get_type`        | Resolve a fully-qualified name → kind, module, version, source file, line, doc; the field list (each with `type_qname`, `optional`, `is_array`, its own doc, and any constraint/condition); enum/bitmask members (values + docs); and the verbatim `.zs` declaration as `source`. |
| `get_rule`        | Resolve an NDS rule id → rule text, its rule group, and module / version / source file. |
| `find_references` | Every place a type is referenced, by field name and source location. |
| `list_modules`    | Modules in the bundle, optionally filtered by category (common / feature / attribute / service / reference). |
| `get_module`      | Module metadata: category, deps, top-level types. |
| `get_module_versions` | Every version of a module the bundle has indexed. |
| `compare_versions` | Diff between two versions of the same module: added / removed / changed types. |
| `get_zserio_type` | The value range of a zserio built-in, as zserio's own documentation tabulates it — `varsize` → `0 to 2147483647`. Use it instead of recalling a number. |
| `check_answer`    | Check a drafted answer before sending it: identifiers that exist nowhere in the specification (with `did_you_mean` candidates), module versions that were never published, passages quoted from no page, and a member named after the question that the draft ignores. Local, no model, no network. |
| `update_index`    | Force a refresh against Artifactory. Live-swap; no server restart. |

### NDS.Live is a zserio schema, so the bundle carries zserio too

A whole class of member question — *how many lanes can a lane group have? what
range does this field hold? how is it encoded?* — is answered by the **zserio
language and encoding documentation** and by nothing in the NDS.Live
specification. Left out, that gap is invisible: an agent substitutes a plausible
number instead of saying it cannot tell.

The bundle therefore ingests [ndsev/zserio](https://github.com/ndsev/zserio)'s
`doc/` (BSD-3-Clause) at the tag matching the compiler that built the bundle:
searchable through `search_docs` under `source: "zserio"`, with the built-in
value ranges parsed out of the language overview's own table into
`get_zserio_type`. Nothing is transcribed by hand, so no number in the bundle is
one nobody sourced.

Worked example — *"How many lanes can a lane group max have?"*: the schema says
nothing (`Lane lanes[];` carries no `lengthof` constraint and no rule caps it),
but `lanes[]` is a zserio **auto array**, whose length is an implicit `varsize`
(*"array length encoded as `varsize`"*, Auto Length Arrays), and
`get_zserio_type("varsize")` gives `0 to 2147483647`. Both halves are citable;
neither is in the NDS.Live spec.

### Checking an answer before you send it

An agent answering from this index is researcher, author and — unless something
checks it — the only judge of whether its own claims hold. In testing, more than
one model produced identifiers formed by analogy with real ones
(`SPEED_LIMIT_KMH` where the specification has `SPEED_LIMIT_METRIC`), module
versions that were never published, quotations that appear in no page, and
answers that never mention the member named after the very concept asked about.

`check_answer` catches all four. It is worth calling automatically, because every
finding is a fact about text rather than an opinion about quality:

```python
check_answer(
    answer="Assign `EXIT_LANE_COUNT` from `rules.v2026_06.attributes.RulesRoadRangeAttributeType`.",
    question="I need to model the number of exit lanes on a road. Is this possible?",
)
→ {
    "ok": False,
    "unknown_identifiers": [{"name": "EXIT_LANE_COUNT", "did_you_mean": ["EXIT_LANE"]}],
    "unpublished_versions": [{"module": "rules", "version": "2026_06",
                              "published": ["2022_03", …, "2025_11"]}],
    "overlooked_concepts": [{"member": "EXIT_LANE",
                             "defined_in": "core.v2026_06.types.LaneFunctionalType",
                             "doc": "Exit lane on a controlled-access road."}],
    "advice": [...],
  }
```

It costs milliseconds and needs no model, so there is no reason not to run it on
every answer you are about to give.

## Install

```bash
pip install ndslive-mcp      # public PyPI; no NDS gate on the code itself
ndslive-mcp install          # guided setup: verify your Artifactory PAT, save it, pre-fetch the bundle
```

Then register the server with your MCP host. **You don't run the server yourself** — the host launches `ndslive-mcp` on demand over stdio (and kills it when it's done). For Claude Code:

```bash
claude mcp add ndslive -- ndslive-mcp
```

Other hosts (Codex, Gemini, IDE assistants): configure a stdio MCP server whose command is `ndslive-mcp` (no arguments).

`ndslive-mcp install` is a wizard around the lower-level commands, which you can also run individually: `ndslive-mcp auth` (save/verify PAT) and `ndslive-mcp update` (fetch or refresh the bundle).

## How auth works

The Python package is public on PyPI — anyone can install. The **bundle** (the actual NDS.Live spec content: a prebuilt SQLite index) is gated behind NDS Artifactory PAT auth. On first run, the server tries to download the bundle; if no PAT is saved it logs a warning and refuses to answer queries until you run `ndslive-mcp auth`.

PATs are stored in the OS keyring (macOS Keychain / Linux Secret Service / Windows Credential Locker). They never live in plaintext on disk.

For headless / CI usage:

```bash
NDS_ARTIFACTORY_USER=u NDS_ARTIFACTORY_PAT=p ndslive-mcp serve
```

These env vars take precedence over the keyring.

## How updates work

```
   server start ──► GET ndslive-mcp.json on Artifactory
                              │
                       ┌──────┴──────┐
                  same version    newer version
                       │              │
                       │              ▼
                       │      GET bundle.zip
                       │      verify sha256
                       │      extract → <cache>/ndslive-mcp/versions/<v>/
                       │      atomic-swap `current` symlink
                       │              │
                       └──────┬───────┘
                              ▼
                   open index.sqlite (read-only)
```

- **Atomic**: a half-downloaded bundle never becomes the live one — the symlink only flips after sha256 verification.
- **Rollback-friendly, bounded**: the previous bundle stays on disk next to the active one; older ones are removed, so the cache does not grow with every release.
- **Non-blocking**: the check runs in the background at startup (and on an explicit `update_index` tool call), so launching the server never waits on a download — the new index is hot-swapped in when ready.

## Running as a shared HTTP service

The default transport is stdio: your MCP host launches one server per
session on your own machine. To serve **many** clients from one place — a
web chat backend, a team's shared endpoint, a Kubernetes pod — run it over
[Streamable HTTP](https://modelcontextprotocol.io/specification/latest/basic/transports#streamable-http)
instead:

```bash
NDS_ARTIFACTORY_USER=svc NDS_ARTIFACTORY_PAT=… \
ndslive-mcp serve --transport http --host 0.0.0.0 --port 8000
```

| Flag | Default | Meaning |
|------|---------|---------|
| `--host` / `--port` | `127.0.0.1` / `8000` | Bind address. Binding to loopback also enables the SDK's DNS-rebinding guard (only `localhost` Host headers are accepted); use `0.0.0.0` in a container. |
| `--path` | `/mcp` | The MCP endpoint. Clients connect to `http://host:port/mcp`. |
| `--json-response` | off | Answer with plain JSON instead of SSE. Same protocol, friendlier to buffering reverse proxies. |
| `--update-interval SECONDS` | `900` (http), `0` (stdio) | Re-check Artifactory for a newer bundle this often and hot-swap it in. `0` checks once at startup. |
| `--offline` | off | Never check Artifactory; serve whatever bundle is cached. |

The HTTP mode is **stateless** — every request is self-contained, so
several replicas can sit behind one load balancer and a client never has to
re-initialize after a restart. `GET /healthz` returns `503` until a bundle is
loaded and `200 {"status":"ok","bundle":"<version>"}` afterwards, which makes
it a ready-made Kubernetes readiness probe.

A shared service has no `update_index` tool, since it updates itself on
`--update-interval`, and every tool is annotated read-only. The *server*
needs an Artifactory PAT (env vars, as above) to fetch the bundle.

### Requiring sign-in

Point the server at an OAuth 2.1 / OpenID Connect issuer and it becomes a
resource server under the [MCP authorization spec](https://modelcontextprotocol.io/specification/latest/basic/authorization):

- A request without a token gets `401` and a `WWW-Authenticate` challenge
  naming the server's Protected Resource Metadata
  (`/.well-known/oauth-protected-resource/mcp`), which names the issuer. That
  is how MCP clients (IDEs, Claude, ChatGPT) find the login page on their own.
- Tokens are JWTs checked against the issuer's keys:
  - the signature, with an asymmetric algorithm only;
  - `iss` and `exp`;
  - `aud`, which must contain the public URL;
  - optionally, a role in a claim.
- A valid token without the role gets `403` with an explanation.
- `/healthz` and the metadata stay public.

```bash
ndslive-mcp serve --transport http --host 0.0.0.0 \
  --public-url https://mcp.example.com/mcp \
  --auth-issuer https://login.example.com/realms/example \
  --auth-role-claim resource_access.ndslive-mcp.roles --auth-role read
```

| Flag (env var) | Meaning |
|---|---|
| `--public-url` (`NDSLIVE_MCP_PUBLIC_URL`) | The endpoint exactly as clients reach it. It is the metadata's `resource` and the default audience. |
| `--auth-issuer` (`NDSLIVE_MCP_AUTH_ISSUER`) | Issuer whose tokens are accepted. Setting it turns sign-in on. |
| `--auth-jwks-url` (`NDSLIVE_MCP_AUTH_JWKS_URL`) | Where to fetch the signing keys. Defaults to the issuer's metadata; set it when the server reaches the issuer on an internal hostname. |
| `--auth-audience` (`NDSLIVE_MCP_AUTH_AUDIENCE`) | Audience a token must carry. Defaults to `--public-url`. |
| `--auth-scope` (`NDSLIVE_MCP_AUTH_SCOPE`) | Scope clients are told to request, default `ndslive-mcp`. In Keycloak it carries the audience mapper, because Keycloak ignores RFC 8707 resource indicators. |
| `--auth-role-claim`, `--auth-role` (`NDSLIVE_MCP_AUTH_ROLE_CLAIM`, `NDSLIVE_MCP_AUTH_ROLE`) | Dotted path of a claim, and the value it must contain. |
| `--no-auth` | Serve on a non-loopback address without sign-in. Without either this or an issuer, the server refuses to start there. |

## Updating

The **package** and the **spec bundle** update independently:

- **Package** — `pip install --upgrade ndslive-mcp` gets new server code and tools.
- **Bundle** (the spec data) — refreshed automatically: each time your MCP host launches the server, it checks Artifactory and, if a newer bundle is published, downloads and hot-swaps it. You normally run nothing.

Upgrading the package does **not** fetch a new bundle by itself — the next server launch does. To refresh on demand (or immediately, e.g. right after upgrading) use the CLI:

```bash
ndslive-mcp update            # download a newer bundle if one is published
ndslive-mcp update --force    # re-download even if the local version matches
```

Bundle versions are unique UTC timestamps, so a freshly published bundle is always detected as newer.

## Development

Building bundles, releasing and deploying are documented for maintainers in the project repository (`docs/development.md`).

## License

The `ndslive-mcp` software is licensed under [BSD-3-Clause](LICENSE) — the same as the [`ndslive-setup`](https://pypi.org/project/ndslive-setup/) installer. The license covers this software (the "hull") only. The NDS.Live specification content delivered as the bundle is **NDS Protected Material**, not part of this package, and remains gated behind NDS Artifactory authentication and governed by your NDS Member Agreement or NDS.Live Evaluation License.
