Skip to main content
Glama

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.

Related MCP server: netmiko-mcp

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

Available Tools

17 tools
find_deviceIdentify a device by MAC, IP, or hostnameA
Read-onlyIdempotent

Identify a device on the network by MAC, IP, or hostname fragment.

Searches the wireless client list, the DHCP lease table, and the ARP table together, then merges everything known about each matching device into one record. A device seen in several places produces one result rather than three partial ones.

This is the tool for questions like "what is 198.51.100.47", "is that laptop on the network", or "which SSID is this MAC on".

A MAC query matches whatever punctuation the appliance used, so 20-47-47-7d-db-7b, 2047.477d.db7b, and 20:47:47:7d:db:7b all find the same device. An IP query is never treated as a MAC.

Inputs: query (required), vdom, target. Returns: object

find_referencesFind what references an objectA
Read-onlyIdempotent

Find what references an address, service, or interface, before changing it.

This answers the question that precedes every firewall change, which is whether something is safe to touch.

The authority is the appliance itself. FortiOS exposes the same reference lookup its web UI uses, which knows every table that can hold a reference, seventy-four of them for a firewall address on 7.0.14. This tool asks that endpoint and reports what it says in references. It also scans policies, groups, virtual IPs, and static routes directly, because those yield readable detail the endpoint does not, such as a policy's name and action.

Read verdict rather than inferring from a count:

  • referenced, something points at it

  • no_references, the appliance confirmed nothing does

  • no_references_in_checked_scopes, the authoritative lookup was unavailable and a partial scan found nothing, which is a fact about four tables rather than about the appliance

  • object_not_found, no address, group, service, virtual IP, or interface by this name exists, so the question is probably a typo

  • indeterminate, something needed could not be read

safe_to_delete appears only for the first two, because a table this tool could not read cannot support a claim that nothing references the object. A denied read is the likely outcome for a correctly least-privileged token, so an incomplete answer is normal rather than exceptional, and sources_checked names what failed.

The two lists count different things, and will disagree without being in conflict. total_references and references count reference sites: a policy using one address as both its source and its destination is two. The detail lists (policies, groups, vips, routes) count objects, so the same policy appears once there, with referenced_as naming both roles. Neither number is wrong; prefer references when reporting what must be changed before a delete, and the detail lists when naming the objects an operator has to open.

Containers are walked through, and the results are kept separate. references holds what the appliance named directly and every row carries depth: 0. transitive_references holds what was reached through an address group, a service group, a zone, or a switch: each row carries the depth it was found at and a via chain of the container names that led to it. The distinction is the remedy. A direct reference is removed from the object holding it; a transitive one is removed by editing a container or a member list, and the policy that stops matching is not the object you edit.

expansion reports how far the walk got. status is complete when the chain ran out, depth_capped when it hit the ceiling with containers still unopened, which are then named in unexpanded, and incomplete when something along the way could not be read. Only complete means the transitive list is the whole blast radius.

Inputs: object_name (required), vdom, target. Returns: object

get_arp_tableShow the ARP tableA
Read-onlyIdempotent

Show the ARP table, which is the IP-to-MAC bindings the appliance sees.

ARP catches devices DHCP does not, meaning anything with a static address, so it is the fallback when a device is present but holds no lease. MAC addresses are reported in one canonical form.

Inputs: vdom, limit, offset, target, +1 more. Returns: object

get_routing_tableShow the active routing tableA
Read-onlyIdempotent

Show the active IPv4 routing table as the appliance is forwarding it.

This is live state rather than configuration, so it includes connected routes, dynamically learned routes, and routes handed over by DHCP, none of which appear in the static route configuration.

Inputs: vdom, limit, offset, target, +1 more. Returns: object

get_system_statusShow appliance identity and healthA
Read-onlyIdempotent

Report appliance identity and health: model, serial, firmware, load.

The serial and firmware version come from the response envelope rather than the body. FortiOS puts them as siblings of the results object on every cmdb call, and helpers that unwrap straight to results discard them, which is why they appear missing from the API until you read the raw response.

Fields that a given firmware does not report are omitted rather than returned as null, so an absent key means the appliance did not offer the value. FortiOS 7.0.14, for instance, reports no uptime anywhere in the monitor tree.

Inputs: target. Returns: object

list_address_groupsList address groupsA
Read-onlyIdempotent

List firewall address groups and their members.

Compare count against total_available before concluding anything about the whole table. When truncated is present this is one page.

Inputs: vdom, limit, offset, target, +1 more. Returns: object

list_address_objectsList address objectsA
Read-onlyIdempotent

List firewall address objects, optionally filtered.

Address objects are the named source and destination values that policies reference. Each is reported with its type and a single readable value, so a subnet object shows CIDR, an FQDN object shows the hostname, and a MAC object shows the MAC addresses.

Compare count against total_available before concluding anything about the whole table. When truncated is present this is one page and next_offset says where the following one begins.

Inputs: vdom, limit, offset, target, +2 more. Returns: object

list_dhcp_leasesList DHCP leasesA
Read-onlyIdempotent

List current DHCP leases issued by the appliance.

Read filtered_out before concluding anything from a short list. Both filters here drop rows without saying so otherwise, and an interface name with a typo produces an empty list that looks exactly like an appliance handing out no leases.

Inputs: vdom, limit, offset, target, +2 more. Returns: object

list_interfacesList interfacesA
Read-onlyIdempotent

List interfaces with their addresses, VLAN tags, and link state.

FortiOS creates bookkeeping interfaces alongside real ones, such as the quarantine interface that accompanies every wireless VAP. Those are hidden by default and named in hidden_internal so the omission is visible.

Which ones count as bookkeeping is decided by the appliance rather than by the name. FortiOS reports whether each interface is offered as a policy endpoint, and anything it offers is shown regardless of what it is called, so this tool cannot hide an interface a policy could name. hidden_internal_basis says whether that lookup succeeded; when it did not, the fallback is the name and the omission is less trustworthy.

Filters narrow the list silently unless you read filtered_out, which names each active filter and how many rows it removed. with_ip_only keeps interfaces carrying an IPv4 address in the configuration, which includes a DHCP interface that has taken a lease, because FortiOS writes the leased address back into the config.

An interface that holds no address reports addressing instead of ip. Use get_routing_table for the runtime view.

Inputs: vdom, limit, offset, target, +3 more. Returns: object

list_policiesList firewall policiesA
Read-onlyIdempotent

List firewall policies in evaluation order, optionally filtered.

Policies come back in the order FortiOS evaluates them, and each carries an explicit order index so that ordering survives filtering and re-serialization. Order is the entire meaning of a ruleset, and a list position is not something downstream is obliged to preserve.

Three counts appear and they mean different things. count is rows in this page, total_available is rows matching your filters, and total_policies is the size of the whole ruleset. A truncated flag means this is one page of the matches, not all of them, which matters more here than elsewhere: reasoning about a ruleset from an arbitrary prefix of it produces confident wrong answers about what traffic is allowed.

The filters match names exactly and do not expand indirection. A policy referencing a group that contains your address will not match address, and a policy referencing a zone that contains your interface will not match interface. If filtered_out shows a filter removed everything, that is the cue: the object is probably reached through a group or a zone, and find_references answers "what touches this object" properly, walking those containers.

Inputs: vdom, limit, offset, target, +4 more. Returns: object

list_servicesList servicesA
Read-onlyIdempotent

List firewall service objects with their protocols and ports.

Compare count against total_available before concluding anything about the whole table. When truncated is present this is one page.

Inputs: vdom, limit, offset, target, +1 more. Returns: object

list_static_routesList configured static routesA
Read-onlyIdempotent

List configured static routes.

These are the routes an operator configured, which is not the same as the routes in use. A DHCP-assigned default route never appears here because it was never configured. Use get_routing_table for what the appliance is actually forwarding on.

A route whose destination is a named address object is resolved to the underlying CIDR where possible, with the object name kept alongside it.

Inputs: vdom, limit, offset, target. Returns: object

list_targetsList configured FortiGatesA
Read-onlyIdempotent

List the FortiGate appliances this server can reach.

Call this first when several appliances may be configured, since every other tool takes an optional target argument naming one of these. When exactly one is configured, that argument can be omitted entirely.

Credentials are never included in the response.

Inputs: no named parameters. Returns: object

list_vipsList virtual IPsA
Read-onlyIdempotent

List virtual IPs, which are the destination NAT rules.

A VIP maps an external address, and optionally an external port, to an internal one. FortiOS stores those addresses inline on the VIP rather than as references to address objects.

Compare count against total_available before concluding anything about the whole table. When truncated is present this is one page.

Inputs: vdom, limit, offset, target. Returns: object

list_vlansList VLANsA
Read-onlyIdempotent

List VLAN sub-interfaces with their tags and parent interfaces.

A focused view of the VLAN subset of the interface table, since what VLANs exist and what they attach to is asked far more often than the full interface list. FortiOS quarantine VLANs are excluded, and filtered_out says how many that was.

Inputs: vdom, limit, offset, target. Returns: object

list_wifi_clientsList connected wireless clientsA
Read-onlyIdempotent

List wireless clients currently associated, enriched with DHCP and ARP.

Each client is joined against the DHCP lease and ARP tables by MAC, which is what turns an anonymous MAC into a recognizable device. The hostname comes from the DHCP lease, falling back to the vendor class identifier when the client sent no name. MAC addresses are reported in one canonical lowercase colon-separated form whatever spelling the source used, since that is what makes the join work at all.

authenticated is true or false only when the appliance said so, and absent when it did not, because inferring "not authenticated" from a missing field would fabricate a security-relevant claim.

Inputs: ssid, vdom, limit, offset, +1 more. Returns: object

search_configSearch the whole configurationA
Read-onlyIdempotent

Search the whole configuration for a term, across every object type.

Looks through address objects and groups, services, interfaces, static routes, and optionally policies, matching names, values, comments, and member lists. This is the tool for an open question such as "where does 203.0.113.0/24 appear" or "what mentions guest", when you do not yet know which kind of object holds the answer.

Check sources_checked before concluding from a zero result. A source that could not be read contributes no matches, so no matches is not the same as nothing found.

Inputs: term (required), target, include_policies. Returns: object

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A4.1/5.0

    Scored across 17 tools

    Disambiguation5/5

    Each tool targets a clearly distinct FortiGate resource or action. Overlaps such as list_interfaces vs list_vlans, find_device vs the individual client/lease/ARP lists, and find_references vs search_config are explicitly distinguished in the descriptions.

    Naming Consistency5/5

    All tools follow a consistent snake_case verb_noun pattern: list_*, get_*, find_*, search_*. The verb choice accurately reflects the operation, with no mixing of conventions.

    Tool Count4/5

    17 tools is slightly above the typical 3-15 range, but each covers a distinct inspection surface for FortiGate appliances. A few could be consolidated (e.g. list_vlans as a filtered list_interfaces), but the count is reasonable for the domain breadth.

    Completeness4/5

    The set provides broad read-only coverage of firewall policies, addresses, services, interfaces, routes, references, and client/lease/ARP data. Some useful read surfaces are missing (logs, security profiles, zones/schedules, single-object detail beyond list filters), and mutation operations appear intentionally out of scope.

    Maintenance

    ActivityStale
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables read-only querying and diagnostics of Fortigate firewalls via SSH, providing security analysis, traffic monitoring, and configuration inspection through natural language.
      MIT
    • A
      license
      A
      quality
      D
      maintenance
      Provides read-only SSH access to network devices (routers, switches, firewalls) with command allow/deny policies, nt-templates output parsing, and an audit trail, enabling an AI agent to query device state securely.
      10
      MIT
    • F
      license
      Not graded
      quality
      C
      maintenance
      Enables AI agents to safely troubleshoot networks through read-only tools for device inventory, interface status, VLAN paths, BGP neighbors, route lookups, and interface error detection. Integrates with Microsoft Copilot Studio and Teams for natural-language-driven network diagnostics.
      -
    • A
      license
      Not graded
      quality
      C
      maintenance
      Provides read-only MCP tools that let an LLM client query network inventories, inspect devices over SSH, and diagnose network conditions such as interfaces, routes, BGP, OSPF, and VPN state. Configuration changes stay in the local CLI, while the MCP surface remains strictly read-only.
      Apache 2.0