Skip to content

Developer Guide

Design, implementation, testing, and extension guide. Pair with SYSTEM_PROMPT.md (agent-facing behavior contract) and the project README (user-facing tool reference).


Design

Tool surface

Ten read-only tools, grouped by intent:

Intent Tool
Free-text search netbox_search
Filtered list (bounded / exhaustive) netbox_get / netbox_get_all
"Top N devices by X" structural aggregation netbox_aggregate_by_device
Composite device view netbox_device_profile
Cross-table IP fan-out (incl. free-text descriptions) netbox_find_ip_references
Cable path tracing netbox_trace_path
Free-capacity queries (IPs, prefixes, VLANs, ASNs) netbox_get_available
Schema introspection netbox_inspect_type
Raw GraphQL escape hatch netbox_query

Transport: REST for list/filter, GraphQL for introspection and power use

NetBox's Strawberry GraphQL schema exposes list fields as plain list[T] without sibling _count fields, rejects __in lookups on scalar ID filters, and nests pagination inside a pagination input. REST supports the shape we need natively (?site_id=1&site_id=2 for multi-ID, count/next/previous metadata, ?fields= for projection), so list/filter goes through REST. GraphQL is reserved for introspection and netbox_query. Full rationale in the module docstring at the top of services/query_service.py.

Response envelope

Every list-like tool returns a consistent envelope with agent-facing signals. The system prompt treats these as a mandatory checklist — if you change them, update SYSTEM_PROMPT.md in the same commit.

Field Purpose
results The objects.
total_count, has_more Completeness.
suggested_next Pre-built filter dicts for likely follow-up calls. Returned by netbox_get / netbox_get_all (QueryService).
suggested_filters Same intent, different shape — keyed per-source-type. Returned by netbox_search (SearchService).
next_steps Concrete tool-call templates with real IDs — the server telling the agent what to do next.
type_guidance Curated hint for how this type is meant to be queried (e.g. "use assigned_object, not device").
fields_dropped + fields_dropped_hint NetBox's REST ?fields= silently ignored an unknown field — we surface it.
select_collapse_note Fires when a dotted select path can't be resolved at the requested depth via REST.
match_counts[type].truncated (search) Per-type cap detection so agents can re-query with higher limit or switch to _all.
tool_name The __name__ of the called tool (e.g. "netbox_get"). Injected by the with_response_envelope decorator at registration time. Present on every tool response. Use in audit trails and UI drawers.
elapsed_ms Server-side wall-clock execution time in integer milliseconds (time.perf_counter_ns() // 1_000_000). Injected by the same decorator. Present on every tool response. More accurate than browser-measured latency, which includes bridge and network overhead.
display_hint Per-tool renderer guidance. Always present. frame (required) names the suggested visual frame from FRAME_VOCABULARY in tools/_display_hint.py; primary_key (optional) is a dotted path to the row identifier; title (optional) is a short human label. Set via result.setdefault("display_hint", build_display_hint(...)) at the end of each tool function — tool-provided values take precedence.

Layered architecture

Strict top-down dependency direction:

tools/       @mcp.tool wrappers. Thin. Docstrings are the LLM-facing contract.
services/    Business logic: query compilation, pagination, enrichment, signal construction.
clients/     httpx transport. REST + GraphQL share one httpx.Client.
schema/      Pure transformations. No I/O.

Wiring is injected, not imported: server.main() builds a ServiceRegistry, stores it via set_services(), and tools reach it through get_services(). Tests swap the registry in tests/conftest.py without touching production code.

Auth lives in clients/base.build_http_client: tokens prefixed nbt_ use Bearer, everything else uses Token. One place.

Read-only by design

No create/update/delete surface. Writes widen attack surface; the agentic value is in investigation. Users who need writes call NetBox's REST/UI directly. Plugin object types are out of scope — the registry curates core types only.


Implementation

Layer responsibilities

tools/ — One function per MCP tool. Signature is pydantic-annotated (Annotated[int, Field(ge=1, le=100)]) for validation at the MCP boundary. Body is a one-liner: get_services(), call the service, return. Docstring is LLM-facing — invest in it. Never add business logic here.

services/ — Business logic. Each service owns one coherent concern. Services depend on RestClient / GraphQLClient, never on each other — composition is at the registry level. Services compute the agent-signal fields above.

clients/ — Transport only. Translate HTTP-level failures into NetBoxAPIError / SchemaIntrospectionError so upper layers never see raw httpx exceptions.

schema/ — Pure, no I/O:

  • object_types.py — canonical registry (object type → REST endpoint + GraphQL field + optional agent guidance).
  • filter_builder.py — REST-style filter grammar → wire params. Notable quirk: __in suffix is rewritten to repeated bare params because NetBox accepts ?site_id=1&site_id=2 but not ?site_id__in=1&site_id__in=2.
  • select_compiler.py — dotted-path → GraphQL selection compilation.
  • suggested_filters.pySUGGESTION_RULES dict of (key, target_type, fk_field) used to build the suggested_next follow-up blocks.

Exception hierarchy

All custom exceptions subclass AgentMCPError → ValueError so FastMCP serializes them uniformly. Each carries structured attributes agents pattern-match on — UnknownObjectTypeError.valid, CapExceededError.total_count, NetBoxAPIError.status. Add new classes to exceptions.py; don't raise bare ValueError.

Configuration

config.Settings is a pydantic_settings.BaseSettings. Every field uses AliasChoices("foo", "FOO") so it accepts both the CLI overlay key (lowercase) and the env var (uppercase). server.parse_cli_args() builds the overlay; Settings(**overlay) merges. Precedence: CLI > env > default.


Testing

Layout

tests/
├── conftest.py                  shared fixtures (including live_registry)
├── unit/
│   ├── clients/                 RestClient / GraphQLClient (respx-mocked)
│   ├── schema/                  pure-function tests
│   ├── services/                service tests (MagicMock'd clients)
│   └── tools/                   tool wrapper tests (MagicMock'd ServiceRegistry)
├── integration/                 real NetBox required
├── contract/                    schema-drift detectors, real NetBox required
└── fixtures/                    canned REST and GraphQL responses

Markers

integration and contract are excluded by default via pyproject.toml's addopts. integration needs NETBOX_URL + NETBOX_TOKEN; contract asserts every registered object type is queryable and critical types expose the filters we assume.

For a local NetBox to run these against:

git clone https://github.com/netbox-community/netbox-docker.git ../netbox-docker
scripts/netbox_docker_up.sh        # brings up NetBox, waits for readiness
scripts/netbox_docker_down.sh      # tears down

Fixture conventions

  • Services / tools: mock the boundary below. Tool tests mock ServiceRegistry; service tests mock RestClient / GraphQLClient. Don't mock deeper — tests become brittle to refactors.
  • Clients: respx at the HTTP layer.
  • Integration: use live_registry from tests/conftest.py. Always gate on env vars via pytest.skip; never hard-fail when env is missing.
  • Canned fixtures: tests/fixtures/{rest_responses,graphql_responses}/ for cases where recording a real response is cheaper than constructing one inline.

What to test at each layer

Layer Focus
schema/ Pure input → pure output. Exhaustive; everything relies on these.
clients/ Auth header selection, error translation, pagination cursors.
services/ Envelope construction, signal computation (next_steps, fields_dropped, select_collapse_note), pagination, cap enforcement.
tools/ Argument validation, registry lookup, passthrough. Very thin.
contract/ Every registered type queryable; critical types expose assumed fields. Catches NetBox schema drift.

Extending

Add a new object type

  1. Register in schema/object_types.py::OBJECT_TYPES with rest + gql. Add guidance if there's a common dead-end pattern worth steering agents away from.
  2. Add rules to schema/suggested_filters.py::SUGGESTION_RULES if this type is a plausible source in a search → follow-up chain.
  3. Unit test the registry entry in tests/unit/schema/test_object_types.py.
  4. Add to CRITICAL_TYPES in tests/contract/test_schema_assumptions.py if first-class.

Add a new tool

  1. Service method — add to the relevant service or create a new one. Return the standard envelope.
  2. Wire it into ServiceRegistry and build_services() in server.py if you added a new service class.
  3. Tool wrapper under tools/ — thin, pydantic-annotated, rich docstring.
  4. Register via mcp.tool(<fn>) in server.py.
  5. Tests: service with mocked clients; tool wrapper with mocked registry. Integration test if behavior needs end-to-end validation.
  6. Docs: if the tool adds a new intent or signal, update SYSTEM_PROMPT.md (intent table + "Read server signals") and README.md (Tools section).

Add a new response signal

  1. Compute in the service.
  2. Document in the tool's docstring "Returns" section.
  3. Add a row to the response-envelope table in this doc.
  4. Add a row to SYSTEM_PROMPT.md §"Read server signals on every response".
  5. Unit-test in the service tests.

Upgrade NetBox

  1. Bring up the new version locally (scripts/netbox_docker_up.sh).
  2. Run uv run pytest -m contract — this suite's entire job is catching schema drift.
  3. If a type fails introspection, either its GraphQL name moved (update object_types.py) or it was removed (drop it, update CRITICAL_TYPES and README).
  4. Note breaking changes in CHANGELOG.md.