Skip to main content
Workflows Library MCP Directory Realtime AI News Sponsor Tier Subscribe
Front Page / AI Tools / Deep Dive

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

Deepak Bagada

CEO, SaaSNext

Aug 13, 2026 Published
|
Aug 13, 2026 Updated
|
12 Minutes Reading Time
Core Takeaways for Founders & Builders
  • 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:read and proposal:create roles. 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.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

Frequently Asked Questions
No — by design. propose_closure writes to a proposals endpoint requiring human approval; agents triage and propose, only a human changes status, preventing false-negative closes.
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.
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.
It attaches ATT&CK technique context so analysts can group alerts by campaign and prioritize technique chains over isolated hits.
Deepak Bagada
Author Profile

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

Briefing AI Tools

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...

Deepak Bagada Deepak Bagada
12m read
Breaking AI Tools

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...

Deepak Bagada Deepak Bagada
4m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc