AI & Agents¶
pyfsr ships a framework-agnostic tool registry – a declarative catalogue of
core FortiSOAR operations (record CRUD, discovery, picklists, connectors,
playbook runs) as JSON-Schema tool definitions, plus a dispatch() that
executes a tool call against a live client and returns JSON-safe, token-trimmed
results.
It’s deliberately transport-neutral (no MCP, no provider SDK), so the same registry can feed Anthropic tool-use, OpenAI function calling, the bundled MCP server, or a home-grown agent loop.
See also
End-to-end FortiAI / FortiSIEM-MCP examples:
fortisiem_mcp_setup_and_test.py,
trigger_ai_investigation.py,
and run_single_ai_agent.py.
See the examples index for the full set.
Hint
Building an AI agent with external coding tools (GitHub Copilot, Cursor,
Windsurf, Claude Code)? Start from the repo’s
AGENTS.md – it
covers setup (EnvConfig.from_env, instances.toml), the run_and_wait
trigger-and-wait primitive, playbook authoring CLI, and the full tool registry
in one place.
Why use it¶
Wiring an LLM to FortiSOAR by hand means hand-writing JSON-Schema for every operation, normalizing Hydra envelopes, trimming huge records down to fit a context window, and turning every HTTP error into something the model can read. The registry does all of that for you:
Discovery built in. The model can learn the appliance at runtime –
list_modules→describe_module→ act – instead of you hard-coding field names and module types that differ per deployment.Token-trimmed results. Every tool supports
summary=true/fields=[...]so a 60-field alert doesn’t blow the context window when an agent is scanning dozens of records.Picklist resolution. Agents pass friendly values (
"High") and the tool maps them to the IRIs the API actually requires – the single most common cause of failed writes.Errors as data, not exceptions. Every failure returns a structured
{"error": {...}}the model can read and self-correct from, so one bad call doesn’t kill the agent loop.Write once, run anywhere. The same registry feeds Claude, OpenAI, MCP, or your own loop – no per-provider glue.
Available tools¶
The registry ships these tools, grouped by what they do:
Group |
Tool |
What it does |
Safety |
|---|---|---|---|
Discovery |
|
List every module (type/label/plural). Start here to find the right module type. |
read |
|
Describe a module’s fields: name, type, required-ness, and bound picklist. |
read |
|
Records |
|
Fetch one record by reference; |
read |
|
Free-text search a module; returns a page of records. |
read |
|
|
Structured query with |
read |
|
|
Create a record; |
write |
|
|
Update an existing record’s fields by reference. |
write |
|
|
Delete one record (soft by default; |
destructive |
|
Picklists |
|
List every picklist name on the appliance. |
read |
|
List a picklist’s items (itemValue, uuid, iri, ordinal). |
read |
|
|
Resolve a friendly value (e.g. |
read |
|
Connectors |
|
List installed + configured connectors with versions/configs. |
read |
|
Live-check whether a connector configuration is reachable. |
read |
|
|
Execute one connector operation. |
varies |
|
Playbooks |
|
List recent playbook runs (live + historical, newest first). |
read |
|
Fetch one playbook run by its pk. |
read |
|
FortiAI |
|
Trigger an agentic investigation of an alert (normalize → hypothesize → plan → gather evidence → verdict). |
write |
|
Fetch the status/verdict of an investigation by |
read |
|
|
Report FortiAI config: enabled features, LLM profiles, registered MCP servers. |
read |
|
Modules (admin) |
|
Create a module in staging; |
write |
|
Delete a module (the only op that actually removes one); optionally drops orphan tables. |
destructive |
|
|
Commit ALL staged schema changes appliance-wide (not module-scoped). |
appliance-wide |
|
Connector config |
|
Build a complete, runtime-valid default config (handles |
read |
|
Validate a config against the schema before submitting – returns |
read |
|
|
Create a named config; |
write |
|
|
Update an existing config by |
write |
|
|
Idempotent create-or-replace by name – the safe default for deploy scripts. |
write |
|
Playbook runs |
|
Most recent run of a playbook (live or historical); |
read |
|
Slim failure detail |
read |
|
|
Block until the newest run reaches a terminal state; return its summary. |
read |
|
Records (upsert) |
|
Insert-or-update by natural key (or a |
write |
|
Look up by key field(s), create if absent; returns |
write |
|
Scheduling |
|
Create a periodic task that runs a playbook on a cron schedule; returns the created schedule. |
write |
|
Fire a scheduled task immediately (out-of-band of its cron); pair with |
write |
|
|
Delete a scheduled periodic task entirely by name (use |
destructive |
Inspect any tool’s full JSON-Schema (parameters, defaults, enums) at runtime
with get_tool("query_records").input_schema.
Discovery path¶
On a fresh appliance, learn the schema before writing. The recommended sequence avoids guessing field names, picklist values, or connector configurations:
list_modules– discover what modules exist (alerts,incidents, …).describe_module– learn a module’s fields, their types, and which are picklist-backed.list_picklists→get_picklist_values– resolve the friendly strings a picklist field accepts before writing it.list_connectors→healthcheck_connector– confirm a connector is installed and reachable before callingrun_connector_operation.
Only then call a write tool (create_record, run_connector_operation, …).
Each read tool is cached, so the discovery steps are cheap to repeat.
Calling tools¶
dispatch(client, name, arguments) runs one tool and returns a JSON-safe,
token-trimmed result – never raises; a failure comes back as {"error": {...}}.
The read tools resolve against the replay session demo_client() builds, so
their return shapes are doctested here (write ops need a live appliance):
>>> from pyfsr.agent.tools import dispatch
>>> client = demo_client()
>>> r = dispatch(client, "get_record", {"module": "alerts",
... "ref": "9f0eb603-ac1e-41c3-b47b-444589beed39"})
>>> (r["@type"], r["name"])
('Alert', 'Response Capture Test Alert')
>>> hits = dispatch(client, "query_records", {"module": "alerts",
... "filters": [{"field": "name", "operator": "eq",
... "value": "Response Capture Test Alert"}]})
>>> len(hits["members"]), hits["members"][0]["name"]
(1, 'Response Capture Test Alert')
>>> conns = dispatch(client, "list_connectors", {})
>>> conns["connectors"][:3]
['code-snippet -- Code Snippet v2.2.1 (1 config)', 'mitre-attack -- MITRE ATT&CK v2.0.2 (1 config)', 'smtp -- SMTP v2.6.0 (1 config)']
>>> mods = dispatch(client, "list_modules", {})
>>> [m["type"] for m in mods["modules"][:3]]
['agents', 'alerts', 'announcements']
>>> desc = dispatch(client, "describe_module", {"module": "alerts"})
>>> (desc["module"], desc["label"], desc["plural"])
('alerts', 'Alert', 'alerts')
>>> sev = next(f for f in desc["fields"] if f["name"] == "severity")
>>> (sev["type"], sev["picklist_name"])
('picklists', 'Severity')
>>> pl = dispatch(client, "list_picklists", {})
>>> pl["picklists"]
['AlertStatus', 'Severity']
>>> vals = dispatch(client, "get_picklist_values", {"name": "Severity"})
>>> [v["itemValue"] for v in vals["values"]]
['Minimal', 'Low', 'Medium', 'High', 'Critical']
The write tools (create_record / update_record / delete_record) replay
against the same captured alert, so their return shapes are doctested too – an
agent learns the envelope each tool returns without a live box:
>>> client = demo_client()
>>> created = dispatch(client, "create_record", {"module": "alerts",
... "data": {"name": "New Alert"}})
>>> (created["@type"], created["name"])
('Alert', 'Response Capture Test Alert')
>>> updated = dispatch(client, "update_record", {"module": "alerts",
... "ref": "9f0eb603-ac1e-41c3-b47b-444589beed39",
... "data": {"description": "revised"}})
>>> updated["@type"]
'Alert'
>>> dispatch(client, "delete_record", {"module": "alerts",
... "ref": "9f0eb603-ac1e-41c3-b47b-444589beed39"})
{'deleted': '9f0eb603-ac1e-41c3-b47b-444589beed39', 'module': 'alerts', 'hard': False}
A create_record whose data carries a friendly picklist value that doesn’t
resolve (typo, wrong casing) comes back as a structured, actionable error –
field, bad value, and the valid options – instead of an opaque box 400, because
the MCP write tools default strict_picklists=True:
>>> client = demo_client()
>>> out = dispatch(client, "create_record", {"module": "alerts",
... "data": {"severity": "Nope"}})
>>> out["error"]["type"], out["error"]["field"], out["error"]["picklist"]
('PicklistResolutionError', 'severity', 'Severity')
>>> "High" in out["error"]["valid_values"]
True
The discovery tools (list_modules, describe_module) and picklist tools
(list_picklists, get_picklist_values) are doctested above too – a model uses
list_modules → describe_module to learn a module’s fields (and which are
picklist-backed) before it writes a record, and list_picklists /
get_picklist_values to resolve the friendly strings a picklist accepts.
FortiAI investigation¶
investigate_alert kicks off a FortiAI agentic investigation (normalize →
hypothesize → plan → gather evidence over MCP → verdict). With wait=false
(default) it returns a {"task_id", "status"} handle immediately; poll it with
get_investigation_result, which returns the status plus the full verdict payload
(per-phase progress, summary with classification and key findings, hypotheses,
recommended next actions). Captured from a live 8.0 appliance; the verdict is
trimmed (one representative finding/hypothesis/log; all nine phase states kept):
>>> client = demo_client()
>>> started = dispatch(client, "investigate_alert", {
... "ref": "alerts:9f0eb603-ac1e-41c3-b47b-444589beed39"})
>>> started["status"]
'pending'
>>> result = dispatch(client, "get_investigation_result",
... {"task_id": started["task_id"]})
>>> result["status"]
'completed'
>>> result["result"]["summary"]["classification"]
'Inconclusive'
>>> [p["state"] for p in result["result"]["phases"]][:3]
['normalization', 'context_enrichment', 'hypothesis']
>>> result["result"]["playbook"]["immediate_next_actions"][0]
'Preserve forensic evidence...'
Running a single agent¶
An investigation runs the whole pipeline. When you only need one question
answered – enrich this indicator, query the SIEM, look up a ticket – call the
agent directly with run_agent. It is far cheaper, and returns the agent’s own
outputformat (answer / evidence / confidence) rather than an
investigation’s summary/hypotheses.
You never have to guess the payload: every agent publishes its input contract,
and run_agent validates against it before spending an LLM call.
>>> schema = client.ai.agent_input_schema("ioc-enrichment")
>>> sorted(schema)
['ioc', 'question']
>>> result = client.ai.run_agent(
... "ioc-enrichment",
... {"question": "Is this IP known to be malicious?",
... "ioc": [{"type": "IP Address", "value": "8.8.8.8"}]},
... wait=True,
... )
>>> result.answer, result.confidence
('No', '95%')
Omit a required key and it fails locally, naming the key, without calling the
API. Pass validate=False to skip the schema lookup when you already know the
shape.
Two things that bite:
The trigger goes to
/api/ai/agents/{name}/trigger(plural). The service mounts the same router under/ai/triagetoo, but the front door only authorises theagentsform – thetriageform is rejected with a bareAccess Deniedno matter what role you hold.The caller needs
execute.ai_agents(andread.ai_agentsto read the input schema). A missing permission looks identical to a wrong path.
With wait=True, a timeout returns the latest result with a non-terminal
status rather than raising – check result.status before trusting answer.
See examples/run_single_ai_agent.py.
Chat and the Orchestrator (8.0.1)¶
client.ai.chat() opens a multi-turn session with the SOC chat assistant (the
Conversation Agent), exactly as the in-app assistant drives it:
POST /api/ai/agents/conversation/trigger with an X-CHAT-SESSION-ID header,
then poll the task. The session carries previous_response_id / request_id
between turns. (/api/ai/chat/ also exists, but the UI does not call it and its
request shape does not match the agent’s.)
client.ai.orchestrate() is the same protocol against the Orchestrator Agent.
It can pause a turn for a clarification (a missing value) or an
approval (a state-changing action it inferred). A paused turn has
turn.is_paused; answer it on the same session so the plan resumes rather than
starting over.
>>> s = client.ai.orchestrate()
>>> turn = s.ask("Block the malicious IP address on the firewall.")
>>> turn.needs_clarification, turn.pending.question
(True, 'Cannot block ... Please specify the IP address to proceed.')
>>> turn = s.reply("198.51.100.23")
>>> # an approval pause is answered with s.approve() / s.deny()
On 8.0.1 the Conversation Agent does not call the Orchestrator, so
orchestrate() is the only way to reach it.
Playbook designer assistant (8.0.1)¶
client.ai.playbook_assistant() opens a session with the playbook designer’s
built-in assistant (playbook-generator). It works in two phases: describe what
you want, get an outline back, then ask it to generate designer steps from the
outline. Nothing is saved – the steps are returned as data for you to review or
push.
>>> pb = client.ai.playbook_assistant()
>>> outline = pb.ask("On a new Critical alert, look up the source IP in VirusTotal ...")
>>> outline.answer[:80]
'1. Query VirusTotal for the source IP.\n2. If malicious, create an incident ...'
>>> outline.is_user_input_needed
False
When is_user_input_needed is True, the assistant has a follow-up question
before it can finalize the outline – answer it with pb.ask(...) and the
outline continues on the same session:
>>> if outline.is_user_input_needed:
... outline = pb.ask("Use the default VirusTotal config.")
Once the outline is settled, generate_steps() turns it into designer steps:
>>> steps = pb.generate_steps()
>>> steps.status, len(steps.playbook_steps)
('completed', 3)
>>> steps.playbook_steps[0]["stepName"]
'Query VirusTotal for source IP'
Step generation is expensive: one LLM call per step, each resending the whole
context. A 7-step playbook used about 330k FortiAI tokens on 8.0.1, and one run
failed on malformed JSON – check steps.status before trusting
steps.playbook_steps, and retry on a failure.
Connector wizard assistant (8.0.1)¶
client.ai.connector_assistant() opens a session with the connector wizard’s
built-in assistant (connector-generation). It is a guided multi-turn
conversation: you describe the API, it writes info.json, connector.py, and
operations.py, and offers to import them. About 6k FortiAI tokens per turn.
>>> ca = client.ai.connector_assistant()
>>> t1 = ca.ask("Build a connector for the ACME threat feed API. "
... "Base URL https://api.acme.example.com/v1. "
... "One operation: lookup_ioc, takes a single ioc parameter.")
>>> t1.is_user_input_needed
True
>>> t1.answer[:80]
'Should the connector authenticate with an API key or bearer token?'
>>> t2 = ca.ask("API key, passed as X-API-Key header.")
>>> t2.is_user_input_needed
False
On 8.0.1 the assistant’s own import step fails (its info.json serialization
breaks the upload). Ask it to show the files instead, then install them with
client.connectors.install_from_dir:
>>> files = ca.ask("Show me the files you wrote.")
>>> # files.answer contains the connector source; save it to a directory, then:
>>> # client.connectors.install_from_dir("./acme-threat-feed")
Insights (8.0.1)¶
An Insight is a question about your data (“which alert sources produced the
most Critical alerts this week?”) that fsr-ai plans into agent steps, runs, and
summarizes. client.ai.insights drives the Insight cards widget’s flow:
>>> run = client.ai.insights.create("Critical alert sources",
... query="Which alert sources produced the most Critical alerts in the last 7 days?")
>>> run.status, run.insight_id
('completed', 'a1b2c3d4-...')
>>> run.result["concise_summary"][:80]
'FortiGate and FortiSIEM were the top sources of Critical alerts ...'
>>> run = client.ai.insights.run_template("High Risk Active Alerts")
>>> run.status, len(run.result or {})
('completed', 5)
create generates a chain of thought and a plan (two LLM calls), refuses an
infeasible plan, executes it, and saves it as an insights record. The shipped
insight_templates carry ready plans; run_template executes one without the
planning calls. list, get, delete and trigger (re-run a saved insight
now) cover the rest. socrole (SOC Analyst, SOC Manager, Threat Analyst, Infrastructure Admin) shapes both the plan and the summary. On
8.0.1 a create took about 45 s and 8k FortiAI tokens.
Traces (8.0.1)¶
Every agent run records a trace – the data behind the in-app Trace Flow panel. A run’s trace id is its task id.
>>> tree = client.ai.traces.execution_tree(task_id)
>>> tree.total_steps, tree.count_by_type()
(173, {'AGENT': 57, 'LLM': 29, 'TOOL': 17, ...})
>>> client.ai.traces.tokens(task_id)
{'input_tokens': 35093, 'output_tokens': 5805, 'total_tokens': 40898, 'llm_calls': 29}
>>> [(s.provider, s.model, s.usage) for s in client.ai.traces.llm_calls(task_id)][:1]
[('FSRAI', None, {...})]
An investigation, question by question¶
traces.investigation(task_id) rebuilds an alert investigation from its
traces: one AgentRun per sub-agent run, each with the question it was asked,
its answer/evidence/confidence, and every tool call it made. Each call records
the MCP server that ran it, its real output, and selected_by – who decided it
should run:
|
Meaning |
|---|---|
|
No LLM step asked for it; the agent’s code ran a fixed lookup |
|
An LLM step picked it without having seen any tool result |
|
An LLM step picked it after reading earlier tool results |
>>> inv = client.ai.traces.investigation(task_id)
>>> for run in inv.questions:
... print(run.agent, run.answer, [(c.tool_name, c.selected_by) for c in run.tool_calls])
Threat Intelligence Provider No [('get_indicators', 'code'), ('enrich_indicator', 'code'),
('get_alerts_linked_to_indicators', 'llm'), ...]
>>> m = inv.metrics()
>>> m["selected_by"], m["questions_multi_tool"], m["questions_chained"]
({'code': 24, 'llm': 17}, 8, 0)
On 8.0.0 each question got exactly one LLM-chosen tool call. A raw count of tool
calls per question overstates the change in 8.0.1, because most provider agents
also run fixed lookups from code. questions_chained is the measure of an agent
reading a result and calling again.
8.0.1 no longer writes llm_activity_logs for investigations, so
investigation_tool_calls(), attribute_tool_calls() and find_investigations()
read the traces when the box has them (ToolCall.source == "traces") and fall
back to the logs on 8.0.0. tool_usage() reads only the logs.
Known 8.0.1 server defects the client works around or documents:
Paging with a cursor fails server-side, so
traces.iter()stops after the first page with a warning (usepage_size=500)./spans/{id}/tree404s for any non-root span;span_subtree()falls back to the span’s trace tree./spans/{id}/lineagealways 500s.LLM spans record provider
FSRAIand an empty model for FortiAI-proxy calls.purge_older_than()deletes every trace older than the cutoff – there is no per-trace delete.
Scoring investigations (pyfsr.ai_eval, 8.0.1)¶
A trace shows what an investigation did, but not whether it did the right
thing. pyfsr.ai_eval runs investigations against stub MCP servers with known
answers, so each run can be scored.
A suite (YAML; the bundled one is pyfsr/ai_eval/suites/default.yaml)
defines two things:
Stub servers, each registered in FortiSOAR as its own MCP server:
five evidence servers (SIEM, EDR, identity, CMDB, threat intel);
two distractors (cloud billing, marketing CRM) that are no help in any scenario.
Each tool answers from rules keyed on its arguments. A call about an entity the scenario has no data for gets an empty default answer.
Scenarios. Each has:
an alert;
the calls a good investigation makes;
the facts it should surface;
the right verdict.
Some facts can only be reached by pivoting: the entity is learned from one tool’s result and then looked up in another. In
fin-ws-lateral, the EDR shows an SMB connection to a file server, and only a SIEM search on that server finds the payroll exfiltration.
pyfsr ai-eval deploy --instance lab # connector + fixtures + MCP servers + agent allowlists
pyfsr ai-eval run --instance lab --runs 3 --out report.json
pyfsr ai-eval log --instance lab # the stubs' own call log
pyfsr ai-eval teardown --instance lab
deploy installs the bundled fsr-ai-eval-stub connector. Like the Microsoft
Teams connector’s bot listener, the connector starts a local listener when it
is configured. The listener is a stdlib-only MCP server on 127.0.0.1, so no
extra packages or network paths are needed; fsr-ai reaches it on the same box.
Each stub is then registered and allowed for the provider agents the suite
names. Custom connectors need connector development mode on.
Each run is scored from its trace:
Score |
Meaning |
|---|---|
|
1 for an expected classification, 0.5 for a partial one (e.g. Suspicious) |
|
Share of the required calls that were made |
|
Share of the required calls made with the right entity |
|
Share of the pivot calls made with the right entity |
|
Share of stub calls that found data (not a distractor, an unknown entity or a refusal) |
|
Facts some tool returned / that reached an answer or the summary / that reached the summary |
|
Facts that were retrieved but never used |
|
Weighted mean ( |
Over repeated runs, aggregate() reports:
the spread of each score;
the verdict distribution;
how often each expected call and fact was hit;
which tools were actually used, and who chose them (
selected_by).
Together these show which servers the agents should have used, which ones they did use, and where the evidence was lost.
>>> from pyfsr.ai_eval import load_suite, deploy, run_suite
>>> suite = load_suite()
>>> deploy(client, suite)
>>> report = run_suite(client, suite, runs=3)
>>> report["summary"]["scenarios"]["fin-ws-lateral"]["chain_recall"]
{'mean': 0.0, 'min': 0.0, 'max': 0.0, 'stdev': 0.0}
Token cost of an investigation¶
Every run also records its tokens and a price. An investigation’s
InvestigationTrace.tokens holds the root trace’s totals, which already
include every sub-agent run. tokens_by_agent() splits them per agent, so the
parts add up to the total. When the default LLM profile goes through the
FortiAI proxy, run also reads the FortiAI balance before and after each
investigation (client.ai.token_balance()). The drop is the run’s
metered_tokens, and the cost is priced from it when present. Anything else
using FortiAI on the appliance at the same time inflates it; pass --no-meter
to skip it.
TokenPricing defaults to FortiAI’s terms: 5,000,000 tokens a month included
per appliance, and top-ups at $100 per 500,000 tokens (usd_per_million=200).
For a profile on your own OpenAI, Anthropic or Gemini key, set a blended rate,
or separate input and output rates. The aggregate’s budget gives:
tokens and USD per investigation;
how many investigations fit in the free monthly allowance.
pyfsr ai-eval balance --instance lab # allowance, remaining, used
pyfsr ai-eval cost --instance lab <task_id> ... # tokens per agent + cost, any finished investigation
pyfsr ai-eval run --instance lab --usd-per-million 200 --free-tokens 5000000
Which model an investigation uses comes from the LLM profiles
(client.ai.list_llm_configs()) and each agent’s config
(client.ai.get_agent_config(name, version).config.llm_provider, a profile
uuid). The stock 8.0.1 profiles both go through the fortinet-fortiai-proxy
connector:
Low Reasoning (the default) runs
gpt-4.1;High Reasoning runs
gpt-5.4, used by the hypothesis, verdict and metric-computation agents.
A profile’s provider is one of:
|
Billing |
Setup |
|---|---|---|
|
through a connector: |
|
|
your provider account directly |
|
Only installed connectors on fsr-ai’s allow list (fortinet-fortiai-proxy and
openai as shipped) appear in list_providers(). In the fsr-ai 8.0.0
source, the native clients pass only the API key, so a profile’s baseurl is
stored but not used. An Azure OpenAI or self-hosted endpoint therefore has to
go through a connector. verify_llm_config() only works for openai
profiles. A native profile has no FortiAI balance, so token_balance() raises
ValueError and runs are priced from the traces.
Switching to a third-party LLM¶
The UI path is System Configuration → FortiAI, which opens the AI Configuration wizard. pyfsr does the same steps:
pyfsr llm status --instance lab # profiles, wizard providers, each agent's profile
OPENAI_API_KEY=... pyfsr llm setup --instance lab --profile "OpenAI GPT-4.1" --model GPT-4.1
pyfsr llm assign --instance lab "OpenAI GPT-4.1" # every agent; writes an undo snapshot
pyfsr ai-eval run --instance lab --usd-per-million <your blended rate>
pyfsr llm restore --instance lab ~/.pyfsr/llm-snapshots/<file>.json
setup_connector_llm() works in four steps:
installs the connector from Content Hub;
saves a connector configuration with the key and model;
health-checks it and sends a one-line test completion (
--no-verifyskips it). The health check passes on a valid key whose account has no credits; the test completion catches that before any agent is switched;creates a
fortisoarreasoning profile that points at the configuration.
fsr-ai sends no model name, so the connector configuration decides the model;
use one configuration per model. The key comes from an environment variable or
a file (--api-key-file), never from the command line. For Azure OpenAI, pass
--azure-endpoint and --azure-deployment.
assign_llm() sets llm_provider in each agent’s config row, which is what
investigations use. It also writes the agent record through
POST /api/ai/agent/llm/config, as the wizard does, where the API gateway
allows it; on 8.0.1 the gateway returns 403 for API sessions. It returns the
previous assignments for restore_llm_assignments().
On 8.0.1, /api/ai/llm/allowed-providers and the wizard list only
fortinet-fortiai-proxy unless the fortiai-configurations key-store record
has bringYourLLM: {"enabled": true}. That flag only changes the UI: with it
off, an investigation still calls the openai connector
(live-verified on 8.0.1).
Step timeout and retry (8.0.1)¶
client.playbooks.set_step_timeout(step, operation_timeout=5, retry=2) writes
the designer’s Timeout option (arguments.timeout) on a connector step. On
8.0.1, retries fire on a timeout only, not when the operation raises an
error. The appliance stores any value unchecked, so the designer’s rule (whole
seconds, total under 1800s) is enforced client-side.
Use case: triage an alert end-to-end¶
A SOC analyst asks an agent “Triage the latest critical alert and tell me if it’s a real threat.” With the registry attached, the model can carry out the whole workflow itself – no bespoke code per step:
query_recordsonalertsfiltered byseverity = Critical, sorted newest first,summary=true→ finds the alert without flooding its context.get_recordwithfields=[...]→ pulls just the fields it needs to reason.investigate_alert→ kicks off a FortiAI investigation that gathers evidence over the appliance’s MCP servers and returns a verdict.create_recordoncomments(withresolve_picklists=true) → writes its findings back to the alert so the human analyst sees them in FortiSOAR.
Every step is a tool call the model chooses; pyfsr handles discovery, trimming, picklist IRIs, and error reporting so the agent stays on task.
The registry¶
from pyfsr.agent.tools import list_tools, tool_schemas, dispatch
list_tools() # names of every registered tool
tool_schemas() # raw JSON-Schema definitions
Every result is JSON-serializable, and every failure is returned as a
structured {"error": {...}} dict – never a raised exception – so an agent
can read the message and self-correct.
Anthropic (Claude) tool-use¶
to_anthropic_tools() returns the registry in Claude’s tool-use shape
({name, description, input_schema}), and dispatch() runs whatever tool the
model picks. Wiring the two together is a short loop: send the tools, run any
tool_use blocks Claude returns, feed the results back, and repeat until it
stops asking for tools.
import json
import anthropic
from pyfsr import FortiSOAR
from pyfsr.agent.tools import to_anthropic_tools, dispatch
soar = FortiSOAR("soar.example.com", "your-api-token")
llm = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
tools = to_anthropic_tools()
messages = [{
"role": "user",
"content": "Find the latest critical alert and add a comment summarizing it.",
}]
while True:
resp = llm.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
tools=tools,
messages=messages,
)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
# No more tools requested -- Claude's final answer is in resp.content.
print(resp.content[-1].text)
break
# Run every tool Claude asked for and return the results in one turn.
results = []
for block in resp.content:
if block.type == "tool_use":
out = dispatch(soar, block.name, block.input) # JSON-safe, never raises
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(out),
})
messages.append({"role": "user", "content": results})
A typical run of the prompt above has Claude call query_records (filter alerts
by severity = Critical, newest first), then get_record to read it, then
create_record on comments with its summary – each step a tool call pyfsr
executes against the live appliance. Because dispatch() returns errors as
{"error": {...}} data rather than raising, a bad call just comes back as a
tool result Claude can read and correct, and the loop keeps going.
Tip
Install the SDK with pip install anthropic. The same loop works against the
bundled MCP server or OpenAI’s function calling – only the transport changes,
not the registry.
OpenAI function calling¶
from pyfsr.agent.tools import to_openai_tools, dispatch
tools = to_openai_tools() # feed to chat.completions
result = dispatch(client, "search_records", {"module": "alerts"})
Bundled MCP server¶
Install the extra and run the server over the tool registry:
pip install "pyfsr[mcp]"
python -m pyfsr.agent.mcp
The server reads FSR_* environment variables (see
Authentication) to build its client, and exposes the same registry of
tools to any MCP-compatible host.
MCP client config (Copilot, Cursor, Windsurf, Claude)¶
The pyfsr MCP server runs over stdio, so any MCP-compatible client can drive
it. Point your tool at python -m pyfsr.agent.mcp with the FSR_*
environment set:
Claude Desktop / Claude Code (claude_desktop_config.json):
{
"mcpServers": {
"pyfsr": {
"command": "python",
"args": ["-m", "pyfsr.agent.mcp"],
"env": {
"FSR_BASE_URL": "https://fortisoar.example.com:13000",
"FSR_API_KEY": "<your-api-key>"
}
}
}
}
Cursor / Windsurf (.cursor/mcp.json or ~/.codeium/windsurf/mcp_config.json):
{
"mcpServers": {
"pyfsr": {
"command": "python",
"args": ["-m", "pyfsr.agent.mcp"],
"env": {
"FSR_BASE_URL": "https://fortisoar.example.com:13000",
"FSR_API_KEY": "<your-api-key>"
}
}
}
}
GitHub Copilot (.vscode/mcp.json in the repo):
{
"servers": {
"pyfsr": {
"command": "python",
"args": ["-m", "pyfsr.agent.mcp"],
"env": {
"FSR_BASE_URL": "https://fortisoar.example.com:13000",
"FSR_API_KEY": "<your-api-key>"
}
}
}
}
For username/password instead of API key, use FSR_USERNAME and FSR_PASSWORD
in place of FSR_API_KEY. For multiple appliances, see the
Multi-instance config section below.
Multi-instance config (instances.toml)¶
When you manage multiple FortiSOAR appliances, list them in
~/.pyfsr/instances.toml and switch with --instance:
[instances.prod]
base_url = "https://fortisoar.example.com:13000"
username = "csadmin"
password = "<password>"
verify_ssl = false
[instances.dev]
base_url = "https://dev.fortisoar.example.com:13000"
api_key = "<dev-api-key>"
The CLI reads this automatically:
pyfsr instances list # show configured aliases
pyfsr instances show prod # resolved settings (no secrets)
pyfsr instances check # connect to each; exit 1 if any fails
Any pyfsr CLI command accepts --instance prod to target a specific
appliance. The MCP server picks the instance marked default = true (or the
first listed).
Two MCP servers: pyfsr vs fsr_playbooks¶
pyfsr ships the runtime/admin MCP server (this one); the separate
fsr_playbooks package ships the playbook-authoring MCP server
(python -m fsr_playbooks.mcp_server or fsrpb mcp). They share the same
FSR_* environment (fsr_playbooks builds its FortiSOAR client from the same
vars), so point both at one appliance. An agent that must create modules,
configure connectors, run connector actions, and build playbooks uses both:
Task |
Server |
Tool(s) |
|---|---|---|
Create custom modules |
pyfsr |
|
Configure connectors |
pyfsr |
|
Run connector actions |
pyfsr |
|
Build playbooks |
fsr_playbooks |
|
Trigger & verify a run |
pyfsr |
create a triggering record → |
pyfsr owns discovery, record CRUD, module admin, connector config, connector
run, and playbook run inspection/debugging. fsr_playbooks owns the playbook
DSL – compile/validate/push/dry-run, step-type and connector-op discovery
(get_step_type, get_op_schema, find_operation), single-step step_test,
and recipes. The two don’t overlap on the four tasks, so running both gives an
agent the full create-configure-run-build loop with no gaps.
See also
The pyfsr.agent.tools and pyfsr.agent.mcp modules in the API Reference
for the complete tool list and dispatch signatures.
Calling a registered MCP server’s tools¶
Everything above is about pyfsr acting as an MCP server. The reverse –
calling tools on an external MCP server you’ve registered in FortiSOAR
(e.g. FortiSIEM, DeepWiki, or any streamable-HTTP MCP server) – uses
client.ai.list_registered_tools and client.ai.call_registered_tool.
FortiSOAR itself has no REST endpoint that runs a registered server’s tool;
fsr-ai only calls them inside an agent investigation. So pyfsr resolves the
server’s url + auth from its registration record and speaks MCP tools/call
client-side – the same mechanism fsr-ai’s agent uses, driven from your
process. The appliance is not in the tool-call path (only the config lookup
goes through it).
Example: calling a FortiSIEM MCP server¶
FortiSIEM exposes a streamable-HTTP MCP server at /phoenix/mcp behind an
OAuth2 client_credentials grant. Once you’ve registered it in FortiSOAR
(client.ai.register_and_verify), you can list and call its tools:
from pyfsr import FortiSOAR
client = FortiSOAR("fortisoar.example.com", username="csadmin", password="<pw>")
# 1. Discover what tools the registered FortiSIEM server advertises.
tools = client.ai.list_registered_tools("FortiSIEM")
print([t.name for t in tools])
# ['query_fsm_postgres', 'query_fsm_clickhouse', 'get_incidents_by_entity',
# 'get_incident_by_id', 'get_reputation_by_entity', ...]
# 2. Call one. Arguments match the tool's input_schema.
# pyfsr reads the stored credential from the registration
# record and builds the auth header automatically.
r = client.ai.call_registered_tool(
"FortiSIEM",
"get_reputation_by_entity",
{"params": {"ip": ["8.8.8.8"]},
)
# 3. Read the result. MCPToolResult has .ok, .result, .error:
# .ok == (status == "success") -- FortiSOAR-native envelope convention.
# A third-party server like FortiSIEM returns its own payload, so .ok
# is often False even on success; use .result / .error directly.
print(r.result) # the tool's actual output (dict, string, or None)
print(r.error) # error text, if any
Registering FortiSIEM in the first place¶
If you haven’t registered the server yet, register_and_verify does
validate-then-save (the same sequence the UI’s “Add MCP Server” form runs),
keyed on name so re-running updates instead of duplicating:
import httpx
# Mint an OAuth2 bearer token from FortiSIEM's token endpoint.
token_resp = httpx.post(
"https://fortisiem.example.com:13001/phoenix/rest/pub/security/oauth/token",
data={
"grant_type": "client_credentials",
"client_id": "<fortisiem-client-id>",
"client_secret": "<fortisiem-client-secret>",
},
verify=False,
)
bearer = token_resp.json()["access_token"]
# Register in FortiSOAR.
saved = client.ai.register_and_verify({
"name": "FortiSIEM",
"description": "FortiSIEM Phoenix MCP server",
"type": "external", # user-registered; built-ins are "internal"
"transport": "http", # FortiSOAR maps http -> streamable_http
"url": "https://fortisiem.example.com:13001/phoenix/mcp",
"active": True,
"authentication": {"value": bearer, "type": "BEARER"},
})
print(f"Registered as {saved['uuid']}, {len(saved['tools'])} tools")
# Grant it to the agents that should call FortiSIEM during triage.
for name, version in [("siem", "1.0.0"), ("ioc-enrichment", "1.0.0")]:
client.ai.allow_mcp_server_for_agent(name, version, saved["uuid"])
# -> AgentConfigDTO(name='siem', version='1.0.0', ...)
See also
Complete, runnable examples:
fortisiem_mcp_setup_and_test.py
(full setup + investigation + evidence attribution),
register_and_call_public_mcp_server.py
(no-auth DeepWiki public server, minimal register-call-cleanup loop).