Metadata-Version: 2.5
Name: ndslive-mcp
Version: 0.4.0
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
pipx 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: SQLite index, raw schemas, docs) 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 ──► HEAD ndslive-mcp.json on Artifactory  (~100 ms)
                              │
                       ┌──────┴──────┐
                  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**: previous version dirs stay on disk.
- **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.nds.live/mcp \
  --auth-issuer https://sso.nds-association.org/realms/nds \
  --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. |

`deploy/keycloak/configure-realm.sh` sets up a Keycloak realm for this. It
is idempotent and needs only realm-admin rights on that realm
(`KC_ADMIN_REALM=nds`), with no server access or theme files:
- the resource client with its `read` role;
- the audience scope;
- `DPL_members` granted, `DPL_public` not;
- a public client for MCP hosts, with PKCE, and a login flow that turns
  non-members away *on the login page* with the reason and a link to
  chat.nds.live (MCP hosts don't show a server's 403 body: Claude Code only
  reports that the credentials were "rejected on reconnect"). The message is
  an ordinary realm setting (`DENY_MESSAGE`; Authentication → mcp-browser →
  Deny access), re-applied on every run;
- a client-credentials client for chat.nds.live.

### Local stack

`deploy/local/` runs the service the way it will be deployed, on one
machine: Keycloak with that realm, plus the server from this checkout with
sign-in on. It serves the bundle your local `ndslive-mcp` already cached.

```bash
deploy/local/up.sh      # build and start: MCP on :8100, Keycloak on :8180
deploy/local/smoke.sh   # members and chat get in; public, evaluation and wrong-audience tokens don't
docker compose -f deploy/local/compose.yaml down
```

Test users are `member`, `public` and `eval`, each with the username as the
password. Only `member` gets through the login page; the other two see the
members-only message there. chat.nds.live's backend uses client `ndslive-chat` with secret
`local-chat-secret`. To try an interactive login from an MCP host, add
`http://localhost:8100/mcp` with client ID `mcp-hosts`.

## Updating

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

- **Package** — `pipx 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.

`pipx upgrade` 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.

## How it's built

The bundle is built off-band by CI and published to Artifactory:

```
spec sources ──► zserio.jar + indexer-extension ──► symbols.jsonl
                                                      │
                                  + nds.live.compatibility/*.yaml (categories)
                                  + documentation.nds.live  + best-practices  (markdown)
                                                      │
                                                      ▼
                                                build_index.py
                                                      │
                                                      ▼
                                              index.sqlite (FTS5)
                                                      │
                                                      ▼
                                ndslive-mcp.zip + ndslive-mcp.json
                                                      │
                                                      ▼
                            NDS Artifactory — same folder as the spec zip (gated)
```

Java only runs at build time. Clients are pure Python — no JRE required.

See [`docs/architecture.md`](docs/architecture.md) for the full design and [`docs/jsonl-schema.md`](docs/jsonl-schema.md) for the JSONL contract.

## Build and test locally

### Prerequisites

- **Python 3.10+** — for the server itself, the test suite, and the index build step.
- **Java 11+** — only needed if you want to build a fresh bundle end-to-end (the indexer extension runs the zserio compiler). The installed server does not need a JRE.
- **A checkout of [`nds-live-indexer-extension`](https://github.com/ndsev/nds-live-indexer-extension)** alongside this repo, if you want to rebuild the indexer.
- **The zserio compiler jar** via `pip install zserio==2.18.1` (the prod spec zip does not bundle it). The jar lands at `…/site-packages/zserio/compiler/zserio.jar`.
- **A spec bundle zip** for the indexer to consume (e.g. `ndslive.zip` from the NDS compatibility-build pipeline; unpacks to `ndslive/` with `all.zs` at its root).

### Install and run tests

```bash
pip install -e '.[dev]'
pytest                          # hermetic — no Java, no network, no Artifactory
ruff check .
```

The test suite uses synthesized JSONL fixtures and `httpx.MockTransport` so it has no external dependencies. CI runs the same commands across Python 3.10 / 3.11 / 3.12.

### Run the server against an existing bundle

If you already have a `ndslive-mcp.zip` on disk (e.g. from CI or a colleague), point the cache at it and serve in `--offline` mode so it skips the startup Artifactory check:

```bash
# Extract the bundle into a versioned cache dir
mkdir -p ~/.cache/ndslive-mcp/versions/local
unzip -q <path-to-bundle>.zip -d ~/.cache/ndslive-mcp/versions/local/

# Point `current` at it
ln -sfn ~/.cache/ndslive-mcp/versions/local ~/.cache/ndslive-mcp/current

# Serve — no auth required, no network call
ndslive-mcp serve --offline
```

You can iterate on tool definitions in `src/ndslive_mcp/tools/`, restart, and the new code picks up the existing index.

### Build a bundle end-to-end

Useful for testing the full pipeline before pushing. Requires Java 11+ and a built indexer JAR.

```bash
# 0. Get the zserio compiler jar (shared by the indexer build and the index run)
pip install zserio==2.18.1
ZSERIO_JAR=$(python -c 'import zserio, os; print(os.path.join(os.path.dirname(zserio.__file__), "compiler", "zserio.jar"))')

# 1. Build the indexer JAR once (or after extension changes)
cd ../nds-live-indexer-extension
mkdir -p libs && cp "$ZSERIO_JAR" "libs/zserio-2.18.1.jar"   # satisfies compileOnly fileTree('libs')
gradle shadowJar

# 2. Build a bundle in this repo using a local spec zip
cd ../ndslive-mcp
ZSERIO_JAR=$ZSERIO_JAR \
INDEXER_JAR=../nds-live-indexer-extension/build/libs/nds-live-indexer-extension-*-all.jar \
LOCAL_SPEC_ZIP=../_ext/ndslive.zip \
SKIP_DOCS=1 \
bash scripts/build_bundle.sh
# → .work/ndslive-mcp.zip
# → .work/ndslive-mcp.json
```

Then run `ndslive-mcp serve --offline` against the produced bundle (see previous section).

Env-var overrides that short-circuit external fetches for offline iteration:

| Variable | Effect |
|----------|--------|
| `LOCAL_SPEC_ZIP` | Use a local spec zip instead of fetching from Artifactory. |
| `SKIP_DOCS` | Skip cloning `documentation.nds.live` + `best-practices.nds.live` + `nds.live.compatibility` (faster, but no doc FTS and no module categories). |
| `BUNDLE_VERSION` | Override the version string in `ndslive-mcp.json`; defaults to a UTC timestamp. |
| `WORK` | Work directory; defaults to `./.work`. |

Without these overrides, `build_bundle.sh` needs `NDS_ARTIFACTORY_USER` / `NDS_ARTIFACTORY_PAT` to download the spec zip from the NDS compatibility-build pipeline.

### Smoke-check what landed in the bundle

The SQLite index inside the bundle is queryable directly. A quick sanity check after a build:

```bash
python -c "
from pathlib import Path
from ndslive_mcp.store import Store
s = Store(Path('.work/out/index.sqlite'))
print('modules:', len(s.list_modules()))
print('lane versions:', s.get_module_versions('lane'))
print('sample search:', [r.qname for r in s.search('LaneGroup', limit=3)])
"
```

## Releasing

Maintainer steps. The **package** (PyPI) and the **spec bundle** (Artifactory) release independently.

**Cut a package release `vX.Y.Z`:**

1. In `CHANGELOG.md`, move the `## [Unreleased]` entries under a new `## [X.Y.Z] - YYYY-MM-DD` heading (leave a fresh empty `## [Unreleased]` above).
2. Bump `version` in `pyproject.toml`.
3. Commit, then tag and push:
   ```bash
   git tag vX.Y.Z && git push origin main --tags
   ```

The tag triggers `release.yml`, which publishes to PyPI (OIDC Trusted Publishing) **and** creates the GitHub Release using that version's `CHANGELOG.md` section as the notes. No manual PyPI upload or release step.

**Rebuild the spec bundle** — only when the indexer, the build pipeline, or the upstream spec changed (the weekly job already rebuilds automatically when the spec zip's checksum changes):

```bash
gh workflow run release.yml --ref main -f force=true
```

This rebuilds the index from the indexer pinned in `pyproject.toml` (`[tool.ndslive-mcp.build] indexer-extension-ref`) and deploys via the step below. Adopting a new indexer is deliberate: release an indexer tag, bump that pin, then force a rebuild.

## Deploy

CI runs `scripts/deploy_bundle.sh` automatically as the final step of the release workflow. To deploy by hand (emergency push, or to test the deploy path without merging to main):

```bash
# 1. Build the bundle, embedding the production publish URL
NDS_BUNDLE_PUBLISH_URL=https://artifactory.nds-association.org/artifactory/<repo>/<path> \
INDEXER_JAR=../nds-live-indexer-extension/build/libs/nds-live-indexer-extension-*-all.jar \
NDS_ARTIFACTORY_USER=$USER \
NDS_ARTIFACTORY_PAT=$ART_PAT \
bash scripts/build_bundle.sh

# 2. Dry-run to confirm the upload destinations
DRY_RUN=1 bash scripts/deploy_bundle.sh

# 3. Real upload — bundle first, then ndslive-mcp.json (order matters: keeps clients consistent)
NDS_ARTIFACTORY_USER=$USER NDS_ARTIFACTORY_PAT=$ART_PAT \
NDS_BUNDLE_PUBLISH_URL=https://artifactory.nds-association.org/artifactory/<repo>/<path> \
bash scripts/deploy_bundle.sh
```

The deploy script refuses to run if `ndslive-mcp.json`'s embedded `url` doesn't match `NDS_BUNDLE_PUBLISH_URL` — that mismatch means the build was done with a stale URL and clients would 404.

## 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.
