Skip to main content
Glama
Neeraj829784

OWASP ZAP MCP Server

by Neeraj829784
README.md
<div align="center">

# ๐Ÿ•ท๏ธ OWASP ZAP MCP Server

### Drive the world's most popular web app scanner from your AI assistant.

Point Claude, Cursor, or any [MCP](https://modelcontextprotocol.io) client at
**[OWASP ZAP](https://www.zaproxy.org/)** and run real crawls, authenticated
scans, and vulnerability triage โ€” through **67 curated, safety-gated tools**
built straight from the [official ZAP API](https://www.zaproxy.org/docs/api/).

[![CI](https://github.com/Neeraj829784/zap-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/Neeraj829784/zap-mcp-server/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/)
[![OWASP ZAP 2.17](https://img.shields.io/badge/OWASP%20ZAP-2.17.0-00549E)](https://www.zaproxy.org/)
[![MCP](https://img.shields.io/badge/Protocol-MCP-6E56CF)](https://modelcontextprotocol.io)
[![Tools](https://img.shields.io/badge/Tools-67-1f883d)](#-the-67-tools)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](https://docs.docker.com/)

<samp>[**Quick start**](#-quick-start) ยท [**See it in action**](#-see-it-in-action) ยท [**The 67 tools**](#-the-67-tools) ยท [**Authenticated scanning**](#-authenticated-scanning-in-6-steps) ยท [**Security**](#-security-model) ยท [**Responsible use**](#-responsible-use)</samp>

</div>

> [!WARNING]
> **Authorized use only.** Active scanning sends real attack payloads. Run it
> **only** against systems you have explicit, written permission to test.
> This server refuses cloud-metadata targets and can be pinned to an
> engagement scope โ€” but the responsibility is yours.

---

## โšก See it in action

Once it's connected, you drive ZAP in plain language. The model picks the right
tools and chains them โ€” no REST calls, no ZAP UI:

> ๐Ÿ’ฌ **You:** *Crawl `https://juice-shop.herokuapp.com`, run an active scan, and summarise what you find.*

Behind that one sentence, the assistant orchestrates the toolchain:

```text
โ‘   zap_access_url            seed the target into ZAP's site tree
โ‘ก  zap_spider_scan       ๐ŸŽฏ  crawl for pages, forms, and parameters
โ‘ข  zap_active_scan       ๐ŸŽฏ  attack the discovered surface (SQLi, XSS, โ€ฆ)
โ‘ฃ  zap_get_alerts_summary    triage findings by severity
โ‘ค  zap_generate_report       export a shareable HTML report
```

> ๐Ÿค– **Assistant:** *Crawled 41 URLs and completed the active scan. Findings by
> severity โ€” **High:** SQL injection on `/rest/products/search`; **Medium:**
> missing CSP and reflected XSS; plus several low-severity header issues. Full
> report written to `/zap/wrk/report.html`.*

Every attack-capable step (๐ŸŽฏ) is checked against the target-authorization policy
first, so the model physically cannot fire a scan at an out-of-scope or
cloud-metadata host โ€” regardless of what it's asked to do.

---

## โœจ Why this exists

Talking to ZAP's raw REST API from an LLM is clumsy and risky: hundreds of
endpoints, no guardrails, and it's easy to point an attack at the wrong host.
This project gives your AI assistant a **small, opinionated, safe** surface:

<table>
<tr>
<td width="50%" valign="top">

**๐Ÿ” Safe by design**
- Cloud metadata endpoints (`169.254.169.254`) are **always** refused
- Optional scope allowlist + private-range blocking
- Control port bound to localhost by default

</td>
<td width="50%" valign="top">

**๐Ÿ”‘ Real authenticated scanning**
- Full workflow: context โ†’ auth method โ†’ indicators โ†’ user โ†’ forced-user
- `scan_as_user` for spider, AJAX spider, and active scan
- The thing most ZAP wrappers skip entirely

</td>
</tr>
<tr>
<td width="50%" valign="top">

**๐Ÿงฑ Reliable under load**
- One pooled async client, bounded retries with backoff
- Typed errors + a uniform result envelope
- A single bad call can never crash the server

</td>
<td width="50%" valign="top">

**๐Ÿšข Production posture**
- Fail-closed config, secrets never logged
- Pinned, non-root, health-gated containers
- Green CI on every push

</td>
</tr>
</table>

---

## ๐Ÿ—๏ธ Architecture

```mermaid
flowchart LR
    A["๐Ÿค– LLM client<br/>(Claude ยท Cursor)"] -->|MCP / Streamable HTTP<br/>127.0.0.1:8000| B
    B["๐Ÿ•ท๏ธ zap-mcp-server<br/>67 tools ยท target policy<br/>pooled async client"] -->|internal docker net<br/>http://zap:8080| C
    C["๐Ÿ›ก๏ธ zap-daemon<br/>OWASP ZAP 2.17.0<br/>API restricted to private ranges"]
    B <-->|shared volume<br/>/zap/wrk| D["๐Ÿ“ zap-wrk<br/>reports ยท imports"]
    C <-->|shared volume<br/>/zap/wrk| D
    B -.->|refuses metadata / out-of-scope| X["โ›” blocked targets"]
```

- The MCP server reaches ZAP over the internal Docker network.
- Both ports are published on **`127.0.0.1` only** โ€” nothing is world-exposed.
- Every state-changing ZAP action requires the API key.
- **Shared `/zap/wrk` volume.** ZAP resolves every file path in its API against
  its own filesystem, so both containers mount the same volume at the same path.
  This is what makes file-based tools work end to end: the agent stages an input
  (HAR, OpenAPI spec, URL list, automation plan) for ZAP to read, and reads back
  reports ZAP writes. A one-shot `wrk-init` service prepares the directory as
  `1000:1000` mode `2775` (setgid) before ZAP starts; the MCP server joins gid
  `1000` via `group_add`, so both unprivileged users can read and write there and
  new files inherit the shared group automatically.

The optional all-in-one image collapses this into one container: same components
and the same MCP endpoint, but ZAP is reached over loopback and `/zap/wrk` is just
a local directory, so no volume sharing is needed. See [Quick start](#-quick-start).

---

## ๐Ÿš€ Quick start

**Fastest path โ€” one image, zero config:**

```bash
docker build -f Dockerfile.allinone -t zap-mcp-server:all-in-one .
docker run -d --name zap-mcp -p 127.0.0.1:8000:8000 zap-mcp-server:all-in-one
```

No `.env`, no API key to invent โ€” ZAP's API is bound to loopback *inside* the
container and its key is generated at startup. First boot takes ~60โ€“90s while
ZAP's JVM warms up; wait for `healthy`, then you're live at
`http://localhost:8000/mcp`:

```bash
docker inspect --format '{{.State.Health.Status}}' zap-mcp   # -> healthy
```

That green status is meaningful: the container reports `healthy` only when
**both** ZAP's API and the MCP endpoint answer.

> For real engagements, the two-container **[compose](#-compose-recommended-for-real-use)**
> setup is recommended โ€” independently upgradable ZAP, and restarting the server
> won't discard a live scan session.

### Which setup should I use?

| | ๐Ÿ… &nbsp;All-in-one | ๐Ÿ…‘ &nbsp;Compose *(recommended)* |
|---|---|---|
| Launch | `docker build` once, then `docker run` | `docker compose up -d` |
| Config before first run | none | create `.env`, choose an API key |
| Containers | 1 | 2 |
| ZAP API key | auto-generated per container | you supply it |
| ZAP proxy/API reachable | no โ€” loopback-only in the container | yes, on `127.0.0.1` |
| Restart MCP without losing scan state | โœ— | โœ“ |
| Upgrade ZAP independently | โœ— rebuild | โœ“ change the image tag |
| Best for | first look, demos, CI throwaways | real engagements, long-running work |

<details>
<summary><b>๐Ÿ… &nbsp;All-in-one โ€” how it stays honest, and overrides</b></summary>

<br>

Running two processes in one container is normally an anti-pattern, so the parts
that usually break are handled explicitly:

- **s6-overlay is PID 1** โ€” reaps orphaned processes and forwards signals, rather
  than backgrounding with `&` and leaking zombies.
- **Ordered startup** โ€” services run `apikey โ†’ zap โ†’ zap-ready โ†’ mcp-server`. The
  `zap-ready` gate polls ZAP's API and only then starts the MCP server, which is
  the single-container equivalent of compose's `depends_on: service_healthy`.
- **No degraded state** โ€” if ZAP never becomes ready the container exits non-zero
  instead of serving an MCP endpoint with a dead scanner behind it.
- **Self-healing** โ€” if either process dies, s6 restarts it.
- **Clean shutdown** โ€” `docker stop` exits 0 in ~3s; s6's grace periods are kept
  under Docker's 10s SIGKILL deadline.
- **Unprivileged** โ€” s6 sets up as root, then both ZAP and the MCP server drop to
  uid 1000. Neither runs as root.
- **Pinned supervisor** โ€” s6-overlay is fetched by version and checksum-verified
  at build time.

Overridable via `-e`: `ZAP_API_KEY` (use a fixed key), `ZAP_STARTUP_TIMEOUT`
(readiness budget, default 300s), plus the usual `ZAP_TARGET_ALLOWLIST` /
`ZAP_BLOCK_PRIVATE_TARGETS` scope controls.

</details>

> [!NOTE]
> The all-in-one image does **not** expose ZAP's HTTP proxy or API, so you cannot
> point a browser through it or open ZAP's own tooling. Use compose if you need
> that. Trade-offs worth knowing before you rely on it: a ZAP upgrade means
> rebuilding the image, restarting the MCP server also restarts ZAP and discards
> live scan state, and one health signal covers both processes so it is less
> obvious which half failed.

### ๐Ÿ…‘ Compose (recommended for real use)

```bash
# 1. Set your secret (never committed)
cp .env.example .env
#    edit .env -> ZAP_API_KEY=<long-random-value>

# 2. Launch (ZAP starts, becomes healthy, then the MCP server starts)
docker compose up -d --build

# 3. Confirm
docker compose ps                    # both services: healthy
docker compose logs -f mcp-server    # "Registered 67 MCP tools"
```

### Connect your client

Either option serves the same endpoint, so the client config is identical:

```json
{
  "mcpServers": {
    "owasp-zap": { "url": "http://localhost:8000/mcp" }
  }
}
```

---

## ๐Ÿ”‘ Authenticated scanning in 6 steps

The capability most ZAP wrappers skip โ€” scan behind a login:

```text
1. create_context โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ scope it (include app, exclude /logout)
2. set_authentication_method โ”€โ”€โ–บ e.g. formBasedAuthentication
3. set_logged_in / out_indicator
4. new_user โ†’ set_user_credentials โ†’ set_user_enabled
5. set_forced_user (+ mode)  โ”€โ”€โ–บ keeps the session alive during scans
6. spider_scan_as_user โ†’ active_scan_as_user
```

Prefer repeatable runs? Drive the whole pipeline with a ZAP
[Automation Framework](https://www.zaproxy.org/docs/automate/automation-framework/)
plan via `zap_run_automation_plan`.

---

## ๐Ÿงฐ The 67 tools

Tools marked ๐ŸŽฏ are gated by the target-authorization policy. Every tool returns
a uniform envelope: `{"status":"success",...}` or
`{"status":"error","code":...,"retryable":...}`.

<details>
<summary><b>Core &amp; crawling</b> (15)</summary>

| Group | Tools |
|---|---|
| Core & health | `get_version`, `access_url` ๐ŸŽฏ, `get_sites`, `get_urls`, `new_session` |
| Spider | `spider_scan` ๐ŸŽฏ, `spider_scan_as_user` ๐ŸŽฏ, `spider_status`, `spider_results`, `spider_stop` |
| AJAX spider | `ajax_spider_scan` ๐ŸŽฏ, `ajax_spider_scan_as_user` ๐ŸŽฏ, `ajax_spider_status`, `ajax_spider_results`, `ajax_spider_stop` |

</details>

<details>
<summary><b>Scanning &amp; findings</b> (20)</summary>

| Group | Tools |
|---|---|
| Active scan | `active_scan` ๐ŸŽฏ, `active_scan_as_user` ๐ŸŽฏ, `active_scan_status`, `active_scan_progress`, `active_scan_stop`, `active_scan_pause`, `active_scan_resume`, `list_scan_policies` |
| Passive scan | `passive_scan_status`, `passive_scan_set_enabled`, `passive_scan_clear_queue` |
| Findings & triage | `get_alerts`, `get_alerts_summary`, `get_alert_details`, `get_number_of_alerts`, `delete_all_alerts`, `add_alert_filter`, `list_alert_filters`, `apply_alert_filters`, `retest_alerts` |

</details>

<details>
<summary><b>Auth, context, imports &amp; reports</b> (32)</summary>

| Group | Tools |
|---|---|
| Context & scope | `create_context`, `include_in_context`, `exclude_from_context`, `list_contexts`, `get_context`, `export_context`, `import_context` |
| Authentication | `get_auth_methods`, `get_auth_method_config_params`, `set_authentication_method`, `get_authentication_method`, `set_logged_in_indicator`, `set_logged_out_indicator` |
| Users | `new_user`, `set_user_credentials`, `set_user_enabled`, `list_users`, `get_user` |
| Forced user | `set_forced_user`, `set_forced_user_mode`, `get_forced_user`, `is_forced_user_mode_enabled` |
| Imports & automation | `import_openapi_url`, `import_openapi_file`, `import_graphql_url`, `import_har`, `import_urls`, `run_automation_plan`, `automation_plan_progress` |
| Reports | `list_report_templates`, `report_template_details`, `generate_report` |

</details>

> All tool names are prefixed with `zap_` (e.g. `zap_active_scan`).

---

## ๐Ÿ›ก๏ธ Security model

| Control | Behavior |
|---|---|
| **API key** | Required. Server won't start without `ZAP_API_KEY` (dev override: `ZAP_ALLOW_INSECURE=true`). Never logged by this server: `httpx` request logging is suppressed because the ZAP API takes the key as a `?apikey=` query parameter. ZAP itself still echoes it once in its own startup line. |
| **Metadata block** | `169.254.169.254`, `metadata.google.internal`, etc. are **always** refused. Not configurable. |
| **Scope allowlist** | `ZAP_TARGET_ALLOWLIST` pins attackable hosts to your engagement. |
| **Private-range block** | `ZAP_BLOCK_PRIVATE_TARGETS=true` refuses internal targets. |
| **Localhost binding** | MCP `:8000` and ZAP `:8080` publish on `127.0.0.1` only. |
| **Response caps** | Large lists are bounded (`max_response_items`) with `truncated` metadata. |

> [!IMPORTANT]
> The MCP endpoint has **no built-in auth** and can launch attacks. To expose it
> beyond localhost, set `MCP_BIND=0.0.0.0` **and** front it with an
> authenticating reverse proxy.

<details>
<summary><b>โš™๏ธ Configuration reference</b></summary>

| Variable | Default | Description |
|---|---|---|
| `ZAP_API_KEY` | *(required)* | Must match the ZAP daemon's key. |
| `ZAP_BASE_URL` | `http://zap:8080` | ZAP API base URL. |
| `ZAP_TARGET_ALLOWLIST` | *(empty)* | Comma-separated allowed host suffixes. |
| `ZAP_BLOCK_PRIVATE_TARGETS` | `false` | Refuse private/loopback targets. |
| `MCP_BIND` | `127.0.0.1` | Host interface the MCP port binds to. |
| `REQUEST_TIMEOUT` / `CONNECT_TIMEOUT` | `60` / `10` | HTTP timeouts (s). |
| `ZAP_MAX_RETRIES` / `ZAP_RETRY_BACKOFF` | `2` / `0.5` | Retry policy. |
| `ZAP_MAX_RESPONSE_ITEMS` | `500` | Cap on returned list items. |
| `ZAP_REPORT_DIR` | `/zap/wrk` | Report output dir. Must be on the volume shared with ZAP. |
| `ZAP_STARTUP_TIMEOUT` | `300` | All-in-one only: seconds to wait for ZAP's API before failing the container. |

</details>

---

## ๐Ÿงช Development

```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements-dev.txt
pytest -q          # policy, config, error envelope, tool behavior
```

CI runs `py_compile` + `pytest` on every push to `main`.

---

## โš–๏ธ Responsible use

Active scanning is an attack. In most jurisdictions, testing systems without
permission is illegal. Before you scan:

- โœ… Confirm the target is **in scope** for an engagement you're authorized to run
- โœ… Pin scope with `ZAP_TARGET_ALLOWLIST`; consider `ZAP_BLOCK_PRIVATE_TARGETS=true`
- โœ… Use a long random `ZAP_API_KEY`; never commit `.env`

Cloud metadata endpoints are always refused and this cannot be overridden.

---

## ๐Ÿ“„ License

[MIT](LICENSE) ยฉ Neeraj829784 โ€” swap the `LICENSE` file for Apache-2.0 if you
want an explicit patent grant.

<div align="center"><sub>Built for authorized penetration testing &amp; bug-bounty work. Hack responsibly. ๐Ÿ›ก๏ธ</sub></div>