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:__insuffix is rewritten to repeated bare params because NetBox accepts?site_id=1&site_id=2but not?site_id__in=1&site_id__in=2.select_compiler.py— dotted-path → GraphQL selection compilation.suggested_filters.py—SUGGESTION_RULESdict of(key, target_type, fk_field)used to build thesuggested_nextfollow-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 mockRestClient/GraphQLClient. Don't mock deeper — tests become brittle to refactors. - Clients:
respxat the HTTP layer. - Integration: use
live_registryfromtests/conftest.py. Always gate on env vars viapytest.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¶
- Register in
schema/object_types.py::OBJECT_TYPESwithrest+gql. Addguidanceif there's a common dead-end pattern worth steering agents away from. - Add rules to
schema/suggested_filters.py::SUGGESTION_RULESif this type is a plausible source in a search → follow-up chain. - Unit test the registry entry in
tests/unit/schema/test_object_types.py. - Add to
CRITICAL_TYPESintests/contract/test_schema_assumptions.pyif first-class.
Add a new tool¶
- Service method — add to the relevant service or create a new one. Return the standard envelope.
- Wire it into
ServiceRegistryandbuild_services()inserver.pyif you added a new service class. - Tool wrapper under
tools/— thin, pydantic-annotated, rich docstring. - Register via
mcp.tool(<fn>)inserver.py. - Tests: service with mocked clients; tool wrapper with mocked registry. Integration test if behavior needs end-to-end validation.
- Docs: if the tool adds a new intent or signal, update
SYSTEM_PROMPT.md(intent table + "Read server signals") andREADME.md(Tools section).
Add a new response signal¶
- Compute in the service.
- Document in the tool's docstring "Returns" section.
- Add a row to the response-envelope table in this doc.
- Add a row to
SYSTEM_PROMPT.md§"Read server signals on every response". - Unit-test in the service tests.
Upgrade NetBox¶
- Bring up the new version locally (
scripts/netbox_docker_up.sh). - Run
uv run pytest -m contract— this suite's entire job is catching schema drift. - If a type fails introspection, either its GraphQL name moved (update
object_types.py) or it was removed (drop it, updateCRITICAL_TYPESand README). - Note breaking changes in
CHANGELOG.md.