mcp-recon
# mcp-recon
An MCP server that gives an AI agent reconnaissance tools, and enforces the
authorization scope in the server rather than in the prompt.
> **This is for targets you own or have written permission to test.** A bug
> bounty programme with a published scope, a signed penetration testing
> engagement, your own infrastructure. Nothing else.
>
> The `scope.yaml` file is the technical translation of that permission, and the
> server will not start without one. There is no unrestricted mode and no flag to
> switch the scope engine off.
---

Three refusals for three different reasons — an explicit deny rule, a name that
is not in the allowlist, and a name that *is* in the allowlist but resolves to
the cloud metadata service. The agent is told the same sentence every time; the
audit log is where the reason lives. Reproduce it with
[`demo/record.sh --play`](demo/record.sh).
---
## The problem
Give a language model an HTTP client and a system prompt saying "only test
`*.example.com`" and it will *mostly* comply. "Mostly" is the problem: the prompt
is a request, not a constraint, and there are three ordinary ways it stops being
honoured.
- **The agent reasons its way out.** It finds `internal-example.com` in a
certificate, decides that is obviously the same company, and checks it. Nothing
malicious happened; it was being helpful.
- **Something it reads tells it to.** The agent fetches a page and the page
contains text addressed to the agent. That is prompt injection, and its
defining feature is that the attacker's text arrives through the same channel
as your instructions, with no marker distinguishing them.
- **A name resolves somewhere else.** `api.example.com` is genuinely in scope. It
resolves to `169.254.169.254`. The agent did everything right and just read the
cloud metadata service.
The third case is the interesting one, because no amount of prompt engineering
addresses it. The agent cannot check what a hostname resolves to; it can only ask
the tool, and by then the request has been made.
So the scope does not live in the prompt. It lives in the execution path, as a
type:
```python
# exec/http.py — this is the only way in
async def fetch(self, target: AuthorizedTarget, *, path: str) -> HttpFetchResult:
```
There is no overload taking a string, and nothing but the scope engine can
produce an `AuthorizedTarget`. A tool that forgot to check the scope does not
fail review — it fails to typecheck. `tests/test_architecture.py` parses the AST
of every module under `tools/` to confirm that none of them imports an executor,
a socket library, or the one function that can mint that type.
An out-of-scope request is not discouraged; there is no way to express one. That
is also what makes the injection case survivable: a page can say whatever it
likes to the agent, and the worst outcome is that the agent tries something, the
server refuses, and the refusal is logged.
---
## The five layers
```
MCP client (the agent)
│ tool call with arguments
▼
┌─────────────────────────────────────────────────────────────┐
│ [1] Schema validation Pydantic v2: types, formats, │
│ ranges, allowed header names │
├─────────────────────────────────────────────────────────────┤
│ [2] Scope engine deny → allow → default-deny → │
│ resolve → validate EVERY address │
│ → pin ◀── NOT NEGOTIABLE │
├─────────────────────────────────────────────────────────────┤
│ [3] Budget & limits token bucket per host, session │
│ quota, concurrency, timeouts │
├─────────────────────────────────────────────────────────────┤
│ [4] Executor the real operation, against the │
│ pinned address, size-capped │
├─────────────────────────────────────────────────────────────┤
│ [5] Audit append-only JSONL: what was │
│ attempted, allowed, and refused │
└─────────────────────────────────────────────────────────────┘
```
Layer 2 is the one that matters, and the order inside it is the design:
1. **Is the scope still valid?** `expires` is mandatory, and past that date
everything is denied.
2. **Canonicalise.** `2130706433`, `0x7f000001`, `127.1`, `::ffff:127.0.0.1` and
`[2002:7f00:1::]` are all 127.0.0.1. `EXAMPLE.com.`, `example%2ecom` and
`example。com` are all `example.com`. A Cyrillic lookalike is *not*. Anything
genuinely ambiguous — userinfo before the host, a backslash, a stray percent
sign — is refused rather than guessed at.
3. **Deny beats allow.** Always, regardless of order or specificity.
4. **Default deny.** No allow rule, no access. Not being in the deny list is not
a reason.
5. **Resolve, then validate the address on its own merits.** Loopback,
link-local, cloud metadata, RFC 1918, CGNAT, multicast and reserved ranges are
refused even when the name pointing at them is in your allowlist.
6. **Pin the address.** The executor connects to the IP the engine validated,
with the real hostname in the `Host` header and in TLS SNI. Nothing re-resolves
between the check and the connection, so DNS rebinding has no window to happen
in, and TLS still validates against the hostname.
7. **Every redirect hop repeats all of the above.** `Location:
http://169.254.169.254/` from a host that is genuinely in scope is the
cheapest SSRF there is. The chain stops at the first refusal.
---
## What it will not do
The interesting part of a security tool is the list of things it was capable of
and did not ship. In rough order of how often it comes up:
| Not exposed | Why |
|---|---|
| **Port range scanning** | `check_ports` takes an explicit list, capped at 20. Asking for more is an error, not a truncated scan — an agent that asked for 60 ports and silently got 20 would draw conclusions from a scan it did not run. |
| **Subdomain or directory brute-forcing** | Thousands of requests to find something. Certificate transparency finds the same subdomains passively, and an agent in a loop with a wordlist is a denial of service with good intentions. |
| **Any state-changing HTTP method** | GET, HEAD and OPTIONS. Reconnaissance observes; it does not modify. |
| **Arbitrary request headers** | Five headers may be set. Forging `X-Forwarded-For` or `Host` is an exploitation technique wearing a reconnaissance costume. |
| **Credential testing of any kind** | No login attempts, no default-credential checks, no spraying. |
| **Payload injection** | No SQLi, XSS, SSTI or command-injection probes. Finding a vulnerability class is a human judgement about impact and consent, not a tool call. |
| **JWT signature verification** | We do not have the key. `analyze_jwt` says so in its own output, because a tool that implied it had verified something would be worse than one that did not check. |
| **Reading local files or running commands** | There is no filesystem tool and no shell tool. The only file this server reads is the scope. |
| **Turning off the scope engine** | Asked for often. The answer is no. The moment there is a bypass flag, the flag is the security model, and flags get set by tired people at 2am. |
---
## Installation
Requires Python 3.11 or later.
```bash
git clone https://github.com/dotMuny/mcp-recon && cd mcp-recon
uv sync
uv run mcp-recon --scope ./scope.yaml --check
```
To get a `mcp-recon` on your `PATH` — which is what an MCP client needs, since it
launches the server from an unpredictable working directory:
```bash
uv tool install .
```
A container image is also defined; see [`Dockerfile`](Dockerfile) for the mount
layout, which keeps the scope file outside the image on purpose.
---
## The scope file
```yaml
scope:
name: "example-program"
authorized_by: "https://example.com/.well-known/security.txt -- public bug bounty, retrieved 2026-09-01"
expires: "2027-12-31"
allow:
domains:
- "example.com" # the apex, and only the apex
- "*.example.com" # any subdomain at any depth, NOT the apex
ips:
- "203.0.113.0/24"
deny:
domains:
- "admin.example.com"
limits:
requests_per_minute_per_host: 30
total_requests_per_session: 1000
```
Full key reference, wildcard semantics and the limits you can set:
[`docs/tools.md`](docs/tools.md). Start from
[`scope.example.yaml`](scope.example.yaml), and check it before you use it:
```bash
mcp-recon --scope ./scope.yaml --check
```
---
## Connecting a client
`claude_desktop_config.json`, or `claude mcp add`:
```json
{
"mcpServers": {
"recon": {
"command": "mcp-recon",
"args": [
"--scope", "/absolute/path/to/scope.yaml",
"--audit-log", "/absolute/path/to/audit/session.jsonl"
]
}
}
}
```
Both paths must be absolute. Any other stdio MCP client works the same way:
diagnostics go to stderr, and stdout carries the MCP wire protocol and nothing
else.
There is also a Streamable HTTP transport, which binds to loopback and requires a
bearer token:
```bash
MCP_RECON_AUTH_TOKEN=$(openssl rand -hex 32) \
mcp-recon --scope ./scope.yaml --transport http --port 8931
```
Think carefully before exposing that to a network. Anyone who reaches the port can
run reconnaissance against your authorized targets, spend your budget, and put
your source address on the traffic. Put it behind TLS and a reverse proxy that
authenticates properly, or keep it on stdio.
---
## The tools
Ten, plus two free introspection calls — `get_scope` and `get_budget`. Four
passive tools that never contact the target (DNS, WHOIS, certificate
transparency, TLS certificate), three active ones that make exactly one request
each (`http_fetch`, `http_headers_audit`, `check_ports`), and three local
decoders that cost nothing (`analyze_jwt`, `decode_payload`, `parse_url`).
Every description states what the tool does, what it does **not** do, and what it
costs against the budget. Full table, costs, resources and prompts:
[`docs/tools.md`](docs/tools.md).
---
## Tool output is data, not instructions
Everything a tool returns comes from outside — page bodies, DNS TXT records,
certificate subjects, WHOIS text — and any of it can contain text addressed to
the agent. The results carry a warning saying so, but the answer is structural
rather than textual: the scope is enforced in this process, below the layer the
agent operates at. An injected instruction can persuade the agent to *try*
something; it cannot make the attempt succeed.
---
## Development
```bash
uv sync
uv run pytest # 409 tests, no network access required
uv run mypy --strict src
uv run ruff check
```
The whole suite runs offline. Every network call is mocked at the transport with
`respx` or injected behind a protocol, and the handful of tests that open real
sockets bind them on loopback. If a test ever reaches the internet, that is a
bug — and you can prove it has not:
```bash
unshare -rn sh -c 'ip link set lo up; .venv/bin/python -m pytest'
```
409 passed, with no network interface but loopback.
Worth reading if you are here for the interesting parts:
- **`tests/data/bypass_cases.yaml`** — 90 scope-evasion attempts, each with the
expected verdict and a note on why. Adding a case is one YAML block.
- **`tests/test_scope_props.py`** — Hypothesis generating hostnames and addresses
against absolute invariants. "Almost never approves a loopback address" is not
a security property.
- **`tests/test_architecture.py`** — the import rules, checked by reading the AST.
- **`src/mcp_recon/scope/normalize.py`** — every known allowlist bypass is a
representation bug, and they all live here.
---
## Documentation
- [`docs/threat-model.md`](docs/threat-model.md) — what the design protects
against, and the six things it does not.
- [`docs/decisions.md`](docs/decisions.md) — the design decisions with a real
alternative, and what was given up.
- [`docs/tools.md`](docs/tools.md) — scope file reference, tool costs, budget
behaviour, audit record format.
- [`SECURITY.md`](SECURITY.md) — how to report a scope bypass, and what counts
as one.
---
## Licence
Apache-2.0. See [LICENSE](LICENSE).
TDQS
Scored across 12 tools
Each tool targets a distinct reconnaissance action or data source; overlapping pairs (certificate_transparency vs tls_certificate_info, analyze_jwt vs decode_payload, http_fetch vs http_headers_audit) are clearly differentiated by descriptions. Minor overlap remains but no serious misselection risk.
All names are snake_case and descriptive, but the set mixes verb-first patterns (check_ports, parse_url) with noun-first patterns (dns_lookup, http_fetch) and noun-noun names (certificate_transparency, tls_certificate_info). The convention is readable but not fully predictable.
12 tools for a scoped reconnaissance server is well within the ideal 3-15 range; each tool covers a distinct capability and there is no redundant filler.
The surface covers scope/budget, passive DNS/WHOIS/CT, live TLS, HTTP fetching/auditing, port checks, and local JWT/payload/URL analysis. Minor gaps exist (e.g., no reverse DNS/IP WHOIS or deeper active enumeration), but they are reasonable given the deliberate safety restrictions.