Skip to content

About

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.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

12 Commits

Folders and files

Repository files navigation

offsec-mcp-server

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.

Documentation

  • 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.

Quick start

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 client

Then 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.

Status — mapped against the live portal

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.

Tools

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.

Identifiers

  • 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.

Instance ids & the WebSocket

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:

  1. The upgrade is unauthenticated → connect.
  2. Send {"action":"sign_in","value":"<bearer token>"} → group:"auth" success.
  3. Send {"action":"subscribe","value":"host_actions"} → the server immediately pushes a host_actions/started snapshot 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.

Authentication

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 the refresh_token cookie, 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.

Automatic token refresh

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 (mode 0600, 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_token in it can mint new tokens for your account indefinitely. .gitignore excludes .cookie, *.cookie, and *.token.json.

Typical flow

  1. offsec_list_labs (group Play) → pick a machine id/slug.
  2. offsec_get_machine_details → read the brief.
  3. offsec_start_machine → deploy begins (async).
  4. offsec_list_running_labs → read its IP and instance id (once started).
  5. Connect via your OpenVPN client and work the box.
  6. offsec_get_walkthrough if you want the official walkthrough (unlocks after start).
  7. offsec_stop_machine (or offsec_revert_machine) — no instance id needed; it's auto-discovered.

Testing

Unit tests run the normalizers and client helpers against real portal responses captured under tests/fixtures/ (PII redacted). No network needed.

npm test

There 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.mjs

HTTP transport (optional)

TRANSPORT=http PORT=3000 node dist/index.js   # binds 127.0.0.1:3000/mcp

Requires the optional express dependency (installed by default).

Environment variable reference

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)

About

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.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages