The workflow: discover, read, plan, apply
How a model uses the four tools, learns a profile's mode and handles guarded approvals.
The workflow: discover, read, plan, apply
The four tools are meant to be used in order. The server sends the same outline to the model as its
instructions (in the initialize result and in server/discover), so a model sees it before its
first call.
1forti_discover -> forti_read -> forti_plan -> forti_apply {plan_id}2 find paths look at the validate and execute, as the3 and attributes current state store a change profile's mode allows
The examples below show tool arguments. Tools has every field.
1. Discover
1{"what": "devices"}2{"what": "capabilities", "profile": "home"}3{"what": "resources", "query": "addr", "kind": "table"}4{"what": "resource", "resource": "firewall/address", "fields": ["type", "subnet"]}
deviceslists the configured profiles with host, port and mode (never a secret), from the local configuration.capabilitiesconnects to the device: hostname, model, serial, FortiOS version compared with the SDK's schema version, the profile's mode and the number of known resources.resourcessearches the compiled-in catalog of resource paths (configuration tables, singletons and typed monitor endpoints).resourcedescribes one resource: its key, attributes, write operations and risk tier.fieldsgives full detail for selected attributes, andlive: trueadds the device's own help text, options and ranges.
Everything except capabilities and live: true is answered locally, without contacting the device.
2. Read
1{"resource": "firewall/address", "filter": ["type==ipmask", "name=@lan"], "fields": ["name", "subnet"], "count": 100}2{"resource": "firewall/policy", "key": 12}3{"resource": "monitor/system/status"}
Narrow reads with key (one table object), fields, filter (FortiOS syntax; list items are
ANDed, a comma inside one item ORs) and start/count. Large results are cut to the byte budget and
say where to continue: Large results and paging. Device secrets
come back as "<redacted>" (Security).
3. Plan
1{"profile": "home", "operations": [2 {"op": "create", "resource": "firewall/address", "object": {"name": "printer", "subnet": "192.0.2.50 255.255.255.255"}},3 {"op": "update", "resource": "firewall/address", "key": "LAN", "set": {"comment": "managed"}, "clear": ["color"]}4]}
forti_plan validates every operation against the catalog and the SDK's attribute types, reads the
current objects, and stores the plan (owner-only, in ~/.forti/state/). It never changes the
device. The result (data) carries:
| Field | Meaning |
|---|---|
id | The plan id, which forti_apply takes as plan_id, e.g. 20261015-104512-a1b2c3 |
digest | SHA-256 over the device identity, the operations and the before-state |
risk, summary | The plan's risk tier (low, elevated, critical) and a one-line summary |
operations | Per operation: the attribute diff and the exact request (wire) that apply will send, secrets redacted |
mode, notes | The profile's mode and what apply will do in it |
approve | Guarded mode only: forti plan approve <id> |
Operations run in order at apply time:
op | Arguments |
|---|---|
create | resource, object |
update | resource, key, set, clear |
delete | resource, key |
action | resource (a monitor/... path), body |
4. Apply
1{"plan_id": "20261015-104512-a1b2c3"}
forti_apply takes only the plan id; the operations come from the stored plan, so a model cannot
apply something other than what was planned. What happens depends on the profile's mode.
| Mode | Result of forti_apply |
|---|---|
read-only | policy_denied: nothing is sent |
guarded | approval_required until a person approves the plan, then it executes |
autonomous | Executes now |
How a model learns the mode
A model never has to guess. The mode is in the forti_discover devices and capabilities
results, in every forti_plan result (mode and notes), and the server's instructions and the
forti_apply description explain all three outcomes. Nothing a model sends can change the mode: it
is set by the operator with forti configure.
Guarded mode: approval_required
In a guarded profile, the first forti_apply returns a tool error (isError: true) whose
structuredContent is:
1{"ok": false, "error": {"code": "approval_required",2 "message": "plan 20261015-104512-a1b2c3 needs a human approval (guarded mode): forti plan approve 20261015-104512-a1b2c3",3 "details": {"plan_id": "20261015-104512-a1b2c3", "reason": "missing",4 "approve_command": "forti plan approve 20261015-104512-a1b2c3"}}}
The result also carries a second text block written for the model:
1Approval required (guarded mode). Ask the user to run this in their own terminal, then call forti_apply again with the same plan_id:2 forti plan approve 20261015-104512-a1b2c3
The model relays the command
The model shows you the exact
forti plan approve <id>command.You approve in your own terminal
forti plan approveshows the plan (diff, request bodies, risk) and asks for confirmation.The model applies again
The model calls
forti_applyagain with the sameplan_id, and it executes.
An approval:
- belongs to that plan's digest: a changed or tampered plan is not approved;
- expires after 24 hours (
reason: expired); - is used once (
reason: used): applying again needs a new plan and a new approval.
forti-mcp has no approve tool and never writes approvals. In guarded mode the approval is always a person's act, outside MCP. Read what that does and does not guarantee in Safety and modes.
Autonomous mode and the safeguards of every apply
In an autonomous profile, forti_apply executes immediately. In every mode that writes, apply:
- checks the plan's digest (tamper check);
- re-reads every object the plan touches and checks the device's serial number, refusing with
stale_planif anything changed since planning (plan again); - checks that the audit log can be written, and refuses with
config(nothing sent) if not; - for a critical-risk plan, first saves a configuration backup (owner-only, in
~/.forti/state/) and refuses withpolicy_deniedif it cannot, unless the profile sets"backup_before_critical": false; - runs the operations in order, never retrying a request that may have reached the device, and
verifies each one by reading it back (monitor actions cannot be read back and are reported
applied_unverified); - after each critical-risk operation, probes management connectivity with a fresh connection;
- stops at the first failure;
- writes a rollback plan and an audit record (
audit.jsonl).
A successful result (data.status: "applied") lists every operation's status (verified,
applied_unverified, ...), the number of verified and unverified operations, the rollback plan
(data.rollback.plan_id) and, for critical plans, the backup.
When apply fails
| Code | What to do |
|---|---|
stale_plan | The device, an object or the profile's host changed since planning: read again and make a new plan |
partial_failure | Execution stopped at a failed operation. details has the per-operation record of what was applied |
outcome_unknown | A request may have reached the device without a reply. Read the device before planning again |
management_connectivity_lost | The probe after a critical change failed. The remaining operations were not attempted; details has the rollback plan, the backup and the before-state |
policy_denied | The profile is read-only, the plan was already applied, or a critical plan's backup failed |
The rollback plan is an ordinary stored plan. Apply it like any other (forti_apply or
forti apply <id>), under the same mode rules. It needs a working management path; see
Recovery aids are not guaranteed recovery.