Skip to main content
Glama
threatintel-mcp

threatintel-mcp

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.

Related MCP server: virustotal-mcp-server

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:

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.

Available Tools

6 tools
get_url_resultGet urlscan.io resultA
Read-onlyIdempotent

Fetch the results of a urlscan.io scan by its uuid.

Call this after scan_url to retrieve the verdict and the contacted infrastructure (domains, IPs, ASN, hosting country, page title, TLS issuer). Scans take roughly 10-30 seconds; if this fails with an error of kind 'pending', wait a few seconds and call again.

Inputs: uuid (required). Returns: object

lookup_domainVirusTotal domain lookupA
Read-onlyIdempotent

Look up a domain on VirusTotal.

Call this to assess a domain indicator: its detection ratio across URL/domain engines, reputation score, registrar and creation date (a recent creation date is a strong newly-registered-domain signal), and category tags. Useful when pivoting from a URL or email sender to the domain behind it. Returns a compact summary.

Inputs: domain (required). Returns: object

lookup_hashVirusTotal file hash lookupA
Read-onlyIdempotent

Look up a file hash on VirusTotal.

Call this when you have a file hash (from a sample, an email attachment, an EDR alert, a sandbox report) and want to know whether it's known-malicious: the multi-engine detection ratio, the suggested threat label/family, file type and size, and first/last-seen dates. Accepts MD5, SHA-1 or SHA-256. Returns a compact summary, not the full per-engine report.

Inputs: file_hash (required). Returns: object

lookup_ipVirusTotal IP lookupA
Read-onlyIdempotent

Look up an IP address on VirusTotal.

Call this to get hosting context and reputation for an IP indicator: the detection ratio, reputation score, ASN, owning organization, network range and country. Useful when pivoting from a domain's resolved IP or a urlscan-reported page_ip to understand who hosts the infrastructure. Returns a compact summary.

Inputs: ip (required). Returns: object

scan_urlSubmit URL to urlscan.ioA

Submit a URL to urlscan.io for a fresh sandbox scan.

Call this when you have a suspicious or unknown URL and want live analysis: what it loads, where it redirects, what infrastructure it contacts, and whether urlscan flags it as malicious. Returns a scan uuid — then poll get_url_result with that uuid to fetch findings.

OPSEC — visibility defaults to 'unlisted' on purpose. A public scan is indexed and browsable by anyone, including the adversary, who may be watching urlscan for scans of their own infrastructure. Scanning their URL publicly tips them off that they are under investigation and can burn the operation. Use 'public' only when you deliberately want the result shared; use 'private' for the most sensitive cases.

Inputs: url (required), visibility. Returns: object

search_urlscanSearch urlscan.io historyA
Read-onlyIdempotent

Search urlscan.io's historical scan database (Elasticsearch query syntax).

Call this to find existing scans instead of running a new one — e.g. to see every scan that touched a domain/IP, hunt for a favicon or TLS-cert hash across sites, or discover other pages hosted on the same infrastructure. This is passive: it does not touch the target, so it's safe to use freely during an investigation. Returns a short list of matching scans with their domains, IPs, ASN and result links to pivot from.

Inputs: query (required), size. Returns: object

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A4.3/5.0

    Scored across 6 tools

    Disambiguation5/5

    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.

    Naming Consistency4/5

    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.

    Tool Count5/5

    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.

    Completeness4/5

    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.

    Maintenance

    ActivitySlowing
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      A
      maintenance
      An MCP server that exposes a 60+ tool security and threat-intel stack to AI agents, enabling secret scanning, Sigma rule generation, ransomware lookup, OSINT, and deep research.
      1
      MIT
    • F
      license
      A
      quality
      D
      maintenance
      MCP server for security analysis using VirusTotal API, enabling AI assistants to analyze URLs, files, IP addresses, and domains with automatic relationship fetching.
      8
      1
      -
    • A
      license
      A
      quality
      C
      maintenance
      MCP server for urlscan.io that scans URLs, searches historical scan data, and assesses indicators with compact, context-efficient summaries instead of raw API responses.
      14
      MIT