Skip to main content
Glama

maasmcp

An MCP server that puts Canonical MAAS bare-metal provisioning in the hands of LLM agents — safely, type-checkedly, and without writing a curl one-liner ever again.

maasmcp speaks the Model Context Protocol over streamable-HTTP and exposes the MAAS 3.7 API to any MCP-aware client (Claude Code, Cursor, custom agents) as a single, fully hand-written surface:

126 hand-written tools — opinionated, type-checked, safety-gated. High-level workflow verbs (acquire_machine, deploy_machine, apply_storage_layout, network_layout, bulk_power_control_machines, …) for the jobs you actually do, plus direct per-resource tools (machines_create, tag_update, user_delete, …) for everything else. Every tool is a small class with name, description, and run, Pydantic-Field-documented parameters, and a confirm gate on every destructive write. Each tool also publishes MCP ToolAnnotations (title, readOnlyHint, destructiveHint, idempotentHint) and a leading [domain] tag in its description, so clients can navigate the surface without memorizing 126 names.

Current version: 0.10.1 · License: Apache-2.0 · Python: ≥ 3.11


Table of contents


Related MCP server: MCP VPS Manager

Why maasmcp?

MAAS is the de-facto bare-metal provisioning system for Linux fleets. It has a comprehensive REST API — hundreds of endpoints across machines, networks, storage, fabrics, VLANs, subnets, users, etc. — but the raw surface has rough edges for an LLM:

  • Path/query/body parameter shapes vary per resource (/machines/?hostname=X vs POST /machines/ with multipart form fields).

  • Identifier drift: internal MAAS ids (VLANs, fabrics) vs IEEE 802.1Q VIDs vs system_ids vs hostnames. The agent has to know which one each call wants.

  • Pagination is universal but client-side invisible — list endpoints return paginated slices by default and an LLM rarely thinks to walk them.

  • Destructive writes don't confirm. One slip of a delete_* or release_machine call can wipe a fleet.

maasmcp solves these by:

  • Hand-writing every tool with Pydantic-typed parameters and Field(description=…) docs taken from MAAS's own API documentation (vendored in docs/reference/maasapi.md), so the LLM sees the same description a human would.

  • Curating high-level verbs that handle the messy parts — VLAN-vs-VID translation, auto-pagination, hostname-to-system_id resolution, partial-failure aggregation on bulk ops.

  • Gating every destructive write behind a confirm: bool = False default. Calling delete_block_device(system_id, id) without confirm=True raises ValueError before any MAAS call. Use get_block_device to verify the target first.


Architecture

                 ┌────────────────────────────────────────┐
                 │      MAAS region controller            │
                 │        /api/2.0/ (REST API)            │
                 └─────────────────┬──────────────────────┘
                                   ▲
                                   │ async httpx + OAuth
        ┌──────────────────────────┴───────────────────────┐
        │   src/maasmcp/maas/                              │
        │   ├── client.py       (async httpx + OAuth)      │
        │   ├── errors.py       (typed exception hierarchy)│
        │   ├── paginated_get.py (auto-paginate helper)    │
        │   ├── binding.py      (hide client param from    │
        │   │                    published tool schemas)   │
        │   └── singleton.py    (shared client at runtime) │
        └──────────────────────┬───────────────────────────┘
                               │
                               ▼
              ┌────────────────────────────────────┐
              │   src/maasmcp/curated/             │
              │   126 hand-written tools           │
              │   (one small class per file)       │
              └─────────────────┬──────────────────┘
                                ▼
              ┌─────────────────────────────┐
              │  src/maasmcp/registry.py    │
              │  MCPServer wiring + rebind  │
              └─────────────────────────────┘
                                ▼
              ┌─────────────────────────────┐
              │  Starlette + uvicorn        │
              │  /healthz   (no auth)       │
              │  /mcp       (bearer auth)   │
              └─────────────────────────────┘
                           ▲
                           │   streamable-HTTP
                           │
                  ┌────────┴────────┐
                  │   MCP client    │
                  │  (Claude Code,  │
                  │   Cursor, …)    │
                  └─────────────────┘

Two key design properties:

  • Everything is hand-written. One standard per tool: a small class (name, description, run) in its own module under src/maasmcp/curated/, registered and enriched via curated/_common.py::register_curated. The server is deterministic and installable without a live MAAS.

  • Dependency direction is one-way. Tools depend on the shared MAAS client (maas/client.py); high-level workflow verbs additionally compose direct tools or raw calls.


Quickstart

Install

pip install maasmcp

Or from a checkout:

pip install -e ".[dev]"

Run

The server needs three settings: the MAAS base URL, a MAAS API key (to talk to MAAS), and a bearer token (to gate /mcp so random agents can't drive your fleet). Environment variable names use the MAASMCP_ prefix with __ as the section delimiter:

export MAASMCP_MAAS__BASE_URL="https://maas.example.com/MAAS"
export MAASMCP_MAAS__API_KEY="<your-maas-api-key>"
export MAASMCP_SERVER__BEARER_TOKEN="<choose-a-strong-random-token>"
maasmcp serve

By default it listens on 0.0.0.0:8765. Override with MAASMCP_SERVER__HOST and MAASMCP_SERVER__PORT.

Point your MCP client at http://localhost:8765/mcp and authenticate with the bearer token. Liveness check is at GET /healthz (no auth):

curl http://localhost:8765/healthz
# → healthy

Readiness probe: GET /readyz checks the MAAS upstream and returns 503 when MAAS is unreachable. It requires the bearer token (only /healthz is unauthenticated) — pass -H "Authorization: Bearer $TOKEN" from your probe config.

maasmcp speaks streamable-HTTP only (no stdio transport). For Claude Code:

claude mcp add --transport http maas http://localhost:8765/mcp \
  --header "Authorization: Bearer <your-bearer-token>"

For Claude Desktop or other stdio-only clients, bridge through an stdio↔HTTP proxy such as mcp-proxy.


Configuration

Settings come from a YAML config file or MAASMCP_* environment variables — the two modes are exclusive (when --config is given, the environment only fills ${VAR} placeholders inside the YAML; there is no per-key env override of file values). Secrets use ${VAR} indirection so they never sit in committed config:

# config.yaml
maas:
  base_url: https://maas.example.com/MAAS
  api_key: ${MAAS_API_KEY}
server:
  bearer_token: ${MAASMCP_BEARER_TOKEN}
  host: 0.0.0.0
  port: 8765
  log_level: info
maasmcp serve --config config.yaml

The relevant settings:

Setting

Default

Purpose

maas.base_url / MAASMCP_MAAS__BASE_URL

(required)

MAAS base URL

maas.api_key / MAASMCP_MAAS__API_KEY

(required)

MAAS API OAuth key

server.bearer_token / MAASMCP_SERVER__BEARER_TOKEN

(required)

Bearer token gating /mcp; /healthz is exempt

server.host / MAASMCP_SERVER__HOST

0.0.0.0

Bind address

server.port / MAASMCP_SERVER__PORT

8765

HTTP port

server.log_level / MAASMCP_SERVER__LOG_LEVEL

info

uvicorn / structlog level

server.max_paginated_items / MAASMCP_SERVER__MAX_PAGINATED_ITEMS

1000

Hard cap on auto-paginated list results

The API key and bearer token are read by the runtime, never logged. The observability layer logs bearer tokens only as a 6-char correlation prefix.


Tools

All tools that target a machine accept either system_id or hostname (exactly one); hostname is resolved server-side. (Exceptions: get_machine takes a combined identifier; acquire_machine filters by hostname rather than addressing; machines_power_parameters takes id; machine_update's hostname field sets the new name.)

High-level workflow verbs

These are the opinionated verbs for the jobs you actually do. The tool surface registers 126 hand-written tools organised by domain, of which the workflow verbs compose others or raw MAAS calls — the plumbing is invisible to the agent.

Reads (no gate)

find_available_machines, list_machines, get_machine, power_status, wait_for_state, summary, network_layout, storage_summary, list_block_devices, get_block_device, list_partitions, get_partition, list_raids, get_raid, list_volume_groups, get_volume_group, list_fabrics, get_fabric, list_spaces, get_space, list_vlans, get_vlan, list_subnets, get_subnet, list_ip_ranges, get_ip_range, list_interfaces, get_interface.

Machine lifecycle (gated writes)

acquire_machine (allocating a machine removes it from the shared pool, so it is gated too), release_machine, deploy_machine, power_control.

Single-host tag ops (gated)

tag_machine, untag_machine.

Bulk fleet operations (gated, partial-failure aware)

bulk_release_machines, bulk_tag_machines, bulk_untag_machines, bulk_power_control_machines.

Storage writes (gated, confirm=True)

create_block_device, update_block_device, delete_block_device, set_boot_disk, clear_boot_disk (gap-closure — surfaces MAAS's missing clear/unset verb as a structured status: "unsupported" response with available_workarounds; never issues a fake write call), format_block_device, mount_block_device, create_partition, delete_partition, format_partition, mount_partition, unmount_partition, create_raid, update_raid, delete_raid, create_volume_group, update_volume_group, delete_volume_group, create_logical_volume, delete_logical_volume.

Networking writes (gated, confirm=True)

create_fabric, update_fabric, delete_fabric, create_space, update_space, delete_space, create_vlan, update_vlan, delete_vlan, create_subnet, update_subnet, delete_subnet, create_ip_range, update_ip_range, delete_ip_range, create_interface, update_interface, delete_interface, assign_vlan_to_interface, create_bond, create_bridge, create_interface_vlan, link_subnet, unlink_subnet.

Composite writes (v0.6.0, gated, confirm=True)

apply_storage_layout.

Direct MAAS API tools

Beyond the workflow verbs, maasmcp exposes 33 hand-written direct tools mapping 1:1 to MAAS REST operations (same names the MAAS API uses) for anything the workflow layer does not cover:

  • Machine ops: machine_abort, machine_commission, machine_delete, machine_details, machine_lock, machine_unlock, machine_mark_broken, machine_mark_fixed, machine_power_parameters, machine_update, machines_accept, machines_create, machines_is_registered, machines_list_allocated, machines_power_parameters, machines_read, machines_set_zone

  • Collection reads: fabrics_read, spaces_read, subnets_read, vlans_read, ipranges_read

  • Tags: tag_delete, tag_read, tag_update, tags_create, tags_list, tags_read

  • Users: user_delete, user_read, users_create, users_list, users_read

List endpoints auto-paginate up to max_paginated_items (default 1000). Collection read tools (*_read, *_list) accept paginate=False to fetch exactly one MAAS-server-sized page instead.

Discovering tools

With 126 tools, browsing tools/list can be overwhelming. Use the tools_index helper to get a grouped catalog:

tools_index()
# → {
#     "groups": {
#       "machines": [
#         {"name": "list_machines", "title": "List Machines",
#          "description": "List machines in MAAS.", "read_only": true,
#          "destructive": false, "idempotent": true},
#         ...
#       ],
#       "storage": [...],
#       "network": [...],
#       "tags": [...],
#       "users": [...],
#       "zones": [...],
#       "fleet": [...],
#       "system": [...],
#       "meta": [
#         {"name": "tools_index", "title": "Tools Index", ...}
#       ]
#     }
#   }

Every tool description also begins with a [domain] tag (for example, [storage] Create a block device...) and exposes MCP ToolAnnotations:

  • title — human-friendly Title Case name.

  • readOnlyHint — true for reads, lists, and composite reads.

  • destructiveHint — true for all state-mutating writes (every tool that is not read-only).

  • idempotentHint — true for read-only calls.

These hints let an MCP client filter or sort the tool list without parsing free-text descriptions.

The confirm safety gate

Every destructive verb accepts confirm: bool = False and raises ValueError before any MAAS call when confirm is missing or False. The default exists for one reason: if an LLM calls the tool without thinking, nothing happens. To actually execute the operation, the agent must re-invoke with confirm=True — ideally after using a read verb to verify the target.

agent → delete_block_device(system_id="abc", block_device_id=42)
maasmcp → ValueError: delete_block_device requires confirm=True
agent → get_block_device(system_id="abc", block_device_id=42)
maasmcp → {name: "sdc", size: 500107862016, …}
agent → delete_block_device(system_id="abc", block_device_id=42, confirm=True)
maasmcp → {status: "ok", operation: "delete"}

Networking writes are cluster-wide — a typo'd VLAN or subnet can affect every machine on a fabric. They use the same gate but have no business-rule pre-flight checks; the agent is responsible for using get_vlan / get_subnet / get_fabric to verify the target first.


Composite workflows

The three composite tools are the most distinctive features of maasmcp. They exist because the equivalent MAAS calls would require a half-dozen round-trips and careful sequencing that an LLM reliably gets wrong.

network_layout — full topology in one call

Returns the machine's interface → VLAN → subnet → fabric tree, plus link-state classification that flags LACP member-suppression as the expected pattern (not a fault). Replaces 5–10 sequential reads with one.

network_layout(system_id="eec43g")
# → {
#     "system_id": "eec43g",
#     "hostname": "living-ewe",
#     "interfaces": [
#       { "name": "eno1", "vlan": 79, "subnet": "10.0.79.0/24", "fabric": "fabric-0",
#         "link_state_classification": "bond_up_members_down" },  # ← expected for LACP
#       { "name": "bond0", "vlan": 79, "subnet": "10.0.79.0/24",
#         "members": [42, 43], "link_state_classification": "bond_up_members_down" },
#     ],
#     "errors": []
#   }

On Stage 1 failure it returns partial=True plus errors; the agent decides whether to retry.

storage_summary — storage at a glance

Counts of block devices, partitions, total size in GiB, plus RAID, volume group, and logical volume counts — fetched in two stages (sequential list_block_devices, then a fat gather of per-device partitions + raids + VGs in parallel).

storage_summary(system_id="eec43g")
# → {
#     "block_device_count": 2, "partition_count": 4, "total_size_gib": 1788.5,
#     "raid_devices": 1, "volume_groups": 0, "logical_volumes": 0
#   }

apply_storage_layout — RAID 1 with /boot/efi carved out

The classic RAID-with-ESP layout is a sequence of dependent operations: carve partitions, format the ESP, format the data partitions, build the RAID, format the RAID, mount everything, and tell MAAS which disk is the boot disk. Forget one step or get the order wrong and the system won't boot.

apply_storage_layout takes a single dict spec and sequences create_partition, format_partition, mount_partition, create_raid, and set_boot_disk for you, in the right order. Three layout types are supported:

  • raid_with_esp — partitions carved on each member disk, ESP (fat32 mounted at /boot/efi) on raw disks, RAID over the remaining partitions, rootfs on RAID. Use this for RAID 1 with /boot/efi off-array.

  • raid — partitions carved on each member disk, RAID over all of them. No ESP (BIOS boot only).

  • plain — no RAID, one or more disks partitioned and optionally formatted + mounted.

apply_storage_layout(
    system_id="eec43g",
    layout={
        "layout_type": "raid_with_esp",
        "disks": [
            {"block_device_id": 1, "partitions": [
                {"size": "1G", "fstype": "fat32", "mount_point": "/boot/efi"},
                {"size": "remaining"},
            ]},
            {"block_device_id": 2, "partitions": [
                {"size": "1G", "fstype": "fat32"},   # mirror copy of the ESP, no mount
                {"size": "remaining"},
            ]},
        ],
        "raid": {"level": 1, "name": "md0", "filesystem": "ext4", "mount_point": "/"},
        "boot_disk": {"block_device_id": 1},
    },
    confirm=True,
)

Returns {status: "complete" | "partial", completed_steps, failed_step, errors, rollback_hint}. On partial there is no automatic rollback — the agent decides whether to retry, restore via MAAS's raw op-restore_storage_configuration endpoint (not a curated verb), or live with the partial state.

Requires MAAS cluster setting default_storage_layout=blank so MAAS does not re-layout storage on commission/deploy. If your MAAS uses a different default, this layout will not survive to deploy.

Manual recipe (driving leaf verbs directly)

For incremental workflows where you want full control:

with MAAS cluster setting default_storage_layout=blank:
  create_partition × N        # one per disk, sized to fit ESP + rootfs
  format_partition × N        # ESP and rootfs partitions
  mount_partition × N         # /boot/efi and /boot on the appropriate ones
  create_raid(level=1, partitions=[...])
  format_block_device(raid_id, fstype)
  mount_block_device(raid_id, "/")
  set_boot_disk(block_device_id=...)
  deploy_machine(system_id, distro="ubuntu/noble")

Bulk fleet operations

The four bulk verbs fan out across many machines and aggregate results so partial failures are visible without raising:

  • bulk_release_machines(system_ids, erase=True, comment=…) — uses MAAS's batch endpoint, returns per-host status.

  • bulk_tag_machines(system_ids, tag) — asyncio.gather with a Semaphore(10) cap.

  • bulk_untag_machines(system_ids, tag) — same shape.

  • bulk_power_control_machines(system_ids, action) — action ∈ {on, off, cycle}; per-host errors reported as upstream <status>.

bulk_power_control_machines(system_ids=["eec43g", "8smce7"], action="cycle")
# → [{"system_id": "eec43g", "status": "ok"},
#    {"system_id": "8smce7", "status": "error", "error": "upstream 504"}]

Inspect every entry — bulk verbs never raise on partial failure, so an agent that doesn't iterate the result list will silently leave failing hosts in their pre-call state.


Deployment

A Dockerfile is included. The MAAS URL, API key, and bearer token drive the whole process (nested MAASMCP_ names, same as docker-compose.yml):

export MAASMCP_MAAS__BASE_URL="https://maas.example.com/MAAS"
export MAASMCP_MAAS__API_KEY="<your-maas-api-key>"
export MAASMCP_SERVER__BEARER_TOKEN="<choose-a-strong-random-token>"  # required, no default

Build and run:

docker build -t maasmcp:0.10.1 .
docker run --rm -p 8765:8765 \
  -e MAASMCP_MAAS__BASE_URL -e MAASMCP_MAAS__API_KEY -e MAASMCP_SERVER__BEARER_TOKEN \
  maasmcp:0.10.1

A compose file (docker-compose.yml) is included and uses the same variable names. Note: compose substitutes an unset ${VAR} as an empty string — the server rejects an empty bearer token at startup, so a missing MAASMCP_SERVER__BEARER_TOKEN fails closed rather than running unauthenticated.

Health probe (no auth, useful for k8s liveness/readiness):

curl -fsS http://localhost:8765/healthz

Development

Adding or changing a tool against a newer MAAS

All tools are hand-written. To support a newer MAAS API version, consult the vendored spec (docs/reference/maasapi.md) or a live /api/2.0/describe/, then add or update the tool class under src/maasmcp/curated/, register it in curated/_common.py, and make sure src/maasmcp/curated/_catalog.py maps its name to the right domain and annotations. Document the change in CHANGELOG.md, bump the package version, run the full suite, then cut a release tag.

Tests

pytest                 # unit + integration (702 passed, 3 env-gated skips)
pytest -m e2e --no-cov # end-to-end against a live MAAS (gated; --no-cov: the
                       # coverage floor is sized for the full suite)
ruff check src tests
ruff format --check .
mypy --strict src
  • Unit tests cover every tool, the MAAS client, config, and auth.

  • Integration tests boot the Starlette app under uvicorn and drive MCP initialize/tools/list/tools/call over streamable-HTTP, verifying the bearer middleware.

  • E2E tests (pytest -m e2e) assert against a real MAAS instance. Off by default (env-gated skips): set MAASMCP_E2E_MAAS_URL + MAASMCP_E2E_SYSTEM_ID (bulk/storage flows) and MAASMCP_E2E_URL + MAASMCP_E2E_KEY (acquire/release flow). Nothing runs them automatically — they're for manual pre-release verification.

Layout

src/maasmcp/
├── cli.py                  # typer: serve
├── config.py               # pydantic-settings + ${VAR} expansion
├── server.py               # Starlette app factory + uvicorn entry
├── registry.py             # MCPServer instance + tool registration
├── auth.py                 # bearer-token middleware (healthz exempt)
├── observability.py        # structlog config + sanitizers
├── maas/                   # async httpx client, errors, pagination, rebind
└── curated/                # 126 hand-written tools
    ├── _common.py          # registration + shared helpers
    └── …                   # one file per tool

tests/
├── unit/                   # all 126 tools, MAAS client, config, auth, registry pin
├── integration/            # HTTP transport handshake under uvicorn
├── fixtures/maas/          # response fixtures
└── e2e/                    # live MAAS, gated by marker

The full design rationale lives at docs/specs/2026-07-07-maasmcp-design.md. Implementation plans for each release are at docs/plans/2026-07-07-*.md. The vendored copy of the MAAS API v2 documentation (the authoritative reference for endpoint semantics) is at docs/reference/maasapi.md.


Security notes

  • Two credentials live in this process: MAASMCP_MAAS__API_KEY (used to call MAAS) and MAASMCP_SERVER__BEARER_TOKEN (used to gate /mcp). Neither is ever logged in full. Bearer tokens appear in logs only as a 6-char correlation prefix. The bearer token must be non-empty (empty is rejected at startup and by the middleware) — an empty token would otherwise authenticate headerless requests.

  • machine_power_parameters / machines_power_parameters expose BMC/IPMI credentials — MAAS's power_parameters op returns the BMC username/password for IPMI-type machines. Anyone holding the MCP bearer token can read them; treat the bearer token accordingly.

  • The confirm gate is your last line of defence. Destructive and state-mutating verbs (including machine allocation and tagging, which change fleet state) raise before any MAAS call when confirm=True is missing. Treat the gate as the contract — never weaken it.

  • Bulk verbs return per-host status, never raise on partial failure. An agent that doesn't iterate the result list will silently leave the failing hosts in their pre-call state. Inspect every entry.

  • apply_storage_layout is destructive and irreversible at the storage layer. Use storage_summary and list_block_devices first to confirm disk identity.

  • Networking writes are cluster-wide. A typo'd VLAN deletes more than you intended. Verify with get_vlan / get_subnet / get_fabric before confirm=True.

  • LACP bond member suppression is normal. link_state_classification: "bond_up_members_down" is the expected pattern for healthy LACP — the slave interfaces are suppressed by the kernel. Only "bond_down_members_down" indicates a real physical-layer fault.


License

Apache-2.0. See LICENSE.

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    C
    maintenance
    Enables AI agents to interact with Microsoft Fabric by exposing tools for managing workspaces, notebooks, SQL queries, pipelines, and Livy Spark sessions. It provides a comprehensive set of operations for data engineering and analytics tasks using standard Azure authentication.
    37
    4
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables LLMs to securely manage Virtual Private Servers via SSH, with features including command execution, file operations, system monitoring, and service management.
    1
    MIT
  • A
    license
    B
    quality
    B
    maintenance
    Enables AI CLI agents to operate an aaPanel (btPanel fork) hosting panel through its signature-authenticated HTTP API, exposing 57 described operations covering websites, databases, SSL certificates, backups, and system status plus resources for panel inventories. Ships with a read-only default mode and a separate gate over credential-exposing and file-level operations, redacting generated passwords from tool results.
    59
    7 npm
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables LLM agents to operate Cloud.ru MLSpace public API v2 through 17 action-based tools covering training, inference, notebooks, TensorBoards, workspaces, allocations, queues, Docker Registry, data transfer, and resource catalog, with read-only mode, dry-run, and confirmation gates for irreversible actions.
    MIT