CTFd MCP Server
# CTFd MCP Server
<!-- mcp-name: io.github.MrJamescot/ctfd-mcp-server -->
[](https://pypi.org/project/ctfd-mcp-server/)
[](https://pypi.org/project/ctfd-mcp-server/)
[](https://hub.docker.com/r/jamescot/ctfd-mcp-server)
[](LICENSE)
[](https://github.com/MrJamescot/ctfd-mcp-server)
A Model Context Protocol (MCP) server for interacting with any **CTFd v3** instance.
It lets AI tools (Claude Desktop, Cursor, custom agents, ...) authenticate, list
and inspect challenges, submit flags, and query instance state through a stable,
type-safe interface.
The project ships two interfaces built on the **same** client library:
- **MCP tools** (primary) — `ctfd_mcp_server.py`, used over `stdio` or `sse`.
- **REST API** (optional) — `server/main.py`, a FastAPI mirror for scripting,
debugging, and Docker deployments.
```
┌──────────────────────────────────────────────┐
AI agent / MCP │ FastMCP (MCP tools) │
client ────────►│ set_token · login · challenges · submit_flag │
└──────────────────┬───────────────────────────┘
│ shared client
┌──────────────────▼───────────────────────────┐
curl / scripts ─►│ FastAPI REST (/api/v1/...) (optional) │
└──────────────────┬───────────────────────────┘
│
┌──────────────────▼───────────────────────────┐
│ server.ctfd_client.CTFdClient │
│ └─ gateway.py (HTTP, auth, timeouts) │
└──────────────────┬───────────────────────────┘
│ HTTPS / HTTP
┌─────▼─────┐
│ CTFd │
└───────────┘
```
Credentials (token / cookie / password) live **in memory only** and are never
echoed in tool output, written to `server_state.json`, or logged.
---
## Features
- **Multiple authentication modes** — API token, session cookie, or username/password
form login (with CSRF handling).
- **Rich challenge queries** — paginated listing with `category`, `search` (name),
and `solved`/`unsolved` filters, plus per-challenge detail retrieval.
- **Safe flag submission** — requires an explicit `confirm=True`, returns clear
success/failure, and surfaces rate-limit errors. Flags are never logged.
- **Instance introspection** — public instance info, health check, and an
authentication-status tool that reveal no secrets.
- **Consistent structured errors** — `AuthenticationError`, `CTFdAPIError`,
`ChallengeNotFoundError`, `SubmissionError`, `ValidationError`,
`ConfigurationError`.
- **Pagination by default** — one page of challenges per call; no accidental
full-dump downloads.
- **Hardened HTTP** — configurable timeouts, one safe retry for idempotent `GET`s,
no retries for `POST`s (no duplicate submissions), strict JSON/content parsing.
- **REST + MCP from one codebase** — identical behaviour on both interfaces.
- **No hardcoded instance** — `BASE_URL` is validated and configurable at startup
and at runtime.
---
## Installation
Requires Python 3.10+.
The fastest way is to install from **[PyPI](https://pypi.org/project/ctfd-mcp-server/)**:
```bash
pip install ctfd-mcp-server
# MCP stdio server with env config:
CTFD_BASE_URL=https://ctf.example.com CTFD_ADMIN_TOKEN=ctfd_... ctfd-mcp
# optional REST interface:
ctfd-rest
```
For MCP clients, point your config at the packaged entry point:
```jsonc
{
"mcpServers": {
"ctfd-mcp": {
"command": "ctfd-mcp",
"env": {
"CTFD_BASE_URL": "https://demo.ctfd.io",
"CTFD_ADMIN_TOKEN": "ctfd_..."
}
}
}
}
```
Or run from source:
```bash
git clone https://github.com/MrJamescot/ctfd-mcp-server.git
cd ctfd-mcp-server
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then edit .env
```
### Configure
| Variable | Default | Meaning |
| ---------------------- | --------------------- | ---------------------------------------------- |
| `CTFD_BASE_URL` | *(empty)* | CTFd instance root, e.g. `https://ctf.example.com` (without `/api/v1`) |
| `CTFD_ADMIN_TOKEN` | *(empty)* | API token (preferred auth) |
| `CTFD_SESSION_COOKIE` | *(empty)* | Session cookie, e.g. `session=abc...` |
| `CTFD_USERNAME` | *(empty)* | Username for form login |
| `CTFD_PASSWORD` | *(empty)* | Password for form login |
| `CTFD_HTTP_TIMEOUT` | `15` | Per-request HTTP timeout (seconds) |
| `CTFD_HTTP_MAX_REDIRECTS` | `5` | Max redirects followed per request |
| `CTFD_STATE_FILE` | `~/.local/state/ctfd-mcp/server_state.json` | File used to cache auth state |
| `CTFD_MCP_TRANSPORT` | `stdio` | MCP transport: `stdio` or `sse` |
| `MCP_HOST` / `MCP_PORT`| `127.0.0.1` / `8000` | REST server bind settings (loopback by default) |
| `CTFD_API_TOKEN` | *(empty)* | Optional bearer token protecting the optional REST API (`/api/v1/*`) |
| `CTFD_ALLOW_PRIVATE_IPS` | `false` | Allow connections to private/loopback/metadata addresses (e.g. local CTFd test instances) |
| `CTFD_DOWNLOAD_DIR` | `./downloads` | Directory where challenge attachments are saved by `download_file` |
| `CTFD_PERSIST_SECRETS` | `false` | ⚠ Strongly discouraged: write secrets to disk |
> `CTFD_BASE_URL` may include a path prefix (e.g. `https://host/ctfd`); the client
> appends `/api/v1` automatically.
---
## Running the MCP Server
Most MCP clients launch the server themselves via a `command`/`args` config.
For that, your client config should reference **`ctfd_mcp_server.py`**:
```jsonc
// e.g. Claude Desktop / mcp.json
{
"mcpServers": {
"ctfd-mcp": {
"command": "python",
"args": ["/path/to/ctfd-mcp-server/ctfd_mcp_server.py"],
"env": {
"CTFD_BASE_URL": "https://demo.ctfd.io",
"CTFD_ADMIN_TOKEN": "ctfd_..."
}
}
}
}
```
Manual launch:
```bash
# stdio (default) — used by MCP clients
python ctfd_mcp_server.py
# SSE — expose over HTTP for remote/Docker use
CTFD_MCP_TRANSPORT=sse python ctfd_mcp_server.py # http://127.0.0.1:8000/sse
```
### MCP tools
| Tool | Parameters | Description |
| ----------------- | ----------------------------------------------------------------------- | ----------- |
| `set_base_url` | `url` | Point the server at a CTFd instance |
| `set_token` | `token` | Adopt an API token (memory only) |
| `set_cookie` | `cookie` | Adopt a session cookie (memory only) |
| `login` | `username`, `password` | Form login; keeps the session cookie |
| `challenges` | `category`, `search`, `solved`, `page`, `per_page` | Paginated challenge list with filters |
| `challenge` | `identifier` (id **or** name) | Full detail of one challenge |
| `submit_flag` | `flag`, `challenge_name`/`challenge_id`, `confirm` | Submit a flag (requires `confirm=True`) |
| `download_file` | `file_url`, `dest_dir` | Download a challenge attachment over the CTFd `/files/…` route |
| `unlock_hint` | `hint_id` | Unlock and read a hint (paid hints cost points) |
| `scoreboard` | — | Public scoreboard standings |
| `progress` | — | Your score + solved challenges |
| `instance_info` | — | Safe public instance metadata |
| `auth_status` | — | Auth mode + validity (no secrets) |
| `health` | — | Reachability, API and auth checks |
> With token auth, requests are sent with `Content-Type: application/json`
> (CTFd only honours `Authorization: Token ...` on JSON requests). With
> cookie/credentials auth, state-changing requests echo the session CSRF nonce
> as the `CSRF-Token` header, which is re-fetched from the site after login.
Tools return JSON text. Errors are structured, e.g.:
```json
{ "error": { "type": "ChallengeNotFoundError", "message": "Challenge '99' not found (or not visible)." } }
```
---
## Running the REST API (optional)
```bash
python scripts/run_local.sh # reads .env, default http://127.0.0.1:8000
# or
uvicorn server.main:app --host 0.0.0.0 --port 8000
```
Endpoints (all under `/api/v1`):
| Method | Path | Description |
| ------ | ----------------------------- | ---------------------------------------- |
| POST | `/set_base_url` | Validate & set the CTFd base URL |
| POST | `/set_token` | Set API token |
| POST | `/set_cookie` | Set session cookie |
| POST | `/set_creds` | Store username/password for later login |
| POST | `/login` | Form login (session cookie) |
| GET | `/challenges` | Paginated + filtered challenge list |
| GET | `/challenges/{id-or-name}` | Challenge detail |
| POST | `/submit` | Submit a flag (`confirm: true` required) |
| POST | `/download` | Download a challenge attachment (body: `file_url`) |
| POST | `/unlock_hint` | Unlock and read a hint (body: `hint_id`) |
| GET | `/scoreboard` | Public standings |
| GET | `/progress` | Your score and solves |
| GET | `/instance_info` | Public instance metadata |
| GET | `/auth_status` | Auth mode + validity |
| GET | `/health` | Health check |
> The optional REST API can be protected with an extra bearer token: set
> `CTFD_API_TOKEN`, and requests to `/api/v1/*` will require
> `Authorization: Bearer <token>`. The server binds to loopback by default
> (`MCP_HOST=127.0.0.1`).
---
## Docker
A ready-made image is published on **[Docker Hub](https://hub.docker.com/r/jamescot/ctfd-mcp-server)**:
```bash
docker run --rm -p 8000:8000 \
-e CTFD_BASE_URL=https://ctf.example.com \
-e CTFD_ADMIN_TOKEN=ctfd_... \
jamescot/ctfd-mcp-server
```
Or build locally (REST mode):
```bash
docker build -t ctfd-mcp .
docker run --rm -p 8000:8000 \
-e CTFD_BASE_URL=https://ctf.example.com \
-e CTFD_ADMIN_TOKEN=ctfd_... \
ctfd-mcp
```
`docker compose up --build` also works (REST API on `http://localhost:8000`).
To run the **MCP SSE** server in a container instead:
```bash
docker run --rm -it -e CTFD_BASE_URL=https://ctf.example.com ctfd-mcp python ctfd_mcp_server.py
# stdio on the attached terminal
```
---
## Usage examples
### MCP (agent)
```
1. set_base_url url="https://ctf.example.com"
2. set_token token="ctfd_..."
3. challenges category="web", solved=false, page=1, per_page=25
4. challenge identifier="3"
5. submit_flag flag="flag{...}", challenge_id=3, confirm=true
```
### REST
```bash
curl -X POST http://localhost:8000/api/v1/set_base_url \
-H 'Content-Type: application/json' -d '{"url":"https://ctf.example.com"}'
curl -X POST http://localhost:8000/api/v1/set_token \
-H 'Content-Type: application/json' -d '{"token":"ctfd_..."}'
curl 'http://localhost:8000/api/v1/challenges?search=web&solved=false&per_page=10'
curl -X POST http://localhost:8000/api/v1/submit \
-H 'Content-Type: application/json' \
-d '{"challenge_id":3,"flag":"flag{...}","confirm":true}'
curl http://localhost:8000/api/v1/health
```
See [`DEMO.md`](DEMO.md) for a complete walkthrough and
[`examples/`](examples) for curl and Python snippets.
---
## Development & testing
```bash
pip install -r requirements-dev.txt
python -m pytest -q # 76 unit tests, mocked CTFd API (no network)
ruff check server ctfd_mcp_server.py tests
```
The test suite mocks the CTFd API (`tests/conftest.py::FakeGateway`), so unit
tests run offline.
### Integration testing against a real CTFd
Run a local CTFd for live tests (recommended over the shared demo instance,
which serves HTML on public auth-gated routes):
```bash
git clone https://github.com/CTFd/CTFd.git /tmp/CTFd
docker compose -f /tmp/CTFd/docker-compose.yml up
# create a user/challenge, then:
CTFD_BASE_URL=http://localhost:8000 python ctfd_mcp_server.py
CTFD_BASE_URL=http://localhost:8000 uvicorn server.main:app --port 8001
curl http://localhost:8001/api/v1/health
```
---
## Security considerations
- **Credentials are memory-only.** By default nothing is written to
`server_state.json`. Enabling `CTFD_PERSIST_SECRETS` is discouraged.
- **Secrets are never echoed.** Tool and API responses, error messages and logs
redact tokens, cookies, passwords and flags (`server/utils.py`).
- **Every tool validates its input** before touching the network (`set_base_url`
requires an absolute `http(s)` URL, `submit_flag` requires `confirm=True`,
etc.).
- **Controlled retries.** Only idempotent `GET` requests are retried (once).
Flag submissions are never automatically replayed.
- **Trust model.** The server is a local/dev tool: whoever can call its tools can
point it at any CTFd instance and (with a valid credential) read data or submit
flags. Do **not** expose the REST/SSE endpoints on an untrusted network.
- **SSRF guard.** By default connections to private / loopback / link-local /
metadata addresses are refused (`CTFD_ALLOW_PRIVATE_IPS=1` opts out). A local
container or a tool pointed at a private instance will get a clear error.
---
## Limitations
- Requires CTFd **v3+**. The `/api/v1` routes used are standard CTFd v3 API
routes.
- Form login depends on CTFd's web session flow (CSRF nonce extraction is
best-effort). **API tokens are the recommended authentication method.**
- `difficulty` is not a standard CTFd field; challenge `value` (points) is
returned instead.
- `solved` filtering uses CTFd's `solved_by_me` flag, which is only meaningful
when authenticated.
- Instance "version" is reported only when it appears in the rendered page; CTFd
has no public version API endpoint.
- **Tokens are per-instance.** CTFd redirects `/api/v1` calls to its login page
when a credential is invalid. The server detects this and reports: *"the
credential is not valid for THIS instance"* — a token from one CTFd instance
never works on another.
- When `CTFD_USERNAME`/`CTFD_PASSWORD` are configured, the server **auto-logs-in
on demand** (rotating the session cookie) whenever a call returns
unauthenticated, so expired sessions self-heal. After each login the CSRF
nonce is re-fetched from the site (a fresh nonce is required for flag
submissions over a web session).
- A teamless account sees no `/api/v1/challenges` on team-mode instances until it
joins or creates a team; the server surfaces CTFd's permission message.
---
## Contributing
Pull requests are welcome. Please:
1. Open an issue describing the change.
2. Add tests in `tests/` (mocked CTFd API preferred).
3. Run `python -m pytest -q` and `ruff check server ctfd_mcp_server.py tests`.
4. Do not ship credentials in code, tests, or commit `server_state.json` /
`.env`.
## License
[MIT](LICENSE) — repository: <https://github.com/MrJamescot/ctfd-mcp-server>TDQS
Scored across 14 tools
Most tools have clearly distinct purposes, but the authentication group (set_token, set_cookie, login, auth_status) plus health overlap around session/state checking, and challenge vs challenges differ only by singular/plural which can cause misselection. Descriptions do help disambiguate the intended usage.
A mix of conventions: verb_noun tools (set_token, submit_flag, download_file, unlock_hint) coexist with bare-noun tools (challenges, challenge, scoreboard, progress, instance_info, health). Each name is readable in isolation, but there is no single predictable pattern.
14 tools is well within a healthy range and each earns its place, covering setup, auth, challenge interaction, and status without bloat.
For a player-facing CTFd client the surface is strong: auth, listing/reading challenges, flag submission, file download, hint unlock, scoreboard, and progress. Minor gaps remain (e.g. category listing, team/teammate endpoints), but core workflows have no dead ends.