mcp server
CyberSec-MCP-Dojo
Model-Context-Provider - Research Project - How to secure and develop a MCP Server
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`)
{
"mcpServers": {
"cybersec-mcp-dojo": {
"command": "uvx",
"args": [
"cybersec-mcp-dojo"
]
}
}
}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 23, 2026CyberSec-MCP-Dojo
Personal Comment: This small project is a research prooject for a MCP server. I choose nmap as tool usage. The research target cyber security harness for MCP server. This is my first draft created with DeepSeek V4 Pro. Token used around 40 million, tokens used not only for coding also for error handling, docs, questions to architecture and security measures. Total costs $1,70. For me is this development a starting point and blueprint to create secure MCP server and research for security weaknesses or vulnerabilities. DeepSeek V4 pro was very helpful and fast in execution. Knowledge seems to be uptodate how to create a MCP.
Be aware this is a research project, and the amount of users, who attempt to access this tool is limited!
Documentation
An MCP server exposing nmap as tools, gated by RS256 JWT authentication, with secrets stored in an encrypted SQLCipher vault. Built as a cybersecurity dojo: learn MCP servers, JWT security, and local secret storage in one small project.
What it includes
| Tool | nmap flags | Purpose |
|---|---|---|
login | — | Exchange credentials for an RS256 JWT (the only ungated tool) |
discover_hosts | nmap -sn -n <target> | Host discovery only, no port scan |
find_open_ports | nmap --open <target> | Show only open ports |
scan_without_ping | nmap -Pn <target> | Scan targets with ICMP disabled |
Plus:
cybersec_mcp_dojo/— the server package (strictly OOP: one class per concern)http_client.py— HTTP client speaking raw JSON-RPC overrequests; simulates a remote API consumerwebapp/— Flask web client simulator: login page, JWT cookie + CSRF, dashboard with auto-discovered tool forms, documentation tab (mermaid)scripts/vault_bootstrap.py— one-time setup of the encrypted secret vaultscripts/mint_token.py— dev helper to mint a JWT out-of-bandscripts/gen_https_cert.sh— regenerate the self-signed HTTPS certificatedocs/endpoint_diagram.md— endpoint flow diagram
Deliberately excluded: root-requiring packet-crafting options
(-S, --spoof-mac, -D) are not exposed, for MCP security.
Sandboxing with gVisor (optional)
By default nmap scans run directly on the host. Optionally they can run
inside a gVisor sandbox (runsc via Docker): every syscall of the nmap
process goes through gVisor's user-space kernel instead of the host kernel,
so a compromised scan can't touch the host kernel. SANDBOX_MODE controls
it:
auto(default) — use gVisor when Docker has therunscruntime registered, otherwise run directly on the hostoff— always run directly on the host- anything else (e.g.
on) — always use the sandbox and fail if it's missing
Requirements (one-time, needs sudo)
- Docker installed and your user in the
dockergroup (sudo usermod -aG docker $USER, then re-login). - runsc (the gVisor binary), checksum-verified, installed system-wide.
- The runsc runtime registered with Docker via
/etc/docker/daemon.json. - The nmap image built once.
Enabling the sandbox
# 1. download + verify runsc (Google's official release)
curl -fsSL -o ~/.local/bin/runsc https://storage.googleapis.com/gvisor/releases/release/latest/x86_64/runsc
curl -fsSL https://storage.googleapis.com/gvisor/releases/release/latest/x86_64/runsc.sha512 | cut -d' ' -f1 > /tmp/runsc.sha512
sha512sum ~/.local/bin/runsc | cut -d' ' -f1 | diff /tmp/runsc.sha512 -
chmod +x ~/.local/bin/runsc
# 2. install system-wide + register the containerd shim
sudo install -o root -g root -m 0755 ~/.local/bin/runsc /usr/local/bin/runsc
sudo /usr/local/bin/runsc install
# 3. register the runtime with Docker (adds an option, changes nothing existing)
echo '{"runtimes":{"runsc":{"path":"/usr/local/bin/runsc","runtimeArgs":["--platform=ptrace"]}}}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
# 4. build the nmap image the sandbox runs
docker build -t cybersec-mcp-dojo-nmap:latest .
Verify gVisor is active
docker info --format '{{json .Runtimes}}' | grep runsc # runtime registered
docker run --rm --runtime=runsc alpine:3 cat /proc/version # gVisor kernel, not "Linux ... GNU/Linux"
The MCP server detects this automatically (SANDBOX_MODE=auto): from now on
scan output starts with Starting Nmap 7.95 (the alpine image's nmap)
instead of the host's nmap version. To force the sandbox (fail loudly when
it's missing), set SANDBOX_MODE=on in .env.
How it works
- Secrets at rest —
data/vault.dbis a SQLCipher database (AES-256, page-level encryption) holding the JWT signing private key and the login credentials. It is unlocked with the passphrase inkeys/vault_passphrase(0600, gitignored). No plaintext secrets on disk. - Authentication — the ungated
logintool checks credentials and returns a 1-hour RS256 JWT. The server signs with the private key from the vault and verifies with the public key (keys/jwt_public.pem). Clients hold no key material. - Tool calls — every nmap tool requires
targetandtoken. The server validates the JWT first, then the target (rejecting empty values, nmap-style flags, and shell metacharacters), then runs nmap with--before the target and returns the raw output. When sandboxing is active, nmap executes inside a gVisor container instead of directly on the host.
Sequence diagram
sequenceDiagram
participant B as Browser
participant W as Flask webapp
participant S as MCP server (HTTPS)
participant V as SQLCipher vault
participant N as nmap (gVisor)
B->>W: GET /login (session issues CSRF token)
B->>W: POST /login + csrf_token
W->>W: validate CSRF token
W->>S: tools/call login(username, password)
S->>V: check credentials (rate limited)
S-->>W: RS256 JWT (1h)
W-->>B: Set-Cookie dojo_jwt (HttpOnly, SameSite=Strict) + redirect /dashboard
B->>W: GET /dashboard (JWT cookie)
W->>S: tools/list
S-->>W: tool schemas (forms auto-built)
B->>W: POST /dashboard/run/tool_name + csrf_token
W->>W: validate CSRF + JWT cookie
W->>S: tools/call tool_name(target, token)
S->>S: validate JWT with public key
alt invalid / expired token
S-->>W: Unauthorized
else valid token
S->>S: validate target
S->>N: nmap <flags> -- target (gVisor sandbox)
N-->>S: raw nmap output
S-->>W: output
W-->>B: rendered output
end
The raw HTTP client (http_client.py) skips the webapp middleman and speaks
the same JSON-RPC directly: login → JWT → tools/call with token.
Endpoint diagram
flowchart TB
subgraph Browser["Browser"]
B[Login / Dashboard / Docs]
end
subgraph Webapp["Flask webapp (webapp/)"]
W[CsrfProtector + JWT cookie]
end
subgraph Client["http_client.py (direct API consumer)"]
C[DojoMCPHttpClient]
end
B -->|"HTTPS, csrf_token + dojo_jwt cookie"| W
W -->|"JSON-RPC over HTTPS POST /mcp"| S["FastMCP server"]
C -->|"JSON-RPC over HTTPS POST /mcp"| S
S --> L["login(username, password)<br/>ungated, rate limited"]
L --> ISSUE["check credentials, sign RS256 JWT<br/>private key from vault"]
ISSUE -->|token| W
ISSUE -->|token| C
S --> D["discover_hosts<br/>nmap -sn -n -- target"]
S --> P["find_open_ports<br/>nmap --open -- target"]
S --> N["scan_without_ping<br/>nmap -Pn -- target"]
D & P & N --> A{"validate JWT<br/>RS256, public key"}
A -->|"invalid"| E1["Unauthorized"]
A -->|valid| V{"validate target"}
V -->|"empty / flag / metachar"| E2["validation error"]
V -->|valid| R["run nmap via runner"]
R --> G["gVisor sandbox<br/>(docker --runtime=runsc)<br/>or host fallback"]
G --> NM["nmap binary"]
Setup
Prerequisites: Python 3.12, nmap on PATH, and a virtualenv or conda env.
# 1. create an env and install the package
conda create -n cybersec-mcp-dojo python=3.12
conda activate cybersec-mcp-dojo
pip install -e .
# 2. create .env (non-secret settings only)
cat > .env <<EOF
NMAP_PATH=$(which nmap)
JWT_PUBLIC_KEY_PATH=keys/jwt_public.pem
VAULT_DB_PATH=data/vault.db
VAULT_KEY_PATH=keys/vault_passphrase
EOF
# 3. create the encrypted vault (generates keys + credentials)
python scripts/vault_bootstrap.py
Step 3 prints the login credentials once — save them. It creates the
SQLCipher vault, the RSA keypair, and the vault passphrase. Migration from a
previous plaintext setup (private key file + MCP_USER/MCP_PASSWORD in
.env) happens automatically when those are present.
Run
# one command: MCP server (HTTPS) + Flask simulator
python scripts/dev_up.py # Ctrl+C stops both
# or separately:
cybersec-mcp-dojo # MCP server, https://127.0.0.1:14000
cybersec-mcp-dojo-web # web simulator, http://127.0.0.1:5000
# HTTP client (raw JSON-RPC over requests — simulates a remote API consumer)
python http_client.py --insecure list-tools
python http_client.py --cacert keys/https_cert.pem list-tools # or verify properly
python http_client.py --insecure login --username alice --password <secret>
python http_client.py --insecure discover-hosts 192.168.25.0/24 --username alice --password <secret>
python http_client.py --insecure find-open-ports 127.0.0.1 --token <JWT>
The server exposes streamable HTTP over TLS at /mcp; every tool call is
an HTTP POST carrying a JSON-RPC message. The certificate is a self-signed
one generated by scripts/gen_https_cert.sh (keys/https_cert.pem,
keys/https_key.pem) — pass --insecure to skip verification, or
--cacert keys/https_cert.pem to verify against it.
MCP_TRANSPORT=stdio in .env switches back to stdio (useful with MCP hosts
like Claude/opencode that spawn the server themselves); MCP_HOST,
MCP_PORT, MCP_SSL_CERT_PATH, MCP_SSL_KEY_PATH control the bind address
and TLS (unset both cert paths to serve plain HTTP).
Using it as a library:
from http_client import DojoMCPHttpClient
client = DojoMCPHttpClient("https://127.0.0.1:8765", verify="keys/https_cert.pem")
token = client.login("alice", "<secret>")
output = client.find_open_ports("127.0.0.1") # uses the stored token
Web client simulator
A Flask app that simulates a browser-based API consumer:
cybersec-mcp-dojo-web # serves http://127.0.0.1:5000
- Login (
/login) — exchanges credentials for a JWT; the token is stored in anHttpOnly+SameSite=Strictcookie and every form carries a per-session CSRF token. - Dashboard (
/dashboard/) — the MCP tools are discovered from the server (tools/list), so a new tool on the server appears here automatically. Each card is a form that runs the tool and shows the raw output. - Documentation (
/docs/) — renders this README and the endpoint diagrams (mermaid) in a separate tab.
Configuration via environment variables: DOJO_SERVER_URL (default
https://127.0.0.1:14000), DOJO_CACERT (default keys/https_cert.pem),
DOJO_INSECURE=1 to skip cert verification, DOJO_WEB_PORT (default 5000).
The Flask secret key is generated once into data/flask_secret (gitignored).
To extend as a developer: add a tool to the server — the dashboard picks it
up. For custom rendering of a specific tool, add a webapp/templates/tools/
override or extend webapp/dashboard.py; the tool registry lives in
webapp/mcp_backend.py.
Security notes
.env,keys/, anddata/are gitignored — no secrets or key material are ever committed.- The HTTPS endpoint binds
127.0.0.1by default — expose it only if you understand the risk (JWT + rate limiting are the only auth in front of nmap). - TLS uses a self-signed certificate by default; replace it with a real CA
cert (
scripts/gen_https_cert.shor your own) for anything beyond local testing. - The JWT signing key never leaves the server; clients authenticate via the
logintool only. - Wrong vault passphrase or corrupt DB shows up as
DatabaseError("file is not a database") — that's the expected symptom. loginis rate limited: 3 failed attempts block the account for 5 minutes. The state lives in the vault, so it survives server restarts.vault_bootstrap.pyrefuses to overwrite an existing vault without--force(use with care: it re-keys the vault).
License
MIT — see LICENSE.