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'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:
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
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:
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:
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_indextool 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 instead:
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:
- A request without a token gets
401and aWWW-Authenticatechallenge 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;
issandexp;aud, which must contain the public URL;- optionally, a role in a claim.
- A valid token without the role gets
403with an explanation. /healthzand the metadata stay public.
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
readrole; - the audience scope;
DPL_membersgranted,DPL_publicnot;- 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.
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-mcpgets 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:
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 (JSONL contract 2)
(zip) (released jar, pinned) meta · declarations with their own docs
· module prose blocks · rules
│
+ nds.live.compatibility/*.yaml (categories)
+ portal pages the devportal.yaml sitemaps publish
+ zserio language docs (at the compiler's tag)
+ spec tree (verbatim declaration source)
│
▼
build_index.py
│
▼
index.sqlite (FTS5)
│
▼
ndslive-mcp.zip (index + sitemaps, ~13 MB) + ndslive-mcp.json
(both record every input: spec checksum, doc repo commits,
indexer and builder versions)
│
▼
NDS Artifactory — same folder as the spec zip (gated)
Java only runs at build time. Clients are pure Python — no JRE required.
The indexer decides which doc comment belongs to which declaration on the
zserio AST; build_index.py never parses .zs text for documentation. It
reads the spec tree only to cut out each declaration's verbatim source.
A build fails if the bundle would contain a .git directory or any credential
it used, or if it exceeds its size budget (MAX_BUNDLE_MB, default 50).
See docs/architecture.md for the full design and 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-extensionalongside 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.zipfrom the NDS compatibility-build pipeline; unpacks tondslive/withall.zsat its root).
Install and run tests
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:
# 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 the indexer jar at the pinned version.
# 0. Get the zserio compiler jar (its version must match the indexer's)
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. Get the pinned indexer release (what CI uses)...
gh release download v0.4.0 -R ndsev/nds-live-indexer-extension -p '*-all.jar' -D ../indexer
# ...or build it from a checkout (Gradle 8.x; see that repo's README)
# 2. Build a bundle in this repo using a local spec zip
cd ../ndslive-mcp
ZSERIO_JAR=$ZSERIO_JAR \
INDEXER_JAR=$(ls ../indexer/*-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). |
LOCAL_ZSERIO_DOCS |
Use a local zserio/doc checkout instead of cloning ndsev/zserio at the compiler's tag. |
BUNDLE_VERSION |
Override the version string in ndslive-mcp.json; defaults to a UTC timestamp. |
MAX_BUNDLE_MB |
Size budget in MB (default 50); the build fails above it. |
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:
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:
- In
CHANGELOG.md, move the## [Unreleased]entries under a new## [X.Y.Z] - YYYY-MM-DDheading (leave a fresh empty## [Unreleased]above). - Bump
versioninpyproject.toml. - Commit, then tag and push:
git tag vX.Y.Z && git push origin main --tags
The tag triggers release.yml. It publishes to PyPI (OIDC Trusted Publishing), pushes the server's image to artifactory.nds-association.org/tooling-dockerreg/ndslive-mcp:<version> and :latest, and creates the GitHub Release from that version's CHANGELOG.md section. Because the builder version changed, it also rebuilds and deploys the spec bundle. There is no manual upload or release step.
The spec bundle rebuilds itself when any of its inputs moves. scripts/check_needs_rebuild.sh runs weekly and on every tag, and compares them against the published manifest:
- the spec zip;
- the documentation and compatibility repos' latest commits;
- the builder version;
- the pinned indexer.
To rebuild regardless:
gh workflow run release.yml --ref main -f force=true
Checking a change before release, from any branch, without publishing:
gh workflow run release.yml --ref <branch> -f force=true -f deploy=false # bundle as a run artifact
gh workflow run release.yml --ref <branch> -f image_tag=X.Y.Z-rc1 # image only, never :latest
gh workflow run release.yml --ref <branch> -f force=true -f deploy=false \
-f indexer_extension_ref=<indexer branch> # unreleased indexer, built from source
Adopting a new indexer is deliberate:
- Release an indexer tag: bump
versionin itsgradle.propertiesand merge. - Bump
[tool.ndslive-mcp.build] indexer-extension-refinpyproject.toml. - The next check rebuilds the bundle.
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):
# 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 — the same as the 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.
Release files for ndslive-mcp 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| ndslive_mcp-0.5.0.tar.gz | 145.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ndslive_mcp-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 224.5 kB
Release files / ndslive_mcp-0.5.0.tar.gz
| Download URL | ndslive_mcp-0.5.0.tar.gz |
|---|---|
| Size | 145.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e721d2152b6343cd265757a0f4fe17dfb9cb9aed0f7bba3dc35a29e7a2434fcd
|
|
BLAKE2b-256 checksum How to use checksums |
c8946531e6931cec9df86d6878b8902bf1dfb2f4d88501926f9b4d42e85503a7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.
Transparency logRelease files / ndslive_mcp-0.5.0-py3-none-any.whl
| Download URL | ndslive_mcp-0.5.0-py3-none-any.whl |
|---|---|
| Size | 79.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1afc428d270b9de8e31053755a57ab37421275e6ee03915928d9789e9f8e1be6
|
|
BLAKE2b-256 checksum How to use checksums |
8bdaac0a968141ea9797846a682b86589c3fe9971f7446f71ba10b2fb19430de
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.
Transparency log