Semantic MCP Bridge¶
The Proxbox semantic MCP bridge defines how a compatible netbox-sdk client
will discover and invoke a small, versioned set of Proxbox operations through
the existing NetBox REST API. The bridge is intentionally a descriptor, not
another server: Proxbox publishes metadata, while netbox-sdk owns MCP
registration and sends ordinary authenticated HTTP requests to the declared
plugin-local routes.
Consumer activation is currently blocked
Proxbox publishes the producer-side descriptor, but no released
netbox-sdk version has passed the immutable paired gate for this payload.
The currently documented netbox-sdk 0.0.10 pairing belongs only to the
separate proxbox-api REST runtime and does not provide this MCP bridge.
Do not expose these tools to an agent until
tests/fixtures/netbox_sdk_bridge_activation.json names an exact compatible
released version or commit, the paired gate passes that exact module
origin, and committed CI explicitly provisions and validates the same
artifact.
Architecture¶
sequenceDiagram
participant Agent as MCP client / agent
participant SDK as netbox-sdk MCP bridge
participant Root as Proxbox API root
participant Manifest as Proxbox bridge-v1 manifest
participant DRF as Existing DRF target view
participant RQ as NetBox RQ job
Agent->>SDK: Connect with the configured NetBox principal
SDK->>Root: GET /api/plugins/proxbox/
Root-->>SDK: mcp.schema_version + manifest URL
SDK->>Manifest: GET /api/plugins/proxbox/mcp/
Manifest-->>SDK: Strict tool schemas and fixed local paths
SDK->>SDK: Validate schemas, confine paths, register tools
Agent->>SDK: plugin_list_tools or plugin_call_tool
SDK->>DRF: GET or POST /api/plugins/proxbox/sync/schedule/
DRF->>DRF: Authenticate and require core.add_job
alt schedule_sync accepted
DRF->>RQ: Enqueue ProxboxSyncJob
DRF-->>SDK: HTTP 201 job envelope
else validation or authorization failure
DRF-->>SDK: HTTP 400/401/403; no job enqueued
end
SDK-->>Agent: Schema-validated result or structured error
Ownership is deliberately split:
| Layer | Responsibility |
|---|---|
| Proxbox API root | Advertise bridge schema version 1 and the manifest URL only after exact SDK activation |
| Proxbox manifest | Describe tool names, fixed relative routes, effects, annotations, and strict JSON Schemas |
| Existing Proxbox DRF views | Enforce NetBox authentication, core.add_job, validation, visibility, and job dispatch |
Compatible future netbox-sdk |
Discover the manifest, validate it, confine paths to the plugin API root, expose the generic plugin_list_tools / plugin_call_tool MCP surface, hold the existing NetBox credential, validate inputs/outputs, and keep mutations disabled by default |
| MCP host / agent | Preserve operator intent, honor the mutation gate, and treat tool annotations as safety metadata rather than authorization |
Proxbox does not embed FastMCP, open an MCP listener, import netbox-sdk at
runtime, or store a second credential. The HTTP request made by the SDK is the
same request a REST client would make to the existing scheduling API.
Discovery¶
After an exact SDK is activated, start at the authenticated plugin API root:
GET /api/plugins/proxbox/
The root then contains the following member. An absolute manifest URL is returned at runtime; the relative URL below is the portable representation.
{
"mcp": {
"schema_version": "1",
"manifest": "/api/plugins/proxbox/mcp/"
}
}
While activation is blocked, the root deliberately omits mcp and direct
GET /api/plugins/proxbox/mcp/ returns HTTP 503 with the checked activation
record. This prevents descriptor presence from being mistaken for consumer
compatibility.
The SDK then reads the descriptor:
GET /api/plugins/proxbox/mcp/
The descriptor is static and read-only. Reading it never schedules a job. The
manifest declares relative paths such as sync/schedule/; a conforming SDK
must resolve them below /api/plugins/proxbox/ and reject path or origin
escape. Do not concatenate a path supplied by a model or user.
Agents do not receive list_sync_jobs or schedule_sync as standalone MCP
tools. Discover the Proxbox descriptors through the generic MCP tool:
{
"plugin": "proxbox"
}
Then invoke a descriptor through plugin_call_tool, passing the manifest input
inside its arguments member. The examples below show that complete envelope.
This invocation flow is a contract for the future activated SDK pair; the
read-only HTTP descriptor by itself does not prove that the installed SDK is
compatible.
Authentication and authorization¶
Use the normal NetBox principal already configured in netbox-sdk. Do not put a
token in a prompt, tool argument, manifest cache, source file, log, or Proxbox
model. Proxbox never asks for an MCP-specific token.
The manifest view follows NetBox's LOGIN_REQUIRED setting through
IsAuthenticatedOrLoginNotRequired. A deployment with LOGIN_REQUIRED=False
may expose the descriptor anonymously. That does not authorize either tool:
both GET and POST on sync/schedule/ independently require the caller to
hold core.add_job. Tool annotations are advisory metadata and never replace
that server-side permission check.
Expected authorization outcomes after consumer activation:
| Condition | Manifest | list_sync_jobs |
schedule_sync |
|---|---|---|---|
Authenticated principal with core.add_job |
200 | 200 | 201 or validation 400 |
Authenticated principal without core.add_job |
200 | 403 | 403 |
Anonymous, LOGIN_REQUIRED=True |
401/403 | 401/403 | 401/403 |
Anonymous, LOGIN_REQUIRED=False |
200 | 403 unless the effective principal is authorized | 403 unless the effective principal is authorized |
Tool catalog¶
| Tool | HTTP target | Effect | Idempotent | Open-world | Purpose |
|---|---|---|---|---|---|
list_sync_jobs |
GET sync/schedule/ |
read | yes | no | List visible active, failed, and recurring Proxbox sync jobs |
schedule_sync |
POST sync/schedule/ |
destructive | no | yes | Queue an immediate, future, or recurring Proxbox synchronization |
list_sync_jobs¶
The input is a strict empty object. Query fields and additional properties are not part of bridge v1.
{
"plugin": "proxbox",
"tool": "list_sync_jobs",
"arguments": {},
"dry_run": false
}
The result uses the existing schedule-list envelope:
{
"plugin": "proxbox",
"tool": "list_sync_jobs",
"effect": "read",
"status": 200,
"headers": {"Content-Type": "application/json"},
"body": {
"count": 1,
"scheduled_jobs": [
{
"id": 42,
"pk": 42,
"name": "nightly-inventory",
"sync_types": ["all"],
"schedule": "2099-01-15T03:00:00Z",
"interval": 1440,
"status": "scheduled"
}
]
}
}
The list is generated through the existing request-aware helper. The NetBox principal's visibility remains authoritative; the manifest does not create a separate job index.
schedule_sync¶
This tool queues work. It is destructive and non-idempotent: reconciliation may remove stale records from NetBox inventory, and retrying the same call may enqueue a second job. This bridge does not delete Proxmox guests, disks, snapshots, backups, or infrastructure, but clients must still use the SDK's disabled-by-default mutation gate and preserve the operator's scheduling intent.
Input fields¶
| Field | Required | Contract |
|---|---|---|
sync_stages |
yes | Nonempty, unique list of the 13 concrete manifest-declared slugs. Bridge v1 does not advertise the legacy "all" sentinel. A full sync is the exact complete list shown below. |
job_name |
no | String of at most 200 characters. Whitespace at the edges is removed before enqueue; an empty value uses the default job name. |
schedule_at |
no | Strict RFC 3339 date-time with an explicit Z or numeric offset, and it must resolve to a future representable instant. A :60 value is accepted only when the normalized UTC instant crosses a month boundary; arbitrary-minute leap seconds and normalization overflow are HTTP 400. For example, 2100-01-01T00:59:60+01:00 is the same accepted instant as 2099-12-31T23:59:60Z, while 2026-08-12T12:34:60Z, 9999-12-31T23:59:60Z, and 9999-12-31T23:59:59-23:59 reject. Omit or send null for immediate execution unless recurrence is requested. |
recurrence |
no | Object containing exactly one of minutes, hours, days, or weeks, with a positive JSON Schema integer bounded so the converted minute count is at most 2147483647. A finite mathematically integral JSON number such as 6.0 is an integer and is normalized to Python int; booleans, strings, nonintegral numbers, and non-finite numbers are rejected. Omit for a one-shot job. |
proxmox_endpoint_ids |
no | Nonempty, unique list of positive signed-64-bit NetBox PKs (1..9223372036854775807). Integer JSON literals retain the full range. Decimal/exponent forms such as 7.0 normalize only within the exact IEEE-754 safe range 1..9007199254740991; larger float/Decimal values reject before ORM lookup because a JSON parser may already have rounded their identity. Booleans, strings, nonintegral/non-finite values, and signed-64 overflow also reject. Every requested row must exist and be enabled or the whole request fails. Omit for all enabled Proxmox endpoints. |
sync_types, interval_value, interval_unit, and netbox_endpoint_ids are
legacy REST fields, not bridge-v1 arguments; additionalProperties: false
rejects them before dispatch. An explicit empty Proxmox endpoint list is also
rejected; it never degrades into the wider all-endpoints operation. Omission is
the only way to request that behavior.
The exact recurrence maxima are 2147483647 minutes, 35791394 hours,
1491308 days, or 213044 weeks. Each maximum converts to no more than the
NetBox Job PositiveIntegerField ceiling of 2147483647 minutes; the next
integer for any unit is HTTP 400 and enqueues nothing.
Stage selection and invariant reconciliation¶
sync_stages selects only the 13 dependency-ordered backend SSE stages. Every
job scheduled through this bridge first performs endpoint preflight, which may
push current NetBox and Proxmox endpoint configuration/credentials to
proxbox-api. It then reconciles cluster/node inventory, datacenter firewall
objects, and datacenter CPU models for the job's endpoint scope before the
selected SSE stages. VM-template inventory is also reconciled before those
stages unless sync_mode_vm_template=disabled. These invariant passes can
create, update, or remove stale NetBox inventory even when their names are
absent from sync_stages; a narrow stage selection does not isolate them.
The exact complete unique 13-stage public list is translated internally to the
legacy ["all"] job identity after validation. This keeps recurring-job hints
and repair debounce correct without advertising or accepting the "all"
sentinel through MCP. Every proper subset remains an explicit stage list.
Immediate all-endpoint sync¶
Use the smallest request when the operator explicitly asks for an immediate full synchronization across enabled endpoints:
{
"plugin": "proxbox",
"tool": "schedule_sync",
"arguments": {
"sync_stages": [
"virtual-machines",
"storage",
"vm-disks",
"vm-backups",
"vm-snapshots",
"devices",
"network-interfaces",
"vm-interfaces",
"ip-addresses",
"sdn",
"backup-routines",
"replications",
"task-history"
],
"job_name": "operator-requested-full-sync"
},
"dry_run": false
}
Future scoped sync¶
Proxmox endpoint IDs are NetBox primary keys, not cluster IDs, node IDs, or the backend's wire IDs. Discover and verify them through the normal NetBox API before presenting or invoking this request. Bridge v1 intentionally exposes no NetBox-endpoint scope because the producer does not implement that scope end-to-end.
{
"plugin": "proxbox",
"tool": "schedule_sync",
"arguments": {
"sync_stages": ["virtual-machines", "storage"],
"job_name": "maintenance-window-inventory",
"schedule_at": "2099-01-15T03:00:00Z",
"proxmox_endpoint_ids": [7, 9]
},
"dry_run": false
}
If either Proxmox endpoint is unknown or disabled, the server rejects the
entire request and enqueues nothing. It never silently filters [7, 9] down to
one endpoint.
Recurring sync¶
When recurrence is supplied without schedule_at, the server sets the first
run to its current local time. The interval is stored in minutes; this example
becomes 360 minutes.
{
"plugin": "proxbox",
"tool": "schedule_sync",
"arguments": {
"sync_stages": ["devices", "network-interfaces"],
"job_name": "six-hour-inventory",
"recurrence": {"hours": 6},
"proxmox_endpoint_ids": [7]
},
"dry_run": false
}
Success response¶
An accepted request returns HTTP 201 inside the generic MCP result. job_id
identifies the NetBox core Job.
{
"plugin": "proxbox",
"tool": "schedule_sync",
"effect": "destructive",
"status": 201,
"headers": {"Content-Type": "application/json"},
"body": {
"ok": true,
"job_id": 314,
"message": "Sync job queued for immediate execution."
}
}
Future or recurring jobs may instead return a message containing the resolved
schedule time. Treat job_id, not the human-readable message, as the stable
identifier.
Safe agent interaction sequence¶
- Require an activated exact SDK identity in
tests/fixtures/netbox_sdk_bridge_activation.jsonand matching successful immutable CI evidence. A readable manifest is not activation. - Discover the plugin API root and require
schema_version == "1". - Fetch the manifest through the URL returned by the root; do not guess a cross-origin target.
- Call
plugin_list_toolswith{"plugin":"proxbox"}and let the exact gated SDK validate the manifest. Do not create a second Proxbox MCP transport or credential. - Invoke
list_sync_jobsonly throughplugin_call_toolwithplugin="proxbox",tool="list_sync_jobs", andarguments={}. - Before invoking
schedule_syncthrough the same generic envelope, preserve the user's requested sync stages, Proxmox scope, timing, and recurrence. Do not convert an informational request into a sync. - Keep the SDK mutation gate disabled unless the operator or host policy accepts
the blast radius:
NETBOX_MCP_ALLOW_MUTATIONS=1/--allow-mutationsis a single server-wide switch that enables every MCP mutation, not only Proxboxschedule_sync. The flag is not per plugin or per tool. - Prefer explicit, verified Proxmox endpoint IDs for narrow work. Omit scope
only when all enabled endpoints are truly intended; never send
[]as a placeholder. - Submit once. A timeout, transport failure, invalid response, or 5xx after dispatch leaves a non-idempotent outcome ambiguous. The current list schema does not carry the submitted scope or a stable request identity, so listing jobs cannot prove absence: never auto-retry. Report the ambiguity and require an operator to reconcile the NetBox job records before a deliberate retry.
- Never retry a 400 or 403 as a broader scope. A compatible SDK may turn a non-2xx write into an MCP error and may expose only its HTTP status, not the DRF body. Do not guess which field failed; let an operator inspect the authenticated REST response or NetBox logs before constructing a new call.
Errors and fail-closed behavior¶
Validation failures use the existing DRF error envelope and enqueue no job. The
following is the target endpoint's direct HTTP body, not a successful
plugin_call_tool result. A compatible SDK may represent non-2xx writes as MCP
errors and retain only the status in the agent-visible message.
{
"errors": {
"proxmox_endpoint_ids": [
"Unknown or disabled endpoint ID(s): [99]"
]
}
}
| HTTP status | Meaning | Client action |
|---|---|---|
| 200 | Manifest or job list returned | Validate the response against the declared output schema. |
| 201 | Sync job accepted | Record job_id; do not enqueue a duplicate. |
| 400 | Invalid schema, past time, bad recurrence, bad scope, or unavailable Proxmox endpoint | Do not resubmit automatically. If the generic MCP error omits the DRF body, require operator inspection; never guess by dropping fields or widening scope. |
| 401 | Authentication required or invalid | Repair the existing NetBox SDK authentication. Do not pass credentials as tool input. |
| 403 | Principal lacks core.add_job |
Ask an administrator for the least privilege needed; do not bypass DRF. |
| 404 | Plugin or declared route unavailable | Treat the installed plugin as incompatible or not enabled; refresh discovery. |
| 5xx | NetBox-side failure after dispatch may be ambiguous | Report it and inspect NetBox/RQ health. Never auto-retry schedule_sync. |
Common 400 cases include:
- an unknown property, because bridge v1 has
additionalProperties: false; - an empty or duplicate endpoint list, a boolean/string/nonintegral/non-finite
endpoint ID, a decimal/exponent-form endpoint ID above
9007199254740991, or an integer literal outside1..9223372036854775807; - duplicate sync stages, an empty
sync_stageslist, or the legacy"all"sentinel; - a missing, extra, nonpositive, non-integer, or too-large recurrence member;
- a timezone-less or otherwise invalid RFC 3339
schedule_at, including a leap second whose normalized instant overflows the supported datetime range; schedule_atin the past; andjob_namelonger than 200 characters.
Trust boundary and security notes¶
- The descriptor can describe a destructive operation but cannot execute it.
- The global SDK mutation gate reduces accidental calls; it is not authorization and enabling it enables all mutation tools exposed by that MCP server.
core.add_jobon the target DRF view is the server-side authorization gate.- Tool schemas are validation boundaries, not a source of credentials.
- Proxbox endpoint PKs are local NetBox identifiers. Never reinterpret an ID copied from another NetBox instance.
- An omitted Proxmox scope is intentionally broad. A disabled or missing local endpoint remains a hard operational gate during the job preflight.
- The scheduling bridge reaches the Proxmox-to-NetBox reflection pipeline. It does not opt into the separate NetBox-to-Proxmox intent/delete workflow and cannot bypass its five-lock deletion chain.
- Do not cache the manifest longer than the owning SDK's compatibility policy. Rediscover after plugin upgrades or a schema-version mismatch.
Compatibility and versioning¶
Bridge schema version "1" at the API root and manifest identifies the generic
descriptor protocol: manifest/tool envelope shape, fixed plugin-local routing,
effects/annotations, and the supported JSON Schema processing rules. It is
not a frozen version number for every plugin's tool names or input payload.
tests/fixtures/proxbox_bridge_v1.json is a Proxbox-owned generated-contract
snapshot, not a second SDK authority or a fixture that both repositories must
copy.
Any Proxbox tool-name, path, effect, required-field, or meaning change still
needs explicit producer/consumer compatibility review and rediscovery. A
generic descriptor-protocol change requires a new bridge schema version;
plugin-specific payload evolution does not become safe merely because the
string remains "1".
No released SDK identity is currently certified for this descriptor.
tests/fixtures/netbox_sdk_bridge_activation.json therefore remains
state="blocked" with no version or commit, and ordinary CI asserts that the
paired script is not presented as an active gate. In that state the API root
omits mcp and the direct manifest endpoint returns 503. Activation requires a later,
explicit change that provisions one immutable SDK release checkout, records its
exact released version, full commit, and module origin, adds the paired command to
committed CI, and changes the artifact only after every vector passes.
The manual paired gate also fails closed. It requires an explicit SDK root,
the fixed relative module origin, and one exact commit whose complete
netbox_sdk/ package inventory matches the checkout. It materializes
the package from Git blob objects into a private temporary tree before import,
after bounded offline verification of the complete commit/tree/blob graph and
an explicit rehash of every imported blob;
dirty tracked package files, version spoofing, untracked package modules, ambient PYTHONPATH,
and arbitrary installed packages are not identity evidence. Run it as:
/path/to/netbox-sdk/.venv/bin/python -I tests/validate_paired_netbox_sdk_bridge.py \
--sdk-root /path/to/netbox-sdk \
--expected-commit <full-lowercase-commit-sha> \
--expected-version <exact-released-version> \
--expected-environment-root /path/to/netbox-sdk/.venv \
--expected-module-origin netbox_sdk/plugin_bridge.py
The release activation separately records the version belonging to that exact
commit; a mutable source tree whose __version__ merely matches is never
accepted. Do not substitute a branch name, range, or unreleased version guess.
The required SDK behavior includes lossless large endpoint identity, rejection
of unsafe integral floats, and acceptance of :60 only when the normalized UTC
instant crosses a month boundary without calendar overflow.
The manifest is generated from pure Python without Django state, network I/O, or credentials. Its choices are cross-checked against the canonical DRF ChoiceSets, and its response fields are cross-checked against the actual serializer in the real-NetBox test matrix.
Verification and traceability¶
| Requirement | Automated evidence |
|---|---|
| Exact bridge-v1 wire contract | tests/test_mcp_bridge_contract.py plus tests/fixtures/proxbox_bridge_v1.json |
| Documentation links, safety text, and executable examples | tests/test_mcp_bridge_docs.py |
| API root and manifest discovery | tests/test_mcp_bridge_django.py |
LOGIN_REQUIRED and core.add_job boundaries |
tests/test_mcp_bridge_django.py |
| Read envelope and response serializer parity | Pure and real-Django MCP bridge suites |
| Immediate, future, recurring, scoped, and omitted-scope dispatch | tests/test_mcp_bridge_django.py |
| Strict input, lossless integer identity, safe-float boundary, signed-64-bit bounds, normalized-UTC month-boundary leap semantics, leap/offset overflow, and no-enqueue failures | Pure serializer contract plus real-Django route matrix |
| Paired SDK activation remains blocked until exact identity exists | tests/fixtures/netbox_sdk_bridge_activation.json plus tests/test_mcp_bridge_contract.py |
| Exact paired SDK descriptor, argument, and response validation after explicit provisioning | <locked-python> -I tests/validate_paired_netbox_sdk_bridge.py --sdk-root ... --expected-commit ... --expected-version ... --expected-environment-root ... --expected-module-origin netbox_sdk/plugin_bridge.py |
| Full-stage recurring hint and repair-debounce identity | Real-Django bridge suite plus tests/test_schedule_hints.py and tests/test_operator_migration_ux.py |
| No FastMCP, SDK import, or duplicate credential | tests/test_mcp_bridge_contract.py |
| Documentation build and link/navigation integrity | MkDocs strict build in local/CI gates |
Troubleshooting¶
The manifest is 401 or 403¶
Authenticate through the normal NetBox SDK configuration. If NetBox requires login, anonymous discovery is intentionally unavailable.
The manifest works but both tools return 403¶
The principal lacks core.add_job. This permission is required for listing as
well as scheduling because both operations reuse the protected schedule view.
The manifest exists but the SDK tools are unavailable¶
That is the expected fail-closed state while
tests/fixtures/netbox_sdk_bridge_activation.json says blocked. Do not infer
compatibility from descriptor discovery or from the proxbox-api runtime's
historical netbox-sdk 0.0.10 dependency. Wait for an exact released SDK
identity, immutable paired-gate evidence, and explicit CI activation.
A scoped schedule returns 400¶
Verify every Proxmox endpoint PK exists in this NetBox and is enabled. Correct the explicit scope; do not omit it to make the request pass unless the operator actually intends all enabled endpoints.
A recurring request runs immediately¶
That is expected when recurrence is supplied without schedule_at: the
server chooses its current local time for the first run. Set
a future ISO 8601 schedule_at when the first run must wait.
The client reports an output-schema failure¶
Refresh discovery and compare the installed manifest with bridge v1. A plugin or SDK version mismatch should fail closed. Do not disable output validation.
A schedule call times out¶
Do not replay it automatically. Scheduling is non-idempotent and the first
request may have been accepted before the response was lost. list_sync_jobs
does not expose the submitted endpoint scope or a stable request identity, so
even a read cannot prove that retrying is safe. Report the ambiguous outcome and
require an operator to reconcile the NetBox job records before a deliberate retry.