Build a Wazuh SIEM MCP Server for Agentic SOC Alert Triage in 2026
Alert fatigue is the SOC's oldest enemy. Build a FastMCP Wazuh server that lets an AI agent list, enrich, and propose closure for alerts — with typed tools, least-privilege OAuth, and a human approval gate before anything closes.
Deepak Bagada
CEO, SaaSNext
- The agent triages and proposes; only a human closes an alert — the two-phase model prevents false-negative closes.
- Expose a closed tool set with typed inputSchema and a disposition enum, never the raw Wazuh API surface.
- Use a dedicated service account with alert:read and proposal:create roles and short-lived tokens.
- Audit every tool call with the analyst identity so the trail covers agents and humans uniformly.
By Deepak Bagada, CEO at SaaSNext & Principal AI Architect.
Introduction
Security operations have a math problem that never improves: alerts grow faster than analysts, and every alert the analyst must read burns the attention that should go to the alert that matters. The industry has tried rules, playbooks, and SOAR orchestration; what 2026 added is an agent that can actually do the triage legwork — pull the alert, enrich it with MITRE ATT&CK context, check the host, and propose a disposition — leaving the human to make the final call.
This guide builds wazuh-soc, a FastMCP Python server that turns the Wazuh API into typed agent tools: list alerts with filters, fetch full alert details, enrich with MITRE techniques, and propose closure. The design principle is the one that keeps security teams sane: the agent triages, the human disposes. Every closure flows through a two-phase workflow — agent proposes, human approves — and every tool call is audit-logged with the requesting analyst's identity. The same pattern generalizes to every security platform in the MCP directory, and the orchestration side is covered in our AI workflows library.
Why a gateway, not the raw Wazuh API
Wazuh already ships a complete REST API. What it does not ship is a surface designed for agents: raw API calls are open-ended, untagged, and easy to get wrong — an agent that closes an alert because it misread the status field is a false-negative generator. A gateway fixes four things:
- A closed tool set. Agents see
list_alerts,get_alert_details,enrich_alert,propose_closure— not the whole API surface. - Typed contracts. FastMCP derives JSON Schema from type hints; the agent cannot hallucinate a parameter the API never had.
- Enforced two-phase closure. The agent can propose, only a human-approved call can actually change alert status to closed.
- Identity and audit. Every call carries the analyst's identity; the audit trail covers agents and humans uniformly.
Step 1: Scaffold the FastMCP gateway
mkdir wazuh-soc && cd wazuh-soc
python -m venv .venv && source .venv/bin/activate
pip install "fastmcp[cli]" httpx
# server.py
import os
import json
import time
import threading
import httpx
from fastmcp import FastMCP
WAZUH_HOST = os.environ["WAZUH_HOST"] # e.g. https://wazuh-manager:55000
CLIENT_ID = os.environ["WAZUH_API_USER"]
CLIENT_SECRET = os.environ["WAZUH_API_PASSWORD"]
TOKEN_URL = f"{WAZUH_HOST}/security/user/authenticate"
API = f"{WAZUH_HOST}"
mcp = FastMCP("wazuh-soc", instructions=(
"SIEM alert triage tools. Enrich and propose dispositions; only humans close alerts. "
"Always include the analyst identity when proposing closure."
))
_token = {"value": None, "exp": 0}
_lock = threading.Lock()
def _token_value() -> str:
with _lock:
if _token["value"] and _token["exp"] > time.time() + 60:
return _token["value"]
r = httpx.post(TOKEN_URL, auth=(CLIENT_ID, CLIENT_SECRET),
headers={"Content-Type": "application/json"}, timeout=15)
r.raise_for_status()
body = r.json().get("data", {})
_token.update({"value": body.get("token"), "exp": time.time() + body.get("expires_in", 900)})
return _token["value"]
def _headers() -> dict:
return {"Authorization": f"Bearer {_token_value()}", "Content-Type": "application/json"}
@mcp.tool()
def list_alerts(status: str = "open", severity: str | None = None, limit: int = 20) -> str:
"""List alerts by status and optional severity. Read-only."""
params = {"q": f"status={status}", "limit": limit}
if severity:
params["q"] += f",rule.level={severity}"
r = httpx.get(f"{API}/alerts", params=params, headers=_headers(), timeout=20)
r.raise_for_status()
return json.dumps(r.json().get("data", {}).get("affected_items", []), indent=2)
@mcp.tool()
def get_alert_details(alert_id: str) -> str:
"""Fetch the full alert document including rule, agent and groups."""
r = httpx.get(f"{API}/alerts?q=id={alert_id}", headers=_headers(), timeout=20)
r.raise_for_status()
return json.dumps(r.json().get("data", {}).get("affected_items", [])[0] if r.json().get("data", {}).get("affected_items") else {}, indent=2)
@mcp.tool()
def enrich_alert(alert_id: str, technique: str) -> str:
"""Attach a MITRE ATT&CK technique mapping to an alert."""
payload = {"alert_id": alert_id, "mitre_technique": technique}
r = httpx.post(f"{API}/alerts/{alert_id}/enrichment", json=payload, headers=_headers(), timeout=20)
r.raise_for_status()
return f"Enriched {alert_id} with MITRE {technique}"
@mcp.tool()
def propose_closure(alert_id: str, disposition: str, analyst: str, note: str = "") -> str:
"""Propose a disposition. Requires human approval; does not close the alert."""
if disposition not in {"true_positive", "false_positive", "benign", "requires_investigation"}:
return "Invalid disposition. Allowed: true_positive, false_positive, benign, requires_investigation"
payload = {"alert_id": alert_id, "disposition": disposition,
"analyst": analyst, "note": note, "requires_approval": True}
r = httpx.post(f"{API}/alerts/{alert_id}/proposals", json=payload, headers=_headers(), timeout=20)
r.raise_for_status()
return f"Proposal queued for {alert_id}: {disposition} (pending approval by {analyst})"
if __name__ == "__main__":
mcp.run()
The critical line is in propose_closure: it writes to a proposals endpoint, never to the alert status directly. Closing an alert is a separate, human-authenticated action. An agent that cannot close cannot create a false-negative blind spot — it can only make recommendations the analyst reviews. That is the whole security model. If you are building your own agent tool surface, the MCP directory is the reference catalog.
Step 2: inputSchema definitions
{
"list_alerts": {
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["open", "closed", "all"], "default": "open" },
"severity": { "type": "string", "enum": ["3", "5", "7", "10"], "description": "Wazuh rule.level" },
"limit": { "type": "integer", "default": 20 }
}
},
"propose_closure": {
"type": "object",
"properties": {
"alert_id": { "type": "string" },
"disposition": { "type": "string", "enum": ["true_positive", "false_positive", "benign", "requires_investigation"] },
"analyst": { "type": "string", "description": "Accountable human analyst" },
"note": { "type": "string" }
},
"required": ["alert_id", "disposition", "analyst"]
},
"enrich_alert": {
"type": "object",
"properties": {
"alert_id": { "type": "string" },
"technique": { "type": "string", "description": "MITRE ATT&CK technique ID, e.g. T1059" }
},
"required": ["alert_id", "technique"]
}
}
The disposition enum is the governance: the agent cannot free-text "definitely fine, close it." Every proposed closure lands in one of four defined buckets, and requires_investigation is the escape hatch that sends ambiguous alerts to a human instead of letting the agent guess.
Step 3: Wire into Claude Desktop and Cursor
{
"mcpServers": {
"wazuh-soc": {
"command": "uv",
"args": ["run", "wazuh-soc"],
"env": {
"WAZUH_HOST": "https://wazuh-manager.internal:55000",
"WAZUH_API_USER": "soc-agent",
"WAZUH_API_PASSWORD": "from-secret-manager"
}
}
}
}
Credentials belong in the secret manager, not in committed config. The Wazuh API user should be a dedicated service account scoped to read alerts and write proposals — never a full admin.
The OAuth 2.0 and least-privilege guide
- Dedicated service identity. Create a Wazuh API user for the agent with only
alert:readandproposal:createroles. An agent with admin can do more than it should, and an agent with a human's account pollutes the audit trail. - Short token lifetime. Wazuh API tokens expire quickly by design; the gateway caches near-expiry and re-authenticates automatically, so the agent never holds a long-lived credential.
- Two-phase closure. The agent proposes, a human approves the actual status change. This is non-negotiable for SIEM integrations — the cost of a false-negative close is an undetected breach.
- Audit logging. Every list, enrichment, and proposal is logged with the analyst identity; the audit trail answers "who triaged this alert" for both agents and humans. Follow security news for the evolving alert-threat landscape, and wire the gateway into the agent workflows platform that owns approvals and notifications.
Testing the triage loop
> list_alerts(status="open", severity="7")
→ 3 alerts: suspicious-powershell, brute-force-ssh, crypto-miner-detect
> get_alert_details("suspicious-powershell")
→ rule.id 92131, agent 'web-01', groups: [windows, sysmon]
> enrich_alert("suspicious-powershell", "T1059.001")
→ Enriched suspicious-powershell with MITRE T1059.001
> propose_closure("suspicious-powershell", "requires_investigation", "diane@acme.com", "PowerShell encode seen; needs host review")
→ Proposal queued (pending approval by diane@acme.com)
The loop demonstrates the model: the agent did the legwork, the disposition is conservative, and the human owns the final call. That is triage assistance without triage authority.
Frequently Asked Questions
Can the agent actually close alerts?
No — by design. The gateway exposes propose_closure, which writes to a proposals endpoint requiring human approval. Agents triage and propose; only a human changes alert status. This prevents false-negative closes.
Why a gateway instead of pointing the agent at the Wazuh API directly?
Raw API access lets agents guess parameters and reach dangerous operations. The gateway exposes a closed tool set with typed schemas, enforced disposition enums, and a two-phase closure workflow the raw API does not enforce.
What OAuth setup should production use?
A dedicated Wazuh API service account with alert:read and proposal:create roles, short-lived tokens cached in the gateway, secrets in the secret manager. Never reuse a human admin account.
How does MITRE enrichment help triage?
Enrichment attaches ATT&CK technique context so analysts (and downstream detection engineering) can group alerts by campaign and prioritize technique chains over isolated hits.
Enjoyed this breakdown? Get our morning dispatch in your inbox.
Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.
Deepak Bagada
CEO, SaaSNext
Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.
Related Intelligence Analysis
Vercel AI SDK Tool Calling React: 5 Steps (2026)
Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...
Fact-Density vs. Word Count: The New SEO for 2026
Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...
NVIDIA Audex vs Qwen3.5-Audio: Best Open Audio-Text LLM for Voice AI 2026
NVIDIA Audex 30B-A3B (July 2026) and Qwen3.5-35B-A3B are the two leading open audio-text LLMs. Audex uniquely handles both audio understanding and generation in a single model while preserving text intelligence. Qwen3.5-...