Skip to main content
Glama
README.md
# nautobot-mcp

[![CI](https://github.com/shamalawy/nautobot-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/shamalawy/nautobot-mcp/actions/workflows/ci.yml)
[![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](pyproject.toml)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)

An MCP server for [Nautobot](https://nautobot.com), built for instances whose API is too
large to enumerate: Nautobot 3.2 ships **1,673 REST operations across 477 paths**, and
every installed app adds more. This server exposes **15 schema-driven tools** instead of
one tool per endpoint, so the whole API — core and plugins alike — is reachable without
flooding an agent's context.

## What makes it work

**A build step, not runtime parsing.** Nautobot's OpenAPI document is 18 MB and its
GraphQL introspection another 10 MB. A build script fuses them into a ~1.1 MB SQLite
index with an FTS5 search table. The server opens it read-only and answers lookups in
microseconds; start-up does not depend on the size of the API.

**Foreign keys recovered from GraphQL.** OpenAPI alone cannot describe Nautobot's
relationships — every related field serialises as an identical opaque object:

```jsonc
// dcim.device: device_type, role, status and location are indistinguishable here
"device_type": { "id": {...}, "object_type": {"pattern": "^[a-z]+\\.[a-z]+$"}, "url": {...} }
```

GraphQL's type system names the targets outright (`device_type → DeviceTypeType`), so the
two are joined on the OpenAPI component name to recover **441 typed FK edges**. That graph
is what makes dependency planning possible.

**Filters compressed.** `dcim.device` exposes 250 filter parameters, which are really ~74
base fields times a family of lookup suffixes (`__ic`, `__n`, `__isnull`, `__gte`, …).
The index stores base fields plus their suffix sets and describes the vocabulary once.

## Install

```bash
uv venv && uv pip install -e ".[dev]"
cp .env.example .env        # then set NAUTOBOT_URL and NAUTOBOT_TOKEN
cp .mcp.json.example .mcp.json   # optional: for stdio-based clients
python -m nautobot_mcp.schema.build --probe
```

Or skip the checkout entirely and run it in a container — see [Docker](#docker).

The build step fetches the schemas and writes `var/index.sqlite`. Re-run it after
installing or upgrading a Nautobot app — or call the `nautobot_refresh_schema` tool.

### Configuration

| Variable | Default | Purpose |
| --- | --- | --- |
| `NAUTOBOT_URL` | — | Base URL, e.g. `http://nautobot.example.com:8080` |
| `NAUTOBOT_TOKEN` | — | API token |
| `NAUTOBOT_ALLOW_WRITE` | `false` | Master gate for create/update/delete |
| `NAUTOBOT_VERIFY_SSL` | `true` | TLS verification |
| `NAUTOBOT_TIMEOUT` | `30` | Per-request timeout (seconds) |
| `NAUTOBOT_CACHE_DIR` | `./var` | Where schema sources and the index live |
| `NAUTOBOT_MAX_PAGE` | `1000` | Ceiling on `fetch_all` pagination |

Container-only knobs, read by the entrypoint rather than the server:

| Variable | Default | Purpose |
| --- | --- | --- |
| `MCP_TRANSPORT` | `streamable-http` | Transport the container serves (`stdio` for a client-spawned container) |
| `MCP_HOST` | `0.0.0.0` | Bind address for HTTP transports |
| `MCP_PORT` | `8000` | Bind port for HTTP transports |
| `NAUTOBOT_AUTO_INDEX` | `true` | Build a missing schema index on start instead of refusing to run |

### Register with a client

```jsonc
{
  "mcpServers": {
    "nautobot": {
      "command": "/path/to/nautobot-mcp/.venv/bin/python",
      "args": ["-m", "nautobot_mcp"],
      "env": {
        "NAUTOBOT_URL": "http://nautobot.example.com:8080",
        "NAUTOBOT_TOKEN": "...",
        "NAUTOBOT_CACHE_DIR": "/path/to/nautobot-mcp/var"
      }
    }
  }
}
```

HTTP transports are available too: `python -m nautobot_mcp --transport streamable-http
--port 8000`.

## Docker

```bash
cp .env.example .env        # then set NAUTOBOT_URL and NAUTOBOT_TOKEN
docker compose up -d        # or: make docker-up
```

The first start builds the schema index against your instance and stores it on the
`index` volume; later starts reuse it. The server listens on `127.0.0.1:8000/mcp`.

The index is not baked into the image, and cannot be: it is fused from the schemas of one
specific Nautobot instance, including whatever apps that instance has installed. Rebuild
it after installing or upgrading an app — `make docker-index`, or the
`nautobot_refresh_schema` tool, which writes to the same volume.

```bash
make docker-index                 # rebuild the index in place
make docker-logs                  # follow the server log
make docker-down                  # stop; VOLUMES=1 also drops the index
docker compose run --rm server index --offline   # rebuild from cached sources only
```

### Registering the container with a client

Over HTTP, point the client at the published port:

```jsonc
{
  "mcpServers": {
    "nautobot": { "url": "http://127.0.0.1:8000/mcp" }
  }
}
```

Or let the client spawn a container per session over stdio, reusing the same index volume:

```jsonc
{
  "mcpServers": {
    "nautobot": {
      "command": "docker",
      "args": [
        "run", "-i", "--rm",
        "--env-file", "/path/to/nautobot-mcp/.env",
        "-e", "MCP_TRANSPORT=stdio",
        "-v", "nautobot-mcp_index:/data",
        "nautobot-mcp:latest"
      ]
    }
  }
}
```

Anything passed after the image name goes straight to `python -m nautobot_mcp`, so
`docker run ... nautobot-mcp:latest --transport sse --host 0.0.0.0 --port 8000` works too.

### What the compose file assumes

- **The port is published on loopback only.** Section [Security](#security) applies in
  full: this is an unauthenticated proxy holding a token with your permissions, so
  reaching it from another host means putting authentication in front of it, not
  widening the port mapping.
- **Writes stay off** unless `NAUTOBOT_ALLOW_WRITE=true` is in your `.env`.
- **The container is hardened by default** — non-root (uid 1000), read-only root
  filesystem, all capabilities dropped, `no-new-privileges`. The only writable path is
  the `/data` volume, which is where the index and its cached sources belong.
- **Health is a TCP connect**, not an MCP request: an unsessioned request to `/mcp` makes
  the session manager allocate a transport that nothing reaps, so probing the protocol
  every 30s would leak a session per probe.
- **`.env` is read verbatim by compose.** Keep comments on their own line; a trailing
  `# comment` is not reliably stripped from a value.

## Tools

| Tool | Purpose |
| --- | --- |
| `nautobot_search_schema` | Find models by name, description or field name |
| `nautobot_describe_model` | Fields, required fields, FK targets, filters, actions |
| `nautobot_list_apps` | App namespaces (core and plugin), versions, index state |
| `nautobot_plan_create` | Ordered prerequisites for creating an object |
| `nautobot_resolve` | Human name → UUID, scoped to the referring model |
| `nautobot_list` / `nautobot_get` | Read any model, slimmed or projected |
| `nautobot_create` / `nautobot_update` / `nautobot_delete` | Gated writes |
| `nautobot_graphql` | Arbitrary GraphQL queries |
| `nautobot_graphql_schema` | Introspection, a type at a time |
| `nautobot_model_actions` | Non-CRUD endpoints (`trace`, `napalm`, `notes`, …) |
| `nautobot_call` | Any REST endpoint — plugins, bulk ops, custom actions |
| `nautobot_refresh_schema` | Re-fetch schemas and rebuild the index |

Model references are forgiving: `dcim.device`, `device`, `devices`, `Device`,
`/dcim/devices/` and `DeviceType` all resolve, and typos get suggestions
(`dvice` → "Did you mean: dcim.device?").

## Dependency planning

Creating a Device on an empty instance means creating four other objects first.
`nautobot_plan_create("dcim.device")` walks the FK graph, checks the live instance for
what already exists, and returns them in order:

```
dcim.manufacturer → dcim.devicetype → dcim.locationtype → dcim.location → extras.role → dcim.device
```

It also handles Nautobot's **content-type scoping**. `Role`, `Status` and `Tag` are only
assignable to models listed in their `content_types`. A global count is the wrong
question — an instance can hold 20 Roles while *none* apply to a Device:

```jsonc
{
  "model": "extras.role",
  "action": "create",                      // not "use_existing", despite 20 existing
  "content_type_scoped": true,
  "by_referrer": { "dcim.device": { "valid_count": 0 } },
  "note": "No extras.role is assignable to dcim.device yet. Create one with
           content_types including ['dcim.device'] ..."
}
```

Which models scope this way is discovered, not hardcoded: `content_types` means
"what may live here" on `LocationType` and "who may reference me" on `Role`. The planner
tries the scoped query and treats a 400 as proof that scoping does not apply — so
plugin models behave correctly with no extra code.

## Writes

Writes are off until `NAUTOBOT_ALLOW_WRITE=true`. Even then, mutations are two-step: the
first call returns a preview and a `confirm_token`, and the call is repeated with that
token to apply it. Tokens are derived from the payload, so one issued for one body cannot
be replayed against another. `nautobot_update` previews a field-level diff;
`nautobot_delete` previews the object and everything that references it.

## Security

**This server is an unauthenticated privileged proxy to Nautobot.** It holds an API token
and performs no authentication of its own: any client that can reach it acts with that
token's full permissions, without ever possessing the token.

Defaults are deliberately safe — `--host` binds `127.0.0.1` and `NAUTOBOT_ALLOW_WRITE` is
`false`. The risky configuration is combining a non-loopback bind with writes enabled,
which grants unauthenticated create/update/delete over your source of truth to anything
that can route to the port.

The confirm-token flow is an accident guard, not an access control — any client can read
the token from the preview response and confirm immediately.

If the server must be reachable by other hosts, put authentication in front of it (a
reverse proxy with mTLS, an OAuth-aware gateway, or an SSH tunnel) and give it a
Nautobot token scoped to only what the agent needs. See [SECURITY.md](SECURITY.md).

## Responsiveness

- One pooled HTTP/2 client is shared across tools; the planner fans out existence checks
  concurrently.
- Responses are slimmed before reaching the agent. Nautobot has no sparse-fieldset
  support (`?fields=` is rejected as an unknown filter), so `url`, `natural_slug`,
  `notes_url`, timestamps and empty custom-field blocks are dropped client-side, and
  nested related objects are reduced to identity. Pass `fields=[...]` to project, or
  `full=true` to opt out.

## Extending

Each toolset is a module exposing `register(server, ctx)`, listed in
`tools/__init__.py::TOOLSETS`. Registration is wrapped so every tool returns a structured
error instead of raising — an uncaught exception would reach the agent as an opaque
"Error executing tool X".

Plugin endpoints need no code: they appear in `/api/swagger.json`, so rebuilding the
index makes them available to every tool.

## Tests

```bash
pytest
```

82 tests run against fixtures carved from the live schema, with HTTP mocked via `respx`.
They pin the traps found while building this: the slug collision that maps
`virtualization.vminterface` onto DCIM's `InterfaceType`, the `content_types` scoping
that silently produces unusable plans, and the FK heuristic that resolves
`DynamicGroupMembership.group` to Django's `auth.Group` instead of `extras.DynamicGroup`.

## Agent configuration

[AGENT.md](AGENT.md) contains a ready-to-use system prompt and registry description for an
agent driving this server, including the write protocol and the content-type scoping rule
that most commonly causes a create to fail.

## Contributing

Issues and pull requests are welcome. `pytest` must pass and `ruff check` / `ruff format
--check` must be clean; CI enforces both across Python 3.11-3.13. The suite needs no
Nautobot instance and no network — it runs against schema fixtures in `tests/fixtures`
with HTTP mocked by `respx`.

## License

Apache 2.0 - see [LICENSE](LICENSE).

## Layout

```
src/nautobot_mcp/
  schema/build.py     fuses OpenAPI + GraphQL + content types into the index
  schema/index.py     read-only query layer (lookup, FTS search, graph)
  client.py           pooled async HTTP, slimming, error normalisation
  depgraph.py         creation planning and reference resolution
  safety.py           write gate, confirm tokens, diffs
  tools/              one module per toolset, registered through a guard
  server.py           MCP server assembly
```