Skip to content

Querying several appliances

One server can reach any number of FortiGates. Set FORTIGATE_TARGETS to a JSON object and the keys become the target argument on every tool.

Terminal window
FORTIGATE_TARGETS='{
"edge": {"host": "fgt-edge1.example.com", "token": "..."},
"branch": {"host": "fgt-br2.example.com", "token": "...", "verify_ssl": false},
"lab": {"host": "https://fgt.lab:8443", "username": "readonly", "password": "..."}
}'

That is three appliances, each with its own credential, one of them on a non-standard port with legacy auth and one with certificate verification off. Per-target settings are independent; there is no shared default to fight with.

The aliases are not cosmetic. They are what the model types, what it reports back to you, and what it has to get right when you say check the branch firewall.

Use what people in the room already call the boxes. edge, branch, dc, lab work. Serial numbers and inventory codes work badly, because a model asked about the Boise firewall cannot map that to FGT60F-0042 without being told, and it will guess.

Keep them short. They appear in every tool call and every response.

Keep them unambiguous by prefix. branch1 and branch2 are fine; branch and branch-backup invite a model to pick the wrong one when you say branch.

This is the part that trips everyone once. The value is a JSON object inside a shell environment variable, so the outer quoting has to survive the shell and the inner quoting has to survive JSON.

In a .env file or a shell, wrap the whole thing in single quotes and use double quotes inside, as above. Do not swap them: JSON requires double quotes around its keys and string values, and single quotes there are a parse error.

In a JSON client config the object has to be a string, which means escaping:

{
"mcpServers": {
"fortigate": {
"command": "uvx",
"args": ["mcfortigate"],
"env": {
"FORTIGATE_TARGETS": "{\"edge\":{\"host\":\"fgt-edge1.example.com\",\"token\":\"...\"},\"branch\":{\"host\":\"fgt-br2.example.com\",\"token\":\"...\"}}"
}
}
}
}

Unreadable, and easy to get wrong. Setting the variable in the environment and leaving it out of the client config entirely is the better move whenever the client inherits your shell.

Malformed JSON is a startup error naming the parse failure, so you find out immediately rather than through an empty target list.

The two configuration styles coexist, and FORTIGATE_HOST is read after FORTIGATE_TARGETS. A working single-appliance setup can therefore grow a second appliance without being restructured — add FORTIGATE_TARGETS with just the new box in it and leave the original variables alone.

The existing appliance keeps whatever alias FORTIGATE_NAME gave it, or its hostname if you never set one. That is the moment to set FORTIGATE_NAME, because a hostname makes a poor alias and the model now has to choose between two.

With several appliances configured, omitting target is an error — and the error lists the valid aliases:

Several FortiGates are configured, so 'target' is required.
Choose one of: branch, edge, lab

That is deliberate. A silent pick of the first alphabetically would produce a confident, well-formatted, entirely wrong answer about branch when you meant edge. An error naming the options lets the next call be correct, and in practice the model recovers without you noticing it happened.

list_targets is how the model orients itself, and it makes no network calls — it reports what the server was configured with. It therefore answers instantly, and it answers even when every appliance is unreachable, which makes it the right first call when something is wrong.

Nothing in the protocol stops a model from calling the same tool once per target and comparing. In practice this works well for questions with small answers:

Check every firewall for a policy allowing inbound SSH from anywhere.

The model calls list_targets, then list_policies per appliance, and compares.

What is worth understanding is that the model is doing the fan-out, not the server. There is no tool that queries every appliance at once, and that is a design decision rather than a gap — see below.

This is multi-target, not fleet management

Section titled “This is multi-target, not fleet management”

The distinction matters when you are deciding whether to point this at ten appliances or a hundred.

There is no fan-out. Every tool answers about exactly one appliance. Check every firewall for X is N separate tool calls that the model issues and correlates itself. It works, and it is visible in the transcript, but nothing is parallelised on your behalf.

Sessions are per call, not pooled. Each tool call opens a fresh session to its appliance and closes it afterwards. Nothing is cached between calls. That is the right trade for a server that may sit idle for hours between questions — a pooled connection to a firewall is a stale connection waiting to happen — but it means the cost is linear and paid every time. A find_references call measured at roughly 1.1 s against the lab appliance is roughly 11 s across ten of them, in sequence.

The context fills up faster than the clock runs out. Three appliances with large rulesets is three large rulesets in one conversation. This is usually the binding constraint long before latency is.

So the practical ceiling is a handful of appliances you ask questions about, not an estate you inventory nightly. Five is comfortable. Fifty is the wrong shape of problem for this tool.

Three things help, in rough order of effect.

Filter before you fan out. list_policies with service: "SSH" across three appliances is fast; three unfiltered rulesets is slow and floods the context. The same applies to search_config with include_policies: false when only object definitions matter.

Read total_available, not just what came back. Every listing tool bounds its result at 200 rows by default. Across several appliances it is easy to compare pages rather than tables without noticing — the paging contract is what tells you the difference.

Ask orientation questions first. list_targets costs nothing, and get_system_status per appliance is small. Establishing which boxes are on which firmware before asking an expensive question often narrows it to one.

If the question is really what is the state of the whole estate, on a schedule, this is the wrong tool and nautobot-ssot-fortinet is the right one. It syncs FortiGate configuration into Nautobot as scheduled Jobs, so the repeated whole-estate work happens once, into a database built for it — and then you ask Nautobot, which is designed to answer across many devices, instead of asking N appliances one at a time.

The two compose well. Nautobot for the estate-wide and the historical, mcfortigate for what is this specific box doing right now, which is the question a sync cannot answer because its answer changes between syncs.