mcfortigate
Provides read-only access to FortiGate firewall configuration and live state, including address objects, policies, interfaces, routes, DHCP leases, ARP entries, wireless clients, reference lookup, configuration search, and device identification across one or more appliances.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcfortigate is the address object 10.10.5.22 safe to delete, or still referenced?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 mcfortigateAdd 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 mcfortigateHistory 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-tokenA 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 |
| Which appliances can this server reach |
| Model, serial, firmware, hostname, CPU and memory |
| Where does this term appear, anywhere in the config |
Firewall
Tool | Answers |
| What named addresses exist, with readable values |
| What groups exist and what is in them |
| What services exist, with protocols and ports |
| What rules exist, in evaluation order, with filters |
| What destination NAT is configured |
| What points at this object, and is it safe to delete |
Network
Tool | Answers |
| What interfaces exist, with addresses and link state |
| What VLANs exist, with tags and parent interfaces |
| What routes were configured |
| What routes are actually in use right now |
Live state
Tool | Answers |
| Who is on the wireless right now, with hostnames |
| What leases are currently issued |
| What IP-to-MAC bindings the appliance sees |
| 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.
Related
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 toolsfind_deviceIdentify a device by MAC, IP, or hostnameARead-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 objectARead-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 itno_references, the appliance confirmed nothing doesno_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 applianceobject_not_found, no address, group, service, virtual IP, or interface by this name exists, so the question is probably a typoindeterminate, 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 tableARead-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 tableARead-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 healthARead-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 groupsARead-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 objectsARead-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 leasesARead-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 interfacesARead-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 policiesARead-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 servicesARead-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 routesARead-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 FortiGatesARead-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 IPsARead-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 VLANsARead-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 clientsARead-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 configurationARead-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
Scored across 17 tools
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.
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.
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.
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
Related MCP Connectors
Read-only MCP access to a documented IT fleet: state, changes, posture. 15 tools.
Read-only MAC address, OUI, and vendor lookup tools for network diagnostics.
IP intelligence, network diagnostics, and AI service availability tools for AI agents.
DNS, WHOIS, IP geolocation and SSL checks as agent tools.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables read-only querying and diagnostics of Fortigate firewalls via SSH, providing security analysis, traffic monitoring, and configuration inspection through natural language.MIT
- AlicenseAqualityDmaintenanceProvides 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.10MIT
- FlicenseNot gradedqualityCmaintenanceEnables 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.-
- AlicenseNot gradedqualityCmaintenanceProvides 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