An MCP server for managing your own authorized OffSec Proving Grounds (PG Play / Practice) labs from an MCP client: list and search the catalog, inspect machine details, start/stop/revert machines, and pull a machine's walkthrough into your session for personal study.
Use this only for your own account, within OffSec's terms. Don't redistribute walkthrough content — OffSec's community rules prohibit sharing complete solutions/walkthroughs.
- Installation & Setup — step-by-step install and per-client config (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code, generic stdio/HTTP), token retrieval, troubleshooting.
- Usage & Tool Reference — every tool's arguments, example prompts, and sample outputs.
- This README — quick start, architecture, and how the live portal is wired.
git clone https://github.com/setuidloot/offsec-labs-mcp.git
cd offsec-labs-mcp
npm install
npm run build
echo "$(pwd)/dist/index.js" # the path you give your MCP clientThen add the server to your client (full per-platform steps in docs/INSTALLATION.md):
| Client | Where to configure |
|---|---|
| Claude Desktop | claude_desktop_config.json → mcpServers |
| Claude Code | claude mcp add offsec --env OFFSEC_BEARER_TOKEN=… -- node /path/dist/index.js |
| Cursor | ~/.cursor/mcp.json (or .cursor/mcp.json) → mcpServers |
| Windsurf | ~/.codeium/windsurf/mcp_config.json → mcpServers |
| VS Code | .vscode/mcp.json → servers |
Minimal config (Claude Desktop / Cursor / Windsurf shape):
{
"mcpServers": {
"offsec": {
"command": "node",
"args": ["/ABSOLUTE/PATH/TO/offsec-labs-mcp/dist/index.js"],
"env": { "OFFSEC_BEARER_TOKEN": "paste-your-token-here" }
}
}
}Run offsec_whoami first to confirm auth. New here? Start with
docs/INSTALLATION.md.
The endpoints in src/constants.ts were verified against the live portal
(https://portal.offsec.com) with a logged-in bearer token, by reading the
SPA's runtime config (/config.json), its JS bundle, and the actual JSON
responses. They are no longer guesses.
How the portal is wired (discovered):
| Concern | Source |
|---|---|
| Auth | Authorization: Bearer <token> works on all /api/* endpoints |
| REST API | https://portal.offsec.com/api/* |
| Service gateway | https://portal.offsec.com/services (from config.json → API_GATEWAY) |
| Lab catalog | A Typesense cluster. The portal fetches a scoped search key from the gateway, then queries Typesense directly. This server reproduces that flow. |
The server loads /config.json once at runtime to discover the gateway base and
Typesense node, so a deployment change is picked up automatically. Every value
can still be overridden via environment variables.
| Tool | What it does | Mutating? |
|---|---|---|
offsec_whoami |
Verify your token works; returns your profile | no |
offsec_list_labs |
List/search the catalog (default group Play); filter by OS/difficulty, paginate |
no |
offsec_get_machine_details |
OS, difficulty, points, groups, description, objectives, credentials | no |
offsec_list_walkthroughs |
List walkthrough rows (host id, category, unblocked state) | no |
offsec_get_walkthrough |
Retrieve a walkthrough's content (pass unblock: true to unlock a locked one first) |
unblock only |
offsec_unblock_walkthrough |
Unblock (unlock) a machine's walkthrough — the portal's Unlock button (POST the walkthrough id) | yes |
offsec_list_running_labs |
Discover running instances (id, IP, state) over the WebSocket | no |
offsec_start_machine |
Power a machine on (async deploy; see notes below) | yes |
offsec_stop_machine |
Power off the running instance (id auto-discovered, or explicit) | yes |
offsec_revert_machine |
Revert the running instance to a clean state (id auto-discovered, or explicit) | yes |
Every tool takes response_format: markdown (default) or json. Full argument
reference, example prompts, and sample outputs are in
docs/USAGE.md.
- machine_id accepts a numeric host id (
189), a slug (kevin-189), or a name (Kevin). Numeric id is most reliable. - instance_id (for stop/revert) is the host instance id — a different,
larger number (e.g.
32062625). See the limitation below for how to get it.
Start/stop/revert are asynchronous and verified against the live portal:
| Action | Request | Response |
|---|---|---|
| start | POST /api/host-instances/ {host:189} |
201 {"message":"Deploy request in progress"} |
| stop | PATCH /api/host-instances/<instanceId>/ {action:"stop",context_learning_unit_id:189} |
200 {"message":"Stop action in progress"} |
| revert | PATCH /api/host-instances/<instanceId>/ {action:"revert",context_learning_unit_id:189} |
200 {"message":"Revert action in progress"} |
The REST API never returns the instance id or the target IP — starting only
acknowledges the deploy, and no REST endpoint lists running instances
(GET /api/host-instances/ 405; /api/learning-units/<id>/full returns ip:null).
Those values live only on the events WebSocket (wss://portal.offsec.com/ws/events).
This server reads them itself. offsec_list_running_labs (and the auto-discovery
in stop/revert) connect to the WebSocket and run the portal's own handshake —
with the bearer token, no cookie required:
- The upgrade is unauthenticated → connect.
- Send
{"action":"sign_in","value":"<bearer token>"}→group:"auth"success. - Send
{"action":"subscribe","value":"host_actions"}→ the server immediately pushes ahost_actions/startedsnapshot of every running instance:host_instance: { id, ip, related_host:{id,name}, host_instance_state }.
So you normally don't need the instance id: offsec_stop_machine /
offsec_revert_machine with no instance_id discover the running instance
automatically (OffSec allows one concurrent machine; pass machine_id to
disambiguate). Because discovery authenticates via the sign_in message, this
path needs a bearer token specifically — either OFFSEC_BEARER_TOKEN, or a
cookie file containing refresh_token so one can be minted on demand. If the
socket rejects the token, the server refreshes once and reconnects. A second
start returns user_has_started_machine until the first is stopped, and
stop/revert return host_action_in_progress while a deploy is mid-flight — retry
once the machine reaches started.
No password is handled. Configure one of these (in your MCP client config,
or in a git-ignored .env for the smoke test):
OFFSEC_COOKIE_FILE— path to a cookie export from your logged-in browser. Recommended: it contains therefresh_tokencookie, so the server mints new bearer tokens on its own and the session keeps working indefinitely.OFFSEC_COOKIE— the same cookie string inline. Refresh works, but the rotated cookie can't be saved back, so it survives only until the next rotation.OFFSEC_BEARER_TOKEN— a static token. Simplest to grab, but it expires in about 10 hours and must then be re-pasted by hand.
Portal access tokens are short-lived (typ: at+jwt, ~10h). The SPA silently
renews them from an httpOnly refresh_token cookie, and with
OFFSEC_COOKIE_FILE set this server does the same:
POST https://portal.offsec.com/services/auth/v1/sign-in/refresh
Cookie: refresh_token=…; csrftoken=…
X-CSRFToken: <the csrftoken cookie>
→ 200 { "accessToken": "<new jwt>", "expiresIn": …, … } + a rotated refresh_token cookie
Renewal happens proactively (two minutes before exp) and reactively (one
forced retry on any 401, including the WebSocket handshake). Because the refresh
cookie rotates on every use, two things matter:
- The rotated cookie is written straight back to
OFFSEC_COOKIE_FILE(mode0600, atomic replace). If that file isn't writable the session is lost when the process exits, and the server warns on stderr. - Concurrent tool calls share a single in-flight refresh. Overlapping refreshes would each spend the same cookie and invalidate one another.
The new access token is cached alongside the cookie file
(<cookie-file>.token.json, override with OFFSEC_TOKEN_CACHE_FILE) so a
server restart reuses a still-valid token instead of burning a refresh. Editing
the cookie file while the server runs is picked up automatically — no restart
needed after re-exporting.
Refresh tokens do eventually die (expiry, signing out elsewhere). When that
happens the portal answers 401 {"code":"invalid_grant"} and tools return a
Session expired error telling you to export cookies again.
Getting the credentials. Log into portal.offsec.com and open DevTools
(F12) → Network → click any /api/... request. Copy the cookie: request
header into your cookie file (a single k=v; k=v line — this is what enables
refresh), or copy the authorization: value after Bearer for the static
token. Detailed steps are in
docs/INSTALLATION.md § 3.
Treat the cookie file like a password. The
refresh_tokenin it can mint new tokens for your account indefinitely..gitignoreexcludes.cookie,*.cookie, and*.token.json.
offsec_list_labs(groupPlay) → pick a machine id/slug.offsec_get_machine_details→ read the brief.offsec_start_machine→ deploy begins (async).offsec_list_running_labs→ read its IP and instance id (oncestarted).- Connect via your OpenVPN client and work the box.
offsec_get_walkthroughif you want the official walkthrough (unlocks after start).offsec_stop_machine(oroffsec_revert_machine) — no instance id needed; it's auto-discovered.
Unit tests run the normalizers and client helpers against real portal
responses captured under tests/fixtures/ (PII redacted). No network needed.
npm testThere is also a live, read-only smoke test that hits the real API using your token (no machines are started):
node --env-file=.env scripts/live-smoke.mjsTRANSPORT=http PORT=3000 node dist/index.js # binds 127.0.0.1:3000/mcpRequires the optional express dependency (installed by default).
| Var | Purpose |
|---|---|
OFFSEC_COOKIE_FILE |
Path to a k=v; k=v cookie export (recommended — enables automatic token refresh; read and written) |
OFFSEC_COOKIE |
The same cookie string inline (refresh works, but rotation can't be persisted) |
OFFSEC_BEARER_TOKEN |
Static bearer token; expires in ~10h with no refresh cookie alongside it |
OFFSEC_TOKEN_CACHE_FILE |
Where the refreshed access token is cached (default <cookie-file>.token.json) |
OFFSEC_API_BASE_URL |
Override REST base (default https://portal.offsec.com) |
OFFSEC_API_GATEWAY |
Override service gateway (default from config.json, else …/services) |
OFFSEC_TYPESENSE_HOST |
Override Typesense node host (default from config.json) |
OFFSEC_WS_URL |
Override events WebSocket URL (default from config.json, else wss://portal.offsec.com/ws/events) |
OFFSEC_PORTAL_WEB_BASE |
Override web base for generated links |
OFFSEC_USER_AGENT |
Override the User-Agent header |
TRANSPORT |
stdio (default) or http |
PORT |
HTTP port (default 3000) |