Skip to content
Conduit

mcp server

CyberSec-MCP-Dojo

Model-Context-Provider - Research Project - How to secure and develop a MCP Server

BeagleByte/CyberSec-MCP-Dojocommunitylistedunreviewed

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`)
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "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, 2026

CyberSec-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

Toolnmap flagsPurpose
login—Exchange credentials for an RS256 JWT (the only ungated tool)
discover_hostsnmap -sn -n <target>Host discovery only, no port scan
find_open_portsnmap --open <target>Show only open ports
scan_without_pingnmap -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 over requests; simulates a remote API consumer
  • webapp/ — 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 vault
  • scripts/mint_token.py — dev helper to mint a JWT out-of-band
  • scripts/gen_https_cert.sh — regenerate the self-signed HTTPS certificate
  • docs/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 the runsc runtime registered, otherwise run directly on the host
  • off — 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)

  1. Docker installed and your user in the docker group (sudo usermod -aG docker $USER, then re-login).
  2. runsc (the gVisor binary), checksum-verified, installed system-wide.
  3. The runsc runtime registered with Docker via /etc/docker/daemon.json.
  4. 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

  1. Secrets at rest — data/vault.db is a SQLCipher database (AES-256, page-level encryption) holding the JWT signing private key and the login credentials. It is unlocked with the passphrase in keys/vault_passphrase (0600, gitignored). No plaintext secrets on disk.
  2. Authentication — the ungated login tool 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.
  3. Tool calls — every nmap tool requires target and token. 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 an HttpOnly + SameSite=Strict cookie 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/, and data/ are gitignored — no secrets or key material are ever committed.
  • The HTTPS endpoint binds 127.0.0.1 by 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.sh or your own) for anything beyond local testing.
  • The JWT signing key never leaves the server; clients authenticate via the login tool only.
  • Wrong vault passphrase or corrupt DB shows up as DatabaseError ("file is not a database") — that's the expected symptom.
  • login is rate limited: 3 failed attempts block the account for 5 minutes. The state lives in the vault, so it survives server restarts.
  • vault_bootstrap.py refuses to overwrite an existing vault without --force (use with care: it re-keys the vault).

License

MIT — see LICENSE.

↑↓ navigate openesc close