MCP server for LenelS2 S2 NetBox access control via the NBAPI. Read-only by default.
A local MCP server that exposes LenelS2 S2 NetBox NBAPI operations — persons/credentials, access levels, portals/readers/outputs, time specs, holidays, portal/reader groups, threat levels, events/activity, and partitions/UDF lists — as MCP tools usable from any MCP-compatible client (Claude, Gemini/Antigravity, etc.).
Not the open-source netboxlabs.com "NetBox" DCIM/IPAM tool. This targets LenelS2's S2 NetBox physical access-control appliance and its NBAPI (
Web-Based API for S2 NetBox and S2 Global, LenelS2 doc #API-UG-14). Newer NBAPI v1 (doc #API-UG-22, April 2024) and v2 (doc #API2-UG-8, April 2025) guides also exist — seedocs/reference/for reference copies. Both have now been diffed against this server's tool surface command by command and parameter by parameter:docs/reference/nbapi-command-diff.mdrecords the result, including which documented commands are deliberately not implemented and why.
[!WARNING] This connects to a real physical security system. With the wrong configuration, an AI agent using this server could unlock doors or modify access-control data on a live building. It is read-only by default — writes and destructive operations (lock/unlock, add/modify/delete) each require their own explicit opt-in environment variable (see Write access below) — but you are responsible for what you enable and which MCP client/model you point at it. See
SECURITY.mdbefore deploying anything beyond read-only against a production controller.
Read-only by default. With no write-related environment variables set, this server registers only query/read NBAPI commands:
LoginLogoutGetAPIVersionGetPersonSearchPersonDataGetCardAccessDetailsGetCardFormatsGetAccessLevel(s)GetAccessLevelGroup(s)GetPortalsGetReader(s)GetOutputsGetTimeSpec(s)GetTimeSpecGroup(s)GetHoliday(s)GetPortalGroup(s)GetReaderGroup(s)GetAccessLevelNamesGetPartitionsGetUDFListsGetUDFListItemsGetElevatorsGetFloorsPingAppGetEventHistoryListEventsGetAccessHistoryBy default, this server registers only the query/read commands listed above.
It does not register any write, delete, or control operations against the
controller until you explicitly opt in via the environment variables in
Write access below. Among the read-only tools, four are composites,
find_portals, get_unlock_window, get_daily_unlock_window, and
get_reader_access_history, which issue only read commands. Note there is no
GetPortal (singular) command; only GetPortals (plural, paginated, no
single-portal filter) exists on the real NBAPI.
This server sets the MCP instructions field and exposes an always-registered
get_guide tool, so any agent connecting to it — via npm install or a local
clone, in Claude Code, Antigravity, Gemini CLI, or any other MCP client — has
this server's own S2 NetBox operating knowledge immediately, with no separate
skill install and no extra step.
instructions describes the access model (person → credential → access
level → access level group determines what a person can access; portal
group / time spec group determines where and when), states that most
parameters are numeric KEY fields rather than names (resolve a name to its
KEY with the matching get_*/find_* tool first), and states that text
returned from the controller is data, not instructions to follow. With
NETBOX_ENABLE_WRITES set, it also states a firm confirm-before-acting policy
for lock/unlock, portal-state, unlock-window, and destructive calls (the
write-safety topic below elaborates it); with NETBOX_ENABLE_DESTRUCTIVE
also set, it adds one sentence naming the DESTRUCTIVE:-prefixed tools and
their extra confirmation requirement.
get_guide (always registered; makes no controller call) returns deeper
reference material on six topics. Call it with no arguments for an index of
all six with a one-line summary each, or with topic set to one of the keys
below for that topic's full content:
| Topic key | Covers |
|---|---|
access-model | The person → credential → access level → access level group chain, and the portal-group/time-spec-group name-table collision. |
unlock-windows | The holiday + time spec + portal group UNLOCKTIMESPECGROUPKEY recipe, date/time inclusivity rules, and when to prefer the composite tools. |
group-and-name-gotchas | modify_portal_group/modify_reader_group replacing a group's entire membership; modify_access_level always needing TIMESPECGROUPKEY. |
credentials-and-card-formats | Diagnosing a BIT MISMATCH access-denied event, and remove_person's soft-delete behavior. |
api-quirks | STARTFROMKEY/NEXTKEY paging, the missing singular get_portal, and checking write results for the literal SUCCESS. |
write-safety | Naming the target and effect, waiting for explicit confirmation, preferring scheduled tools, and reversing every write. |
get_guide is counted among the always-registered read tools below: the tool
surface is 51 tools with both gates off, 98 with
NETBOX_ENABLE_WRITES, and 113 with NETBOX_ENABLE_DESTRUCTIVE as well.
Node.js >= 18.17 (tested on Node 24), which includes npm. If Node.js isn't installed, on
Windows you can install it with winget, then open a new terminal so node/npm are on
your PATH:
winget install OpenJS.NodeJS.LTS
An S2 NetBox controller reachable from wherever this server runs, with the NBAPI enabled and configured for session-login authentication (not MAC authentication — see the spec for why that's out of scope for v1)
A NetBox operator account with API access and read permission on the resources you want to query
Two ways to get the server:
Option A — npm (no clone needed):
npm install -g s2-netbox-mcp
This installs the s2-netbox-mcp binary; point your MCP client's command at
s2-netbox-mcp directly (no node dist/index.js needed).
Option B — clone and build:
npm install
npm run build
Either way, copy .env.example to .env and fill in real values (or provide the same
variables directly in your shell / in the Claude Code MCP server config's
env block — see below). Never commit .env — it's already gitignored.
cp .env.example .env
# edit .env
Start the server directly to sanity-check it boots (it just waits on stdio
for an MCP client — Ctrl+C to stop; this also sends Logout if a session was
opened):
npm start
| Variable | Required | Default | Description |
|---|---|---|---|
NETBOX_BASE_URL | Yes | — | Base URL of the NetBox controller's web interface, e.g. https://netbox.example.internal. No trailing slash or path — the client appends NETBOX_API_PATH itself. |
NETBOX_USERNAME | Yes | — | NBAPI session-login username. |
NETBOX_PASSWORD | Yes | — | NBAPI session-login password. Never logged, never written to any tracked file. |
NETBOX_ALLOW_INSECURE_TLS | No | false | Set to true/1/yes to accept a self-signed/on-prem TLS certificate. Explicit opt-in only — any other value (including unset) keeps normal certificate verification. |
NETBOX_API_PATH | No | /nbws/goforms/nbapi | The NBAPI path appended to NETBOX_BASE_URL. The default is the verified path on NetBox 6.x controllers. Only set this to override the default — e.g. to the legacy, pre-6.x path /goforms/nbapi, which returns HTTP 410 Gone on 6.x controllers (see Controller prerequisites below). A value without a leading / has one added automatically. |
NETBOX_ENABLE_WRITES | No | false | Set to true/1/yes to register the write tools (see Write access below). Unset (or any other value) leaves the server strictly read-only. |
NETBOX_ENABLE_DESTRUCTIVE | No | false | Set to true/1/yes, together with NETBOX_ENABLE_WRITES, to additionally register the 11 destructive tools (see Write access below). |
NETBOX_EVENT_API_PATH | No | tracks NETBOX_API_PATH | Request path used only for trigger_event. Unset/empty tracks whatever NETBOX_API_PATH resolves to; a non-empty override is used verbatim (leading / added if missing) — e.g. the doc's pre-6.x Event API path /appd/nbapi, if your controller serves it separately. |
NETBOX_UNLOCK_HOLIDAY_GROUPS | No | 8,7,6 | The holiday groups reserved for the managed unlock window, in first,middle,last segment order — see Scheduled unlock windows below. Must be 1-3 distinct integers in 1..8, comma-separated; reserve groups nothing else on the controller uses. |
NETBOX_UNLOCK_NAME_PREFIX | No | MCP Unlock Window | Name prefix of every object the managed unlock window creates: the portal group (<prefix>), the time spec group (<prefix> time specs), and the per-segment holidays/time specs (<prefix> first/middle/last). 1-40 characters so the longest name (<prefix> time specs) fits the 64-character NAME limit. |
NETBOX_DAILY_UNLOCK_HOLIDAY_GROUP | No | 5 | The single holiday group reserved for the managed daily recurring unlock window — see Scheduled daily unlock windows below. Must be a single integer in 1..8, and must not be a member of NETBOX_UNLOCK_HOLIDAY_GROUPS (the two features' reserved groups can never collide). |
NETBOX_DAILY_UNLOCK_NAME_PREFIX | No | MCP Daily Unlock Window | Name prefix of every object the managed daily unlock window creates: the portal group (<prefix>), the time spec group (<prefix> time specs), and the one holiday/time spec (<prefix> schedule). 1-40 characters so the longest name fits the 64-character NAME limit. |
NETBOX_LIVE_TEST_PORTALKEY | No | — | The PORTALKEY of the one door you designate safe to physically unlock during npm run test:live:write/npm run test:live:write:daily. Read only by those scripts, never by the server itself. |
If any of the three required variables is missing, the server prints a single actionable line to stderr and exits with a non-zero status — it never prints a stack trace on startup misconfiguration.
Write/control tools exist in this server but are not registered unless you explicitly opt in:
NETBOX_ENABLE_WRITES=true registers the write tools listed in the
"Write tools" table below — the 45 pass-through tools (creating, modifying,
locking/unlocking, activating, and triggering) plus the three composite
write tools set_portals_state, schedule_unlock_window, and
cancel_unlock_window. Left unset (the default), the server's tool
surface is exactly the read tools below — byte-for-byte the same read-only
posture as before this variable existed.<prefix> first|middle|last — see
Scheduled unlock windows), and do so without NETBOX_ENABLE_DESTRUCTIVE
because those objects are server-owned; they never delete anything else.NETBOX_ENABLE_DESTRUCTIVE=true, set in addition to
NETBOX_ENABLE_WRITES, registers the 11 destructive tools (each
description is DESTRUCTIVE:-prefixed): delete_access_level,
delete_access_level_group, delete_holiday, delete_portal_group,
delete_reader_group, delete_time_spec, delete_time_spec_group,
remove_credential, remove_person, remove_threat_level,
remove_threat_level_group. Two ordinarily non-destructive write tools
also independently refuse one specific destructive-shaped call when this
flag is off, regardless of whether the tool itself is registered:
modify_person refuses a call with DELETED="TRUE" or
PERSONPURGE="TRUE", and modify_udf_list_items refuses a call where any
list item has DELETE="1" — both name NETBOX_ENABLE_DESTRUCTIVE in the
error and send nothing to the controller.WRITE: (or DESTRUCTIVE: for
the 11 above), and every successful write's result text contains the
literal SUCCESS followed by the controller's response data as pretty
JSON (which may be {} when the command returns no data), so you can
always tell a write actually happened.READERKEY or READERGROUPKEY, not
both") reject malformed calls with a tool error before any NBAPI
command is issued — no partial or guessed request ever reaches the
controller.Set these the same way as the other variables — in .env (see
.env.example) or your MCP server config's env block.
Before this server can talk to your controller, on the NetBox web UI go to Configuration → Site Settings → Network Controller → Data Integration and confirm all three of these are checked:
The NBAPI user account also needs a role with NBAPI read access (see the
NBAPI doc's "Setting Up User Roles for the API" section) — a login that
succeeds but can't read the resources this server queries will surface as
FAIL or APIERROR responses per tool call. If you set
NETBOX_ENABLE_WRITES, that role needs Read-Write API privilege instead
(Configuration → Site Settings → User Roles → API Privilege) — Read-Only
suffices only for the read tools.
APIERROR 5." This is
the live-observed symptom of the Use login username/password for
authentication (requires setup privilege) checkbox being unticked, which
puts the controller in MAC-authentication mode instead of session-login
mode (MAC auth is out of scope for this server — see the spec). Login
still returns SUCCESS with a session ID, but every subsequent command —
including Logout — fails with APIERROR 5. Fix: tick that checkbox on
the Data Integration tab. This server's client detects this exact
pattern (a successful re-login followed by another APIERROR 5) and
surfaces a tool error naming the checkbox directly.NETBOX_API_PATH is not served by this
controller. NetBox 6.x serves the NBAPI at /nbws/goforms/nbapi (the
default this server uses); the 2020 doc's /goforms/nbapi path is
deregistered on 6.x and returns 410 for every request. If you're on a
pre-6.x controller, set NETBOX_API_PATH=/goforms/nbapi explicitly; if
you're on 6.x and still see this, double-check NETBOX_API_PATH isn't set
to something else by mistake.This server works with any MCP client that speaks the standard mcpServers
stdio config shape — Claude Code, Antigravity, and Gemini CLI have all been
verified against it directly. Which JSON to use depends on which Setup
option you picked above:
If you installed via npm (Setup Option A):
{
"mcpServers": {
"s2-netbox-mcp": {
"command": "s2-netbox-mcp",
"env": {
"NETBOX_BASE_URL": "https://netbox.example.internal",
"NETBOX_USERNAME": "svc-account",
"NETBOX_PASSWORD": "REPLACE_ME",
"NETBOX_ALLOW_INSECURE_TLS": "false"
}
}
}
}
If you cloned and built locally (Setup Option B):
{
"mcpServers": {
"s2-netbox-mcp": {
"command": "node",
"args": ["/absolute/path/to/s2-netbox-mcp/dist/index.js"],
"env": {
"NETBOX_BASE_URL": "https://netbox.example.internal",
"NETBOX_USERNAME": "svc-account",
"NETBOX_PASSWORD": "REPLACE_ME",
"NETBOX_ALLOW_INSECURE_TLS": "false"
}
}
}
}
Replace the args path with the actual absolute path to dist/index.js on
your machine, and replace the env values with your real controller details
(or omit env entirely and rely on a .env file next to the project if you
prefer — either works, since src/index.ts loads .env via dotenv before
reading process.env). Run npm run build first so dist/index.js exists.
Where to put that JSON depends on the client:
| Client | Config file | Notes |
|---|---|---|
| Claude Code | .mcp.json in your project, or claude_desktop_config.json | Or add non-interactively via claude mcp add-json s2-netbox-mcp '<json>' |
| Antigravity | ~/.gemini/config/mcp_config.json (Windows: %USERPROFILE%\.gemini\config\mcp_config.json) | Global — applies to every Antigravity session |
| Gemini CLI | ~/.gemini/settings.json, under its own mcpServers key | Or add non-interactively via gemini mcp add s2-netbox-mcp <command> [args] -e KEY=value |
All three use the identical mcpServers object shape shown above — only the
surrounding file and location differ.
| Tool | Wraps NBAPI command | Required params |
|---|---|---|
check_connection | GetAPIVersion | — |
get_guide | — (pure in-process lookup; no controller call) | — (optional topic) |
get_person | GetPerson | PERSONID |
search_person_data | SearchPersonData | — (all filters optional) |
get_card_access_details | GetCardAccessDetails | ENCODEDNUM, CARDFORMAT (optional MAXRECORDS/OLDESTDTTM/RESOLVENAMES/RESOLVEDESCRIPTIONS) |
get_card_formats | GetCardFormats | — |
get_access_level | GetAccessLevel | ACCESSLEVELKEY (optional RESOLVEGROUPNAMES) |
get_access_levels | GetAccessLevels | — (optional PARTITIONKEY/STARTFROMKEY/STARTFROMNAME/WANTKEY) |
get_access_level_group | GetAccessLevelGroup | ACCESSLEVELGROUPKEY |
get_access_level_groups | GetAccessLevelGroups | — (optional STARTFROMKEY/PARTITIONKEY) |
get_access_level_names | GetAccessLevelNames | — (optional PARTITIONKEY/STARTFROMNAME) |
get_portals | GetPortals | — (optional STARTFROMKEY/RESOLVEDESCRIPTIONS; no single-portal filter — returns each portal with its nested readers) |
get_reader | GetReader | READERKEY |
get_readers | GetReaders | — (optional STARTFROMKEY; no portal-id filter) |
get_outputs | GetOutputs | — (optional STARTFROMKEY) |
find_portals | GetPortals + GetReaders (composite) | query (search terms) |
get_event_history | GetEventHistory | — (optional EVENTNAME/STARTDTTM/ENDDTTM/NEXTKEY) |
list_events | ListEvents | — (optional RESOLVEPARTITIONNAMES, default true) |
get_access_history | GetAccessHistory | — (optional STARTLOGID/AFTERLOGID/ORDER/MAXRECORDS/ENCODEDNUM/HOTSTAMP/CARDFORMAT/RESOLVENAMES/RESOLVEDESCRIPTIONS) |
get_reader_access_history | GetAccessHistory + GetPerson + GetReaders (composite) | READERKEY (optional SCANWINDOW/MAXMATCHES/RESOLVEDESCRIPTIONS) |
get_time_spec | GetTimeSpec | TIMESPECKEY |
get_time_specs | GetTimeSpecs | — (optional STARTFROMKEY) |
get_time_spec_group | GetTimeSpecGroup | TIMESPECGROUPKEY |
get_time_spec_groups | GetTimeSpecGroups | — (optional STARTFROMKEY/RESOLVEMEMBERNAMES) |
get_holiday | GetHoliday | HOLIDAYKEY |
get_holidays | GetHolidays | — (no calling parameters) |
get_portal_group | GetPortalGroup | PORTALGROUPKEY (optional RESOLVEGROUPNAMES) |
get_portal_groups | GetPortalGroups | — (optional STARTFROMKEY/RESOLVEGROUPNAMES) |
get_reader_group | GetReaderGroup | READERGROUPKEY |
get_reader_groups | GetReaderGroups | — (optional STARTFROMKEY) |
get_partitions | GetPartitions | — |
get_udf_lists | GetUDFLists | — |
get_udf_list_items | GetUDFListItems | UDFLISTKEY |
get_elevators | GetElevators | — (optional STARTFROMKEY) |
get_floors | GetFloors | — (optional STARTFROMKEY) |
ping_app | PingApp | — |
get_threat_levels | GetThreatLevels | — (optional ALLPARTITIONS) |
get_unlock_window | GetPortalGroups + GetPortalGroup + GetTimeSpecGroups + GetTimeSpecs + GetHolidays + GetHoliday (composite) | — |
get_daily_unlock_window | GetPortalGroups + GetPortalGroup + GetTimeSpecGroups + GetTimeSpecs + GetHolidays (composite) | — |
get_portal_states | GetPortalStates | — (optional PORTALSTATES) |
get_portal_statuses | GetPortalStatuses | — (optional ALLPARTITIONS/PORTALKEY/STATEKEY/PARTITIONKEY/LOCATIONKEY; the live state of each door, not its configuration) |
get_locations | GetLocations | — (optional ALLPARTITIONS/STARTFROMKEY) |
get_alarms | GetAlarms | — (optional ALLPARTITIONS/PARTITIONKEY/ID/EVENTID/ACTIVITYID/OWNERID) |
get_picture | GetPicture | PERSONID (returns a Base64 JPEG in PICTURE; may be large) |
get_virtual_credential_request | GetVirtualCredentialRequest | PERSONID, CARDFORMAT |
get_mercury_panels | GetMercuryPanels | — (optional ALLPARTITIONS/PARTITIONKEY/MERCURYKEY/NAME) |
get_mercury_panel | GetMercuryPanel | MERCURYKEY |
get_network_nodes | GetNetworkNodes | — (optional ALLPARTITIONS/PARTITIONKEY/NODEKEY/UNIQUEIDENTIFIER/NAME) |
get_network_node | GetNetworkNode | NODEKEY (optional PARTITIONKEY) |
get_sios | GetSios | MERCURYKEY (no unfiltered SIO listing exists) |
get_sio | GetSio | SIOKEY |
There is deliberately no get_portal (singular) tool — no such NBAPI command
exists; only GetPortals (plural) does. get_card_access_details and
get_access_history identify a card by ENCODEDNUM/CARDFORMAT (and
get_access_history optionally by HOTSTAMP), not by PERSONID — neither
command has a PERSONID parameter.
Every read tool except find_portals returns a thin JSON pass-through of
that NBAPI command's response fields — no reshaping. Each tool's input
schema declares exactly the documented PARAMS fields for its command — no
invented, renamed, or passthrough fields. All NBAPI parameter names above
are copied verbatim from the NBAPI Command Reference (see
specs/archive/s2-netbox-mcp-write.md and the archived specs/archive/s2-netbox-mcp.md)
— none are invented or guessed.
Nine tools are composites — they combine several NBAPI commands and reshape
the result instead of passing one command through: find_portals,
get_unlock_window, get_daily_unlock_window, and get_reader_access_history
(read-only, always registered), and set_portals_state,
schedule_unlock_window, cancel_unlock_window, schedule_daily_unlock_window,
and cancel_daily_unlock_window (write, registered only with
NETBOX_ENABLE_WRITES). Every composite reads list commands fully
paginated (following NEXTKEY until -1, or AFTERLOGID/NEXTLOGID over a
bounded SCANWINDOW for get_reader_access_history) and issues only
commands from the closed allowlist. set_portals_state locks, unlocks (Extended Unlock until
locked again), or momentarily unlocks many portals in one call — the given
portalKeys or every portal — issuing one command per portal sequentially and
never stopping on a single failure; its result partitions the portals into
succeeded, alreadyInState (the controller's "Portal state not changed"),
and failed, and is an error only when failed is non-empty. The five
unlock-window tools are described under Scheduled unlock windows and
Scheduled daily unlock windows below.
find_portals is for finding a door when you only know
where it is. Portal names are site codes (01OF05A), and the only
human-readable location text on the controller is each reader's DESCRIPTION.
GetPortals doesn't return it, and neither command takes a filter. So
find_portals reads every page of GetPortals and GetReaders, joins them by
READERKEY, and returns the portals where every term of query appears
(case-insensitive) in the portal name, a reader name, or a reader description.
For example, "maintenance office" matches a reader described as
BREAKROOM TO MAINTENANCE OFFICE. Each match includes its readers' names and
descriptions. The result also lists portalsWithoutDescriptions: portals none
of whose readers has a description, which can only be found by name. It issues
no commands beyond those two.
get_portals itself also accepts RESOLVEDESCRIPTIONS (default true —
on by default, the same opt-out default as every other RESOLVEDESCRIPTIONS
flag in this codebase): unless explicitly set to false, it fills in each
nested reader's own DESCRIPTION field — GetPortals never populates it,
only READERKEY/NAME/PORTALORDER — via one GetReaders full-table fetch
per call (not per portal/reader), using the same src/readerDescriptions.ts
helper as the other RESOLVEDESCRIPTIONS tools. Unlike those tools, which add
a new sibling field (READERDESCRIPTION) to flat records, this fills
DESCRIPTION in directly on each nested reader object, since that's that
reader's own native GetReaders field name. Set RESOLVEDESCRIPTIONS: false
to get readers back exactly as GetPortals returns them, with no
GetReaders call. This makes plain get_portals listings self-describing;
it doesn't replace find_portals, which remains the tool for searching by
name or description rather than just listing.
get_reader_access_history is for finding out who actually badges through a
given reader — useful, for example, when a reader has no DESCRIPTION and
find_portals can't locate it by name. GetAccessHistory has no
READERKEY/PORTALKEY filter, so this tool reads and filters client-side.
Rather than a date range (a real one proved unworkable live — see
specs/archive/get-reader-access-history.md's Goal section), it scans a fixed-size
window of the most recent SCANWINDOW system-wide records (default 2000):
one cheap MAXRECORDS: '1' call discovers the current maximum LOGID, then
the tool walks forward from maxLogid - SCANWINDOW via its own
AFTERLOGID/NEXTLOGID pagination loop (a separate shape from NEXTKEY),
keeping only the records whose READERKEY matches. Each matching record's
PERSONID is enriched with FIRSTNAME/LASTNAME via one GetPerson call
per distinct person (a lookup failure — e.g. for an operator-style
PERSONID — leaves those two fields blank rather than failing the call).
The result is capped at MAXMATCHES (default 100, earliest matches first)
with a truncated flag. get_reader_access_history also accepts
RESOLVEDESCRIPTIONS (default true — on by default, the one
opt-out boolean in this codebase; every other optional boolean flag
defaults to off): unless explicitly set to false, it attaches a single
top-level READERDESCRIPTION field — the human-readable description of the
call's own READERKEY — via one GetReaders full-table fetch. It is
deliberately not duplicated onto each matches entry, since every match
already shares that identical READERKEY by construction. Set
RESOLVEDESCRIPTIONS: false to omit the field entirely (not present at all,
distinguishable from an unknown reader's '') and skip the GetReaders
call.
get_access_history optionally enriches each returned record with the
badge-holder's name via RESOLVENAMES: true (default false): when set, it
calls GetPerson once per distinct PERSONID found in the result (the same
per-request memoization as get_reader_access_history, via the shared
src/personEnrichment.ts helper — no cross-request cache) and adds
FIRSTNAME/LASTNAME/FULLNAME/NOTES to each record, preserving every
original field. This costs one extra GetPerson call per distinct person in
the result, which is why it's opt-in rather than on by default.
get_access_history has no date-range filter: its previous date-range
parameters were removed entirely, closing
#47 — they didn't match
GetAccessHistory's real NBAPI field names, and a live controlled A/B test
this session found that even the correct field names don't work: the
controller silently ignores them and returns the same records regardless of
the requested range, no error, just no effect. Renaming would have only
traded a loud failure for a silently wrong one, so date-range filtering is
dropped rather than fixed — the same reasoning already documented above for
get_reader_access_history.
get_access_history and get_card_access_details both also accept
RESOLVEDESCRIPTIONS (default true — on by default; the same
opt-out default as get_reader_access_history's own RESOLVEDESCRIPTIONS
above, and unlike RESOLVENAMES, which defaults to off): unless explicitly
set to false, each returned record is enriched with the reader's
human-readable READERDESCRIPTION alongside its existing READER (or
PORTALNAME, for get_card_access_details) code, preserving every other
field. Both tools share the same src/readerDescriptions.ts helper
get_reader_access_history uses. It defaults to on rather than off because,
unlike person-name enrichment, the underlying GetReaders fetch has a fixed
cost — this controller's entire reader table (68 readers) fetches in exactly
2 paginated calls regardless of how many result records are returned, so
there's no scaling cost to make callers opt in to. Set
RESOLVEDESCRIPTIONS: false to skip the GetReaders call and get the plain
(unenriched) response. On both get_access_history and
get_card_access_details, RESOLVENAMES and RESOLVEDESCRIPTIONS are
independent flags — either, both, or neither may be requested in the same
call.
get_card_access_details also accepts its own RESOLVENAMES: true (default
false), enriching the response with the card owner's
FIRSTNAME/LASTNAME/FULLNAME/NOTES via the same shared
src/personEnrichment.ts helper get_access_history uses. Unlike
get_access_history (whose response can carry many distinct PERSONIDs, one
per record), GetCardAccessDetails' response carries exactly one PERSONID
at the top level — a card belongs to one person — so this costs a single
GetPerson call per tool call, not one per distinct person. The four
enrichment fields land on the top level of the response, alongside
PERSONID/DISABLED/EXPDATE, rather than being duplicated onto every
ACCESS record.
get_access_level accepts RESOLVEGROUPNAMES (default true — on by
default, the same opt-out default as the other RESOLVE* flags above,
since GetAccessLevel carries exactly one TIMESPECGROUPKEY and one
READERGROUPKEY per call, so resolving both always costs exactly one
fixed-size GetTimeSpecGroups fetch and one fixed-size GetReaderGroups
fetch, never scaling with anything): unless explicitly set to false, it
resolves the response's bare TIMESPECGROUPKEY/READERGROUPKEY foreign
keys into new sibling TIMESPECGROUPNAME/READERGROUPNAME fields, using
the new src/timeSpecGroupNames.ts/src/readerGroupNames.ts helpers.
TIMESPECGROUPKEY is resolved via the full paginated GetTimeSpecGroups
list, filtering client-side for the matching key — never the singular
GetTimeSpecGroup command, which is verified broken on this controller: it
returns CODE=FAIL/ERRMSG="NOT FOUND" even for a genuinely existing group
(the same finding already documented for src/unlockWindow/managed.ts).
READERGROUPKEY is resolved the same way, via the full paginated
GetReaderGroups list, for consistency. An empty/absent key on either axis
independently skips that axis's fetch and yields '' for just that axis's
name, without affecting the other. THREATLEVELGROUPKEY is never
resolved and is left exactly as-is — no NBAPI read command for threat level
groups exists in this server's command surface at all. If the underlying
GetTimeSpecGroups/GetReaderGroups fetch itself fails, that axis's name
resolves to '' and the call still succeeds with the primary
GetAccessLevel data intact — an enrichment failure never loses the primary
data. Set RESOLVEGROUPNAMES: false to skip both fetches and get the
response back exactly as GetAccessLevel provides it.
get_portal_group accepts the same RESOLVEGROUPNAMES flag (default
true, same opt-out default and identical kind of lookup as
get_access_level's own RESOLVEGROUPNAMES above): unless explicitly set to
false, it resolves the response's bare UNLOCKTIMESPECGROUPKEY foreign key
into a new sibling UNLOCKTIMESPECGROUPNAME field, reusing the same
src/timeSpecGroupNames.ts helper (and so the same full-paginated-list
resolution, never the broken singular GetTimeSpecGroup command). A single
GetPortalGroup response carries exactly one UNLOCKTIMESPECGROUPKEY, so
this always costs exactly one fixed-size GetTimeSpecGroups fetch, never
scaling with anything. The already-human-readable PORTALS sub-list
({PORTALKEY, NAME} per portal) is left completely unchanged.
THREATLEVELGROUPKEY is never resolved — no NBAPI read command for
threat level groups exists in this server's command surface. An
empty/absent UNLOCKTIMESPECGROUPKEY skips the fetch entirely and yields
'' for the name; if the underlying GetTimeSpecGroups fetch itself fails,
UNLOCKTIMESPECGROUPNAME resolves to '' and the call still succeeds with
the primary GetPortalGroup data (including PORTALS) intact. Set
RESOLVEGROUPNAMES: false to skip the fetch and get the response back
exactly as GetPortalGroup provides it.
get_portal_groups accepts the same RESOLVEGROUPNAMES flag (default
true, same opt-out default and field name as the singular
get_portal_group above — this is its explicitly-planned follow-on):
unless explicitly set to false, it resolves every returned group's bare
UNLOCKTIMESPECGROUPKEY foreign key into a new sibling
UNLOCKTIMESPECGROUPNAME field, reusing the same
src/timeSpecGroupNames.ts helper. Unlike the singular tool (whose response
carries exactly one UNLOCKTIMESPECGROUPKEY, so it does at most one
conditional fetch), this plural tool builds the
fetchTimeSpecGroupNames map once per call — only if at least one group
on the page carries a non-empty UNLOCKTIMESPECGROUPKEY (zero
GetTimeSpecGroups calls if every group's key on the page is empty) — then
looks every group up against that same shared map, the same one-fetch-per-
page cost shape as get_time_spec_groups's own RESOLVEMEMBERNAMES above,
never one fetch per group. Unlike GetPortalGroup (singular), GetPortalGroups'
response is already flat per item — DETAILS.PORTALGROUPS.PORTALGROUP[], no
per-item PORTALGROUP wrapper — so no per-item unwrap is applied; that
wrapper quirk belongs only to the singular command's own response envelope.
The already-human-readable PORTALS sub-list ({PORTALKEY, NAME} per
portal) is left completely unchanged on every group.
THREATLEVELGROUPKEY is never resolved — no NBAPI read command for
threat level groups exists in this server's command surface. A group with an
empty/absent UNLOCKTIMESPECGROUPKEY gets UNLOCKTIMESPECGROUPNAME: ''
without needing a match; a group whose key has no match in the fetched map
also gets ''. If the underlying GetTimeSpecGroups fetch itself fails,
every group's UNLOCKTIMESPECGROUPNAME resolves to '' and the call still
succeeds with every group's other fields (including PORTALS) intact — an
enrichment failure never loses the primary data. Set RESOLVEGROUPNAMES: false to skip the fetch and get groups back exactly as GetPortalGroups
provides them.
list_events accepts RESOLVEPARTITIONNAMES (default true — on by
default, the same opt-out default as the other RESOLVE* flags above):
unless explicitly set to false, each returned event's bare PARTITIONID
is resolved into a new sibling PARTITIONNAME field via one GetPartitions
fetch per call — not per event, since GetPartitions takes no
STARTFROMKEY at all and always answers every partition in a single
response, so the cost never scales with how many events come back. This is
backed by the new src/partitionNames.ts helper, mirroring
src/readerDescriptions.ts's shape exactly (a Map-returning fetch
function that never throws). An event whose PARTITIONID has no match in
the fetched map resolves to PARTITIONNAME: '', and if the underlying
GetPartitions fetch itself fails, every event's PARTITIONNAME resolves
to '' and the call still succeeds with every other field (including
ACTIONS) intact — an enrichment failure never loses the primary data. Set
RESOLVEPARTITIONNAMES: false to skip the GetPartitions call and get the
response back exactly as ListEvents provides it.
get_time_spec_groups accepts RESOLVEMEMBERNAMES (default true — on
by default, the same opt-out default as the other RESOLVE* flags above,
since resolving every group's members on a page always costs exactly one
fixed-size GetTimeSpecs fetch, never scaling with how many groups/members
are on the page): unless explicitly set to false, each group's
TIMESPECKEYS.TIMESPECKEY field — which GetTimeSpecGroups returns as bare
TIMESPECKEY string(s) — is replaced with a list of {TIMESPECKEY, NAME}
objects, matching this codebase's own convention for other group-membership
sub-lists that NBAPI already returns as objects natively (get_access_level_group's
ACCESSLEVELS, get_reader_group's READERS). A member key with no match
in the fetched GetTimeSpecs table (an unknown/deleted time spec) resolves
to NAME: '' rather than being omitted. Every other field
(TIMESPECGROUPKEY, the group's own NAME, DESCRIPTION) is unchanged. The
name lookup uses the new src/timeSpecNames.ts helper — one full paginated
GetTimeSpecs fetch per call, regardless of how many groups/members are on
the page — and reuses keyList (src/paging.ts, relocated from
src/unlockWindow/managed.ts) to normalize the bare-key collection. If the
underlying GetTimeSpecs fetch itself fails, every member's NAME resolves
to '' and the call still succeeds with every group's own fields intact —
an enrichment failure never loses the primary data. RESOLVEMEMBERNAMES
applies only to this plural tool, not the singular get_time_spec_group,
which is verified broken (CODE=FAIL/ERRMSG="NOT FOUND") on this
controller even for a genuinely existing group, independent of this change.
Set RESOLVEMEMBERNAMES: false to skip the fetch and get TIMESPECKEYS
back exactly as GetTimeSpecGroups provides it (bare string or array of
strings).
write (needs only NETBOX_ENABLE_WRITES) and destructive (needs
NETBOX_ENABLE_WRITES and NETBOX_ENABLE_DESTRUCTIVE). Every write
tool's input schema declares exactly the documented PARAMS fields for its
command, matching required/optional as documented — see the "Write access"
section above for the gating rules and the shared SUCCESS/WRITE:/
DESTRUCTIVE: conventions.
| Tool | Wraps NBAPI command | Required params | Tier |
|---|---|---|---|
lock_portal | LockPortal | PORTALKEY | write |
unlock_portal | UnlockPortal | PORTALKEY | write |
momentary_unlock_portal | MomentaryUnlockPortal | PORTALKEY | write |
dog_on_next_exit_portal | DogOnNextExitPortal | PORTALKEY | write |
activate_output | ActivateOutput | OUTPUTKEY | write |
deactivate_output | DeactivateOutput | OUTPUTKEY | write |
add_time_spec | AddTimeSpec | NAME | write |
modify_time_spec | ModifyTimeSpec | TIMESPECKEY | write |
add_time_spec_group | AddTimeSpecGroup | NAME | write |
modify_time_spec_group | ModifyTimeSpecGroup | TIMESPECGROUPKEY | write |
delete_time_spec | DeleteTimeSpec | TIMESPECKEY | destructive |
delete_time_spec_group | DeleteTimeSpecGroup | TIMESPECGROUPKEY | destructive |
add_holiday | AddHoliday | HOLIDAYNAME, STARTDATE, ENDDATE, HOLIDAYGROUPS | write |
modify_holiday | ModifyHoliday | HOLIDAYKEY | write |
delete_holiday | DeleteHoliday | HOLIDAYKEY | destructive |
add_portal_group | AddPortalGroup | NAME, PORTALKEYS, UNLOCKTIMESPECGROUPKEY | write |
modify_portal_group | ModifyPortalGroup | PORTALGROUPKEY, PORTALKEYS | write |
delete_portal_group | DeletePortalGroup | PORTALGROUPKEY | destructive |
add_reader_group | AddReaderGroup | NAME, READERKEYS | write |
modify_reader_group | ModifyReaderGroup | READERGROUPKEY, READERKEYS | write |
delete_reader_group | DeleteReaderGroup | READERGROUPKEY | destructive |
add_access_level | AddAccessLevel | ACCESSLEVELNAME, TIMESPECGROUPKEY | write |
modify_access_level | ModifyAccessLevel | ACCESSLEVELKEY, TIMESPECGROUPKEY | write |
delete_access_level | DeleteAccessLevel | ACCESSLEVELKEY | destructive |
add_access_level_group | AddAccessLevelGroup | NAME | write |
modify_access_level_group | ModifyAccessLevelGroup | ACCESSLEVELGROUPKEY | write |
delete_access_level_group | DeleteAccessLevelGroup | ACCESSLEVELGROUPKEY | destructive |
add_person | AddPerson | LASTNAME | write |
modify_person | ModifyPerson | PERSONID | write |
remove_person | RemovePerson | PERSONID | destructive |
add_credential | AddCredential | PERSONID, CARDFORMAT (+ ENCODEDNUM or HOTSTAMP) | write |
modify_credential | ModifyCredential | PERSONID | write |
remove_credential | RemoveCredential | PERSONID (+ CREDENTIALID or ENCODEDNUM/HOTSTAMP) | destructive |
set_threat_level | SetThreatLevel | LEVELNAME (optional LOCATIONKEYS) | write |
add_threat_level | AddThreatLevel | LEVELNAME | write |
modify_threat_level | ModifyThreatLevel | LEVELNAME, SEQNUM, COLOR | write |
remove_threat_level | RemoveThreatLevel | LEVELNAME | destructive |
add_threat_level_group | AddThreatLevelGroup | LEVELGROUPNAME | write |
modify_threat_level_group | ModifyThreatLevelGroup | LEVELGROUPNAME, LEVELNAMES | write |
remove_threat_level_group | RemoveThreatLevelGroup | LEVELGROUPNAME | destructive |
trigger_event | TriggerEvent | EVENTNAME, EVENTACTION | write |
insert_activity | InsertActivity | ACTIVITYTYPE | write |
add_partition | AddPartition | NAME, TIMEZONE | write |
switch_partition | SwitchPartition | PARTITIONKEY | write |
modify_udf_list_items | ModifyUDFListItems | UDFLISTKEY, LISTITEMS | write |
set_portals_state | LockPortal / UnlockPortal / MomentaryUnlockPortal per portal, after GetPortals (composite) | action (portalKeys optional; omitted = every portal) | write |
schedule_unlock_window | AddHoliday/ModifyHoliday, AddTimeSpec/ModifyTimeSpec, AddTimeSpecGroup/ModifyTimeSpecGroup, AddPortalGroup/ModifyPortalGroup, plus DeleteTimeSpec/DeleteHoliday of leftover managed segments, plus reads (composite) | start, end (portalKeys, acknowledgeSideEffects, dryRun optional) | write |
cancel_unlock_window | ModifyPortalGroup, DeleteHoliday, ModifyTimeSpecGroup, DeleteTimeSpec — managed objects only — plus reads (composite) | — | write |
schedule_daily_unlock_window | AddHoliday/ModifyHoliday, AddTimeSpec/ModifyTimeSpec, AddTimeSpecGroup/ModifyTimeSpecGroup, AddPortalGroup/ModifyPortalGroup, plus reads (composite) | startDate, endDate, dailyStartTime, dailyEndTime (portalKeys, acknowledgeSideEffects, dryRun optional) | write |
cancel_daily_unlock_window | ModifyPortalGroup, DeleteHoliday, ModifyTimeSpecGroup, DeleteTimeSpec — managed objects only — plus reads (composite) | — | write |
add_duty_log | AddDutyLog | PERSONID, LOGTEXT (optional ACTIVITYID/PARTITIONKEY) | write |
add_virtual_credential_request | AddVirtualCredentialRequest | PERSONID, CARDFORMAT | write |
remove_virtual_credential_request | RemoveVirtualCredentialRequest | PERSONID, CARDFORMAT | destructive |
add_mercury_panel | AddMercuryPanel | NAME, TYPE, ENABLED, PARTITIONKEY, NETWORK (nested: IPADDRESS, TLSSECURE) | write |
modify_mercury_panel | ModifyMercuryPanel | MERCURYKEY, NAME, ENABLED, NETWORK (nested: IPADDRESS, TLSSECURE) | write |
delete_mercury_panel | DeleteMercuryPanel | MERCURYKEY | destructive |
add_network_node | AddNetworkNode | NAME, TYPE, ENABLED, PARTITIONKEY, UNIQUEIDENTIFIER, DHCPENABLED | write |
modify_network_node | ModifyNetworkNode | NODEKEY | write |
delete_network_node | DeleteNetworkNode | NODEKEY | destructive |
add_sio | AddSio | MERCURYKEY, NAME, MODEL, CHANNEL, ADDRESS, REVINPUT | write |
modify_sio | ModifySio | SIOKEY, NAME, REVINPUT | write |
delete_sio | DeleteSio | SIOKEY | destructive |
The twelve hardware tools (*_mercury_panel, *_network_node, *_sio —
six writes and three destructive deletes, plus the six reads in the table
above) are *
Source-derived launch command. Check the maintainer’s required arguments and credentials before running:
npx -y s2-netbox-mcpMerge this template into ~/Library/Application Support/Claude/claude_desktop_config.json. Keep existing servers. Add any arguments, credentials, and permissions required by the maintainer; this template has not been install-tested.
{
"mcpServers": {
"io-github-j-maff-s2-netbox-mcp": {
"command": "npx",
"args": [
"-y",
"s2-netbox-mcp"
]
}
}
}Restart Claude Desktop completely for changes to take effect. Confirm the server appears connected in the client’s tool list, then try a read-only example from its documentation.
Claude Desktop setup references2-netbox-mcpnpmio.github.J-MaFf/s2-netbox-mcp works with any MCP-compatible client. Copy the config snippet from the Configuration section above and add it to the file shown for your client, then restart the application.
~/Library/Application Support/Claude/claude_desktop_config.jsonRestart Claude Desktop completely for changes to take effect.~/.cursor/mcp.jsonRestart Cursor for changes to take effect..vscode/mcp.jsonReload VS Code window for changes to take effect.~/.codeium/windsurf/mcp_config.jsonRestart Windsurf for changes to take effect..mcp.jsonSave at the project root, then start Claude Code in that project and review the MCP server approval prompt. Keep real credentials out of shared files.