mcp server
threatintel-mcp
MCP server wrapping VirusTotal and urlscan.io as typed tools for Claude-driven threat investigation and IOC pivoting.
Set up
Runs on your computer. Pick your agent and operating system.
Before you start
- uv (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
{
"mcpServers": {
"threatintel-mcp": {
"command": "uvx",
"args": [
"threatintel-mcp"
]
}
}
}same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12
Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs
README
synced Oct 22, 2026threatintel-mcp
An MCP server that wraps threat-intelligence lookup APIs — urlscan.io and VirusTotal — so an AI agent (e.g. Claude) can pivot on indicators during an investigation the way an analyst does at a terminal.
This is a portfolio demonstration. It is a compact, self-contained example built to show design judgment around MCP servers for threat intelligence: tool ergonomics, compact/structured output, rate-limit hygiene, OPSEC, and treating tool output as untrusted. It is not a production incident-response platform, and it wraps only a small slice of each provider's API.
Why this exists
When you investigate a suspicious indicator, you pivot: a URL leads to a domain, the domain resolves to an IP, the IP belongs to an ASN, a dropped file has a hash, that hash shows up on other scans. Each hop is an API call to a different threat-intel platform.
Exposing those platforms as MCP tools lets an agent do that pivoting autonomously and in natural language — "is this domain malicious, and what else lives on its hosting?" — while the human stays in the loop for judgment. MCP is the clean seam for this: one small server, any MCP-capable client.
What it does — the tools
| Tool | Provider | Purpose |
|---|---|---|
scan_url | urlscan.io | Submit a URL for a live sandbox scan (returns a uuid). |
get_url_result | urlscan.io | Poll a scan uuid for the verdict + contacted infrastructure. |
search_urlscan | urlscan.io | Passively search historical scans (Elasticsearch query syntax). |
lookup_hash | VirusTotal v3 | Multi-engine verdict + threat label for a file hash (MD5/SHA-1/SHA-256). |
lookup_domain | VirusTotal v3 | Reputation, registrar, creation date, categories for a domain. |
lookup_ip | VirusTotal v3 | Reputation + hosting context (ASN, owner, country) for an IP. |
Each tool's docstring is written as a "when to call me" prompt — the SDK turns the type hints and docstring into the JSON Schema and description the model sees, so the tools are self-documenting to the agent.
Architecture & design decisions
Three urlscan primitives, three VT lookups. urlscan is modeled around its own submit → poll → search loop; VirusTotal around direct object lookups. That mapping keeps each tool a thin, predictable wrapper over one endpoint.
Compact, structured output — not raw API JSON. A single VirusTotal file
report or urlscan result can be hundreds of KB (every AV engine's verdict, the
full DOM, every request/response). Dumping that into a model's context is
wasteful and buries the signal. Every tool projects the response down to the
handful of fields an investigator actually pivots on — detection ratio, threat
label, reputation, ASN/owner, contacted domains/IPs, first/last seen. This is a
deliberate design choice, commented at each summarize_* function in
virustotal.py / urlscan.py.
All tool output is treated as untrusted and defanged. Threat-intel responses
contain attacker-controlled content — a phishing page's title, a malicious
domain, a WHOIS record. MCP tool output is a prompt-injection surface for the
model and a click-hazard for a human reading a terminal. So every indicator
is defanged on the way out (http → hxxp, evil.test → evil[.]test,
1.2.3.4 → 1[.]2[.]3[.]4) at a single central choke point (sanitize.py).
Client-side rate limiting. The VirusTotal public tier allows 4 req/min and
500/day. A per-API sliding-window limiter (ratelimit.py) throttles locally so a
well-behaved server never trips the upstream 429 under normal single-analyst use.
Structured errors, never raw tracebacks. Auth failures, 404s, upstream 429s,
timeouts and local rate-limit hits are all mapped to small categorized error
envelopes (errors.py) so the agent can decide whether to retry, back off, or
ask for a key. They are returned as tool results with isError: true (the
envelope is the JSON text content), so clients can tell a failed lookup from a
successful one.
Tool annotations. Every tool declares MCP annotations: the five lookups are
readOnlyHint: true; scan_url is not, because it submits a new scan. All are
openWorldHint: true since they call third-party services.
Secrets from the environment only. VT_API_KEY and URLSCAN_API_KEY are
read from the environment (or a local, gitignored .env). Nothing is hardcoded.
MCP protocol version
Built on the MCP Python SDK 2.x and targets the 2026-07-28 specification:
stateless requests (no initialize handshake), server/discover, resultType
on every result, and cacheable tools/list results (ttlMs/cacheScope).
The SDK still negotiates earlier revisions (e.g. 2025-11-25) with older clients.
Cross-call state is already explicit: scan_url hands back a uuid that you
pass to get_url_result, which is the pattern the stateless spec recommends.
Transports: stdio vs. Streamable HTTP
- stdio (default) — the client spawns the server as a subprocess over stdin/stdout. Best for a single local analyst running Claude Desktop or an IDE MCP client on their own workstation with their own keys.
- Streamable HTTP (
--transport http) — the server runs as a long-lived networked process. Best for a shared team deployment: one server holding the org's keys and rate-limit budget, many analysts pointing their clients at it.
Setup
Requires Python 3.10+.
git clone https://github.com/ChristianPresley/threatintel-mcp.git
cd threatintel-mcp
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
cp .env.example .env # then edit .env and add your keys
Get API keys:
- VirusTotal: https://www.virustotal.com/gui/my-apikey
- urlscan.io: https://urlscan.io/user/profile/
Running
# stdio (local analyst) — this is what an MCP client launches for you:
threatintel-mcp --transport stdio
# Streamable HTTP (shared team) — long-lived server on a port:
threatintel-mcp --transport http --host 0.0.0.0 --port 8000 \
--allowed-host 'ti.corp.example:*'
The HTTP transport validates the Host and Origin headers to block DNS
rebinding. Loopback names are always allowed; any other hostname clients use to
reach the server must be listed with --allowed-host (repeatable), and browser
origins with --allowed-origin. Requests with any other Host get
421 Misdirected Request.
The HTTP transport has no built-in authentication. Anyone who can reach the port can spend the org's VirusTotal/urlscan quota. For a shared deployment, keep it on a private network and put it behind an authenticating reverse proxy (e.g. OAuth/OIDC or mTLS), and point
--allowed-hostat the proxy's hostname. The server prints a warning when bound to a non-loopback address.
Claude Desktop / MCP client config
Add to claude_desktop_config.json (macOS:
~/Library/Application Support/Claude/, Windows:
%APPDATA%\Claude\):
{
"mcpServers": {
"threatintel": {
"command": "threatintel-mcp",
"args": ["--transport", "stdio"],
"env": {
"VT_API_KEY": "your-virustotal-key",
"URLSCAN_API_KEY": "your-urlscan-key"
}
}
}
}
If threatintel-mcp isn't on the client's PATH, use the absolute path to the
venv entry point (e.g. .venv/Scripts/threatintel-mcp.exe on Windows) or invoke
via python -m threatintel_mcp.server.
Worked example: pivoting a suspicious domain
An analyst hands the agent a suspicious link. A natural investigation flow:
scan_url("http://secure-login-microsoft.example/")— submits an unlisted scan (so the adversary isn't tipped off) and returns auuid.get_url_result(uuid)— comes backmalicious: true, brandMicrosoft(credential phish), and a set of contacted domains/IPs includingpage_ip: 203[.]0[.]113[.]9on ASNEVIL-HOST.lookup_domain("secure-login-microsoft.example")— VT shows a creation date three days ago (newly-registered-domain signal) and a couple of engines already flagging it.lookup_ip("203.0.113.9")— the hosting IP has poor reputation and hosts in a country inconsistent with the impersonated brand.search_urlscan("page.ip:203.0.113.9")— passively reveals other phishing pages on the same box, expanding the campaign's footprint.
Five hops, two providers, one MCP server — and every indicator in the transcript is defanged so nothing is accidentally clicked or re-interpreted downstream.
Development
pytest # runs the mocked test suite — no real API calls are made
ruff check . # lint
Tests mock every HTTP interaction with respx; the suite never touches the
network and needs no real API keys.
License
MIT © 2026 Christian Presley — see LICENSE.