threatintel-mcp
# threatintel-mcp
An [MCP](https://modelcontextprotocol.io) 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+.
```bash
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
```bash
# 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-host` at 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\`):
```json
{
"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:
1. **`scan_url("http://secure-login-microsoft.example/")`** — submits an
*unlisted* scan (so the adversary isn't tipped off) and returns a `uuid`.
2. **`get_url_result(uuid)`** — comes back `malicious: true`, brand
`Microsoft` (credential phish), and a set of contacted domains/IPs including
`page_ip: 203[.]0[.]113[.]9` on ASN `EVIL-HOST`.
3. **`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.
4. **`lookup_ip("203.0.113.9")`** — the hosting IP has poor reputation and hosts
in a country inconsistent with the impersonated brand.
5. **`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
```bash
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](LICENSE).
TDQS
Scored across 6 tools
Each tool has a clearly distinct purpose: scan_url submits a new scan, get_url_result fetches a specific scan's findings, search_urlscan queries historical scans passively, and lookup_hash/lookup_domain/lookup_ip are separated cleanly by indicator type. The scan/fetch pairing is explicitly documented, so an agent can sequence them without confusion.
All tools follow a verb_noun snake_case pattern (scan_url, get_url_result, search_urlscan, lookup_hash/domain/ip). Minor deviation: the urlscan.io tools encode the source in the noun (urlscan/url_result) while VirusTotal tools use generic verbs, and scan_url vs get_url_result are slightly asymmetric.
Six tools is well-scoped for a threat-intel server, covering the two core workflows (urlscan.io scanning and VirusTotal lookups) without redundancy. Every tool earns its place with no filler.
The surface covers the main IOC pivots (URL submit/fetch, historical scan search, hash/domain/IP reputation), which is solid lifecycle coverage for investigation work. Minor gaps exist: no direct ASN/certificate/WHOIS tool, and no ability to submit samples to VirusTotal, but agents can work around these via the existing tools.