Skip to content
Conduit

mcp server

threatintel-mcp

MCP server wrapping VirusTotal and urlscan.io as typed tools for Claude-driven threat investigation and IOC pivoting.

ChristianPresley/threatintel-mcpcommunitylistedunreviewed

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`)
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "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, 2026

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

ToolProviderPurpose
scan_urlurlscan.ioSubmit a URL for a live sandbox scan (returns a uuid).
get_url_resulturlscan.ioPoll a scan uuid for the verdict + contacted infrastructure.
search_urlscanurlscan.ioPassively search historical scans (Elasticsearch query syntax).
lookup_hashVirusTotal v3Multi-engine verdict + threat label for a file hash (MD5/SHA-1/SHA-256).
lookup_domainVirusTotal v3Reputation, registrar, creation date, categories for a domain.
lookup_ipVirusTotal v3Reputation + 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:

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-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\):

{
  "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

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.

↑↓ navigate openesc close