Skip to main content

mcfortigate

An MCP server that lets a language model read a FortiGate firewall and answer questions about it.

Read-only. Nothing in this server can change a configuration.

Full documentation: mcfortigate.warehack.ing

Why not just wrap the REST API

The obvious way to build this is one tool per FortiOS endpoint returning raw JSON, and that turns out to work badly. A single FortiOS policy object carries more than eighty fields, most of them empty strings, unused IPv6 arrays, and internal UUIDs. Ask a model "which policies allow traffic into the DMZ" against a raw wrapper and it spends most of its attention on "srcaddr6": [] before it finds anything useful.

So the tools here are shaped around the questions operators actually ask rather than around the endpoints FortiOS happens to expose. Three examples.

"Is this address object safe to delete?" is find_references, and it is the tool that shaped the rest of this server.

The obvious implementation scans the tables that obviously hold references: policies, groups, virtual IPs, routes. The appliance disagrees about what "obviously" covers. Asked directly, FortiOS 7.0.14 reports 74 tables that can reference a firewall address and 234 that can reference an interface. An object used only by a web-proxy profile comes back from the obvious implementation as unreferenced, which is a wrong answer in the direction that destroys data.

So the authority here is the appliance's own reference lookup, the one behind the reference counter in its web UI. Two things about it are worth knowing. Every row it returns carries reference_count: 0, genuine references included, so the presence of a row is the signal and the count is a trap. And asked about a key that is absent from the table you named, it answers success with an empty list, so guessing the wrong table reports an in-use object as free. Querying the address table about wan1 finds nothing while the interface table finds two references, which is why this tool works out what kind of object a name refers to before it asks.

It also walks past the direct referrer. The appliance's lookup is not transitive: an address inside a group reports the group and stops, so the policy that actually breaks never appears. On our lab, asking about the switch port internal1 directly returns one row — a virtual switch. Walking two more levels, through the switch and the interface that carries it, reaches the firewall policy that a change to that port would disturb. Reached references are reported separately from direct ones, each carrying the chain that found it, and the walk is depth-capped and says so when it stops early.

The answer is five-valued rather than a boolean, because there are genuinely five things that can be true. Beyond referenced and no_references there is object_not_found for a name that matches nothing, no_references_in_checked_scopes for when the authoritative lookup was unavailable and only the partial scan ran, and indeterminate for when the scan itself was incomplete. safe_to_delete appears only for the first two. A token scoped to firewall objects gets 403 on the routing table, and the client library turns every error status into an empty list, so doing least privilege correctly makes a false clean bill of health more likely, not less.

"What is 198.51.100.47?" is find_device. It searches the wireless client list, the DHCP lease table, and the ARP table, then merges what each one knows about a MAC into a single record.

MAC addresses are normalized before that join, and before matching a query against them. The appliance we test on spells every MAC the same way, so the normalization is insurance rather than an observed fix on that firmware — but the query side is a real defect it closes: a MAC pasted from anywhere else arrives as 20-47-47-7D-DB-7B or 2047.477d.db7b, and a substring match against the colon-separated table form finds nothing and reports the device unknown. The matcher also refuses to read an IPv4 address as a MAC fragment, since an address is nothing but hex digits and dots and a loose matcher would let one match an unrelated device.

"Where does 203.0.113.0/24 appear?" is search_config, which looks through addresses, groups, services, interfaces, routes, and policies at once, for when you do not yet know which kind of object holds the answer.

Install

uvx mcfortigate

Add it to Claude Code. Read the token rather than typing it on the command line, so it does not land in your shell history:

printf 'FortiGate API token: '; read -rs FORTIGATE_TOKEN; echo

claude mcp add fortigate \
  --env FORTIGATE_HOST=fgt.example.com \
  --env FORTIGATE_TOKEN="$FORTIGATE_TOKEN" \
  -- uvx mcfortigate

History records the literal "$FORTIGATE_TOKEN", because the line is recorded before the shell expands it.

That is worth doing and it is not sufficient. Claude Code stores MCP environment values in ~/.claude.json in plaintext, and nothing on this side changes that. Treat the token as readable at rest and put the real control on the appliance: a read-only profile, and a trusted-host list naming only the machine that runs this. Those hold even if the token leaks; keeping it out of history only means it leaks from one fewer place.

Configuration

Create a read-only REST API admin on the FortiGate under System > Administrators > Create New > REST API Admin, give it a read-only profile, and restrict its trusted hosts to whatever runs this server. Then set two variables:

FORTIGATE_HOST=fgt.example.com
FORTIGATE_TOKEN=your-token

A username and password pair works as a fallback on older firmware, via FORTIGATE_USERNAME and FORTIGATE_PASSWORD, but a token is better because it avoids a login round-trip on every call.

For several appliances, set FORTIGATE_TARGETS to a JSON object. The keys become the target argument on every tool, so short aliases are worth it:

FORTIGATE_TARGETS='{
  "edge":   {"host": "fgt-edge1.example.com", "token": "..."},
  "branch": {"host": "fgt-br2.example.com", "token": "...", "verify_ssl": false}
}'

With one appliance configured the target argument can be omitted everywhere. With several it is required, and omitting it produces an error listing the valid aliases so the model can correct itself.

See .env.example for the optional settings, which are FORTIGATE_NAME, FORTIGATE_VDOM, FORTIGATE_VERIFY_SSL, FORTIGATE_TIMEOUT, and FORTIGATE_PORT.

Tools

Orientation

Tool Answers
list_targets Which appliances can this server reach
get_system_status Model, serial, firmware, hostname, CPU and memory
search_config Where does this term appear, anywhere in the config

Firewall

Tool Answers
list_address_objects What named addresses exist, with readable values
list_address_groups What groups exist and what is in them
list_services What services exist, with protocols and ports
list_policies What rules exist, in evaluation order, with filters
list_vips What destination NAT is configured
find_references What points at this object, and is it safe to delete

Network

Tool Answers
list_interfaces What interfaces exist, with addresses and link state
list_vlans What VLANs exist, with tags and parent interfaces
list_static_routes What routes were configured
get_routing_table What routes are actually in use right now

Live state

Tool Answers
list_wifi_clients Who is on the wireless right now, with hostnames
list_dhcp_leases What leases are currently issued
get_arp_table What IP-to-MAC bindings the appliance sees
find_device Who is this MAC, IP, or hostname

Configuration tools read what the appliance was told to do. Live tools read what it currently observes, and those answers are true only at the moment of the call. The distinction matters more than it sounds: a DHCP-assigned default route shows up in get_routing_table and never in list_static_routes, because it was never configured.

Why the listing tools page and the others do not

Every listing tool takes limit and offset, defaults to 200 rows, and always reports total_available. A partial page additionally carries truncated and next_offset.

The problem this solves is not memory. A FortiGate with four thousand ARP entries produces a response Python handles without noticing; what breaks is an MCP client with a size limit truncating the payload on the way to the model, which then reads whatever survived as the whole answer. Nothing in the data says otherwise. Bounding the result here makes the same truncation visible instead of silent.

The analysis tools — find_references, search_config, find_device — take no limit on purpose. They scan in order to reach a verdict, and a verdict from a partial scan is not a shorter answer, it is a wrong one.

FortiOS behaviors handled here

These were found against real hardware rather than in documentation, and each one fails silently rather than loudly, which is what makes them worth writing down.

Boolean fields are strings. FortiOS writes "enable" and "disable" where JSON would use true and false. Passing those through bool() reads every disabled thing as enabled, since bool("disable") is True. That single mistake once flipped every non-blackhole route to blackhole.

The same format means two things. A dotted-mask string like 203.0.113.0 255.255.255.0 is a network in firewall.address.subnet but the interface's own address in system.interface.ip. Collapsing the second to its network address invents IPs that do not exist.

Relational fields change shape by version. Fields such as dstaddr arrive as [{"name": "X"}] on FortiOS 7.2 and later, but as a bare string on 7.0.x. Both are handled.

dst is a placeholder when dstaddr is set. When a route points at a named address object, FortiOS writes the all-zeros sentinel into dst. Reading dst first turns every named-destination route into a bogus default route.

Identity lives in the envelope, not the body. The appliance serial, firmware version, and build number are siblings of results on every cmdb response, not inside it. Helpers that unwrap straight to results discard them, which makes the serial look absent from the API entirely.

FortiOS creates its own interfaces. Names prefixed wqtn., vap., ssl., and naf. are bookkeeping, generated alongside VAPs and VPN tunnels. They are hidden by default, since no operator made them and none can act on them. Pass include_internal=true to see them.

Development

uv venv && uv pip install -e ".[dev]"
uv run pytest
uv run ruff check src/ tests/

The reading layer in fortios.py and the configuration layer in config.py are pure functions with no network or framework coupling, so the test suite runs in under a second with no fixtures and no mock appliance.

The FortiOS behaviors above were learned while building nautobot-ssot-fortinet, which syncs FortiGate configuration into Nautobot in both directions. This server reuses that knowledge for a different purpose.

License

Apache-2.0

Metadata

Release files for mcfortigate 2026.9.12.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mcfortigate 2026.9.12.1
File Size Uploaded
mcfortigate-2026.9.12.1.tar.gz 69.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mcfortigate 2026.9.12.1
File Interpreter ABI Platform
mcfortigate-2026.9.12.1-py3-none-any.whl Python 3 none any Details

Total release size: 132.8 kB

Release files / mcfortigate-2026.9.12.1.tar.gz

Download URL mcfortigate-2026.9.12.1.tar.gz
Size 69.1 kB
Tags Source
SHA-256 checksum
How to use checksums
7adda459dba1065e4d87fdb576c1416390419411c2744988a14c5762d4dbd76a
BLAKE2b-256 checksum
How to use checksums
b8b0afebd7159349cd6b0de2c5a06578c827fca95d875ca49e372ec54c17d0a4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"EndeavourOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / mcfortigate-2026.9.12.1-py3-none-any.whl

Download URL mcfortigate-2026.9.12.1-py3-none-any.whl
Size 63.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
132333590248f43fb286dd6c032898b889b1732799013df8abe039c6bf4bd4b4
BLAKE2b-256 checksum
How to use checksums
c86492ee8864e215c11fc339a7d8752134c4fe0806f555de35c4d7b0b4fb41b4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"EndeavourOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

This release

2026.9.12.1 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page