This file provides guidance to AI Agents when working with code in this repository.
netlicensing-mcp is a FastMCP server that exposes the Labs64 NetLicensing REST API as MCP tools and prompts. It runs over stdio (Claude Desktop / Copilot) or streamable HTTP (remote deployments). Python ≥ 3.12, async throughout.
# Install dev environment (editable + dev extras)
pip install -e ".[dev]"
pip install hatch hatch-vcs
# Run the server locally
python -m netlicensing_mcp.server # stdio (default)
python -m netlicensing_mcp.server http # HTTP on $MCP_HOST:$MCP_PORT (127.0.0.1:8000)
mcp dev src/netlicensing_mcp/server.py # MCP Inspector UI at http://localhost:5173
# Tests (pyproject sets asyncio_mode=auto and turns on coverage automatically)
pytest tests/ -v
pytest tests/test_tools.py::test_name -v # run a single test
pytest tests/ -v --no-cov # disable coverage for faster iteration
# Lint / format / type-check (these are the exact CI checks)
ruff check .
ruff format --check . # use `ruff format .` to apply
mypy src/
# Security
pip-audit --ignore-vuln CVE-2026-4539 --ignore-vuln CVE-2026-3219
# Build & Docker
hatch build --target wheel # version comes from git tag via hatch-vcs
docker build -t ghcr.io/labs64/netlicensing-mcp:latest .CI matrix runs tests on Python 3.12, 3.13, 3.14. Lint/format/mypy run on 3.14 only.
Three layers, top-down:
-
server.py— single FastMCP entry point. Each NetLicensing operation is a thin@mcp.tool()wrapper namednetlicensing_<action>_<entity>(e.g.netlicensing_create_license). The wrapper's only jobs are: (a) translate empty strings /Nonedefaults into "leave unchanged" semantics, (b) call the matching function intools/<entity>.py, (c) wrapNetLicensingErrorinto a JSON error blob via_error(). Tools return JSON strings, not dicts —_json()serializes the result and applies the redaction layer. Token create operations additionally calltag_one_time_display()before_json(); token read operations use_json_token_read()which appliesredact_token_read()to also mask APIKEYnumberand SHOPshopURLfields. -
tools/<entity>.py— one module per NetLicensing entity (products,product_modules,license_templates,licensees,licenses,bundles,tokens,transactions,payment_methods,utilities). Each builds the form-encoded payload, callsnl_get/post/put/delete, and runs the response throughstrip_output_fieldsfromtools/helpers.py. -
client.py— shared asynchttpx.AsyncClient(lazy singleton), Basic-auth header construction, andnl_get/post/put/deletehelpers. Non-2xx responses raiseNetLicensingErrorcarrying the unwrapped NetLicensinginfos.info[].valuemessages. POST/PUT debug logs pass request data throughredact()so sensitive fields are never logged in plaintext. -
responses.py— normalized response envelope (P0.6). Exposeswrap(entity, kind, summary=None, suggested_actions=None, raw=None)andconsole_url(kind, number). Single-item upstream responses become a flat dict with all properties promoted to top level,activecoerced to a JSON bool, and aconsole_urldeep link derived fromMCP_CONSOLE_BASE_URL(defaulthttps://ui.netlicensing.io/#). List responses become{"type": "list", "kind", "count", "items": [...]}withconsole_urlon each item. Every@mcp.tool()acceptsinclude_raw: bool = False; when true the original NetLicensing payload is attached underraw. Server-layer helpers_wrap_json()/_wrap_json_token_read()apply the envelope before_json()redaction; for APIKEY tokens_wrap_json_token_read()dropsconsole_urlbecause the tokennumber(used as the URL path) is itself the API key. -
redaction.py— redaction layer (P0.3). Exposesredact(payload, fields, mode),tag_one_time_display(response), andredact_token_read(response). Default redact set:apiKey,licenseeSecret,nodeSecret,password,secret. Extendable viaMCP_REDACT_FIELDS. Handles both plain dict keys and NetLicensing property arrays ({"property": [{"name": "licenseeSecret", "value": "..."}]}).mode="mask"(default) produces a partial mask (s3c****ecret);mode="remove"drops the field.
API key is held in a contextvars.ContextVar (client.api_key_ctx), defaulted from NETLICENSING_API_KEY. In HTTP mode, ApiKeyMiddleware in server.py reads the key per-request from (in priority order) X-NetLicensing-API-Key header → Authorization: Bearer … → ?apikey= query param, then .set()s the ContextVar for the duration of the request and resets it after. This means a single shared HTTP deployment can serve many tenants without baking a key into the server. Falls back to demo:demo credentials (sandbox) if no key is present.
NetLicensing returns properties as {"property": [{"name": "...", "value": "..."}, ...]} arrays. The client does not flatten these — tool outputs preserve the upstream shape. strip_output_fields (in tools/helpers.py) recursively removes bulky fields (logo) from both plain-dict keys AND property arrays — keep this in sync if more bulky fields show up. Importantly, create/update calls deliberately omit logo, since omission preserves the existing server-side value; never add it back to write paths.
NetLicensing expects booleans as lowercase strings ("true"/"false"). The tools layer handles the conversion. MCP-level signatures use:
bool = True/Falsefor required booleansbool | None = Nonefor optional booleans whereNone= "leave current value alone" on updatesstr = ""for optional strings; the server-layer wrapper converts""→Nonebefore passing down
Preserve this pattern when adding tools — clients (LLMs) will frequently pass empty strings when a field is unspecified.
prompts/audit.py registers five @mcp.prompt() templates (audit_full, audit_customer, audit_expiry, audit_cleanup, audit_anomaly) on the FastMCP instance. They return PromptMessage lists that orchestrate multi-tool audit workflows. New prompts go here and are wired up via register_audit_prompts(mcp).
The long instructions= string passed to FastMCP(...) in server.py documents the NetLicensing entity hierarchy (Product → ProductModule → LicenseTemplate; Product → Licensee → License; Bundles; Transactions; Tokens; PaymentMethods), licensing models, and safety rules (never delete without confirmation; force_cascade only on explicit user request; prefer deactivation over deletion). This is what guides LLM clients — when changing tool behaviour that affects those rules, update the instructions block too.
Environment variables (all optional):
NETLICENSING_API_KEY— required unlessNETLICENSING_ALLOW_DEMO=true; empty without demo flag = fatal error (stdio) or 503 (HTTP)NETLICENSING_ALLOW_DEMO—trueopts in to sandbox demo mode; every tool response is tagged"demo_mode": trueand a warning logs every 60 s; never set in productionNETLICENSING_BASE_URL— defaults tohttps://go.netlicensing.io/core/v2/restMCP_TRANSPORT—stdio(default) orhttp; also via positional CLI argMCP_HOST/MCP_PORT— HTTP bind (default127.0.0.1:8000)MCP_VERBOSE—true|1|yesenables debug logging of API requests/responses (sensitive fields automatically redacted); also via-vflagMCP_REDACT_FIELDS— comma-separated extra field names to add to the redaction set (e.g.ssn,phone); default set:apiKey,licenseeSecret,nodeSecret,password,secretMCP_CONSOLE_BASE_URL— Console UI base forconsole_urldeep links emitted by the normalized response envelope; defaulthttps://ui.netlicensing.io/#
Logs go to stderr (stdio mode must keep stdout clean for the MCP protocol).
deploy/aws/ contains a deploy.sh helper supporting ECS Fargate (production, ALB-fronted) and App Runner (scale-to-zero, dev). HTTP mode exposes /health (returns {"status":"ok"}) for load balancer probes — defined at server.py:130.