Design

Three compose projects. One shared network.

Agents speak streamable HTTP to MCPJungle. Testers speak DNS, LDAP, TACACS+, RADIUS, SMTP, NFS, NTP, HTTPS dest-443 (LabSSO), and the HTTP intercept proxy at the published host ports. The appliances meet on mcplab-shared (LAB_DOCKER_SUBNET, default 10.99.42.0/24 — not Docker's default /16 per network).

Host ports and preflight

Protocol data planes SUTs already speak use the IANA dest on the host: DNS 53, NTP 123, LDAP 389 / LDAPS 636, SMTP 25, TACACS+ 49 / 300, RADIUS 1812/1813, NFS 2049, HTTPS 443 for LabSSO (AGENTS.md rule 15). Container listen ports are unprivileged and irrelevant to consumers. Management/control planes stay high. LabSSO is dest-443. LabMITM is a forward proxy (SUT sets HTTP_PROXY), not dest 443 — do not move LABMITM_PROXY_PORT to 443. Today's default-profile dests that are not native (DNS 10053, LDAP 3389/3636, SMTP 1025, NFS 20490) are residual, not a second policy. TacLab is already native. Operator escape is allowed when preflight cannot free the IANA port; it is an escape, not the default. Remaps are follow-on PRs per service.

Host-occupancy preflight checks every published host port. EACCES / EPERM is not occupied (TacLab 49; dockerd can still publish). Occupancy is /proc/net TCP LISTEN / UDP bound. Fail closed if a non-lab process holds the port.

When a lab feature depends on a Docker daemon or host setting, preflight fails closed with the configuration change in the error. Do not boot a lab that pretends the feature works. LabNTP is in compose; there is no Go userland-proxy probe (appliance ADR 0014: host-published UDP is SNAT'd; compose-network + views.preview stay reliable). The daemon.json copy is operator documentation only. Occupancy of opt-in host UDP 123 uses the timesyncd copy. Docker Desktop / VM NAT cannot preserve UDP source — Linux dockerd + userland-proxy false, or macvlan/ipvlan.

Error messages must name the fix. DNS 53 held by systemd-resolved: stop/disable resolved or extra IP for LAB_PUBLIC_HOST or escape LABDNS_DNS_PORT. NTP 123 held by systemd-timesyncd: stop/disable timesyncd (lab host clock may drift; LabNTP never settimeofday) or extra IP or escape LABNTP_NTP_PORT=10123. HTTPS 443 held by nginx/caddy/apache/another compose stack: stop/disable the occupant or extra IP or escape LABSSO_HTTPS_PORT (SUTs that hardcode dest 443 cannot follow).

ClientsMCP agents
streamable HTTP
GatewayMCPJungle :8080
data plane
TestersReal protocols
mcplab
  • LabDNS scratch image
  • labinfo service directory
  • LabMail receive-only (compose: maildev)
  • LabMITM HTTP intercept :18888 / :18088
  • LabNTP NTP :10123 / management :18123
  • LabSSO HTTPS :443 / management :18443
  • ratarmount-rs writable NFSv3
labldap
  • control :8443
  • labldapd native engine
  • HTTPS + bearer, lab CA
labtacacs
  • taclabd :49 / :300
  • RADIUS :1812 / :1813
  • control :18049
MCP clients  ──streamable HTTP──►  MCPJungle :8080/mcp
                                      │
                 ┌────────────────────┼────────────────────┐
                 │  compose mcplab    │                    │
                 │   labdns  labinfo  LabMail  labmitm  labntp  labsso  labgraph  ratarmount  │
                 └──────────┬─────────┴─────────┬──────────┘
                            │                   │
                 compose labldap         compose labtacacs
                   control :8443            taclabd :49/:300
                   labldapd                 :1812/:1813 :8080

Permanence vs. wipe

Bootstrap YAML and scenario files are desired state. Anything an MCP tool mutates must come back with make reset or a *_state_reset tool. Gateway registration is tmpfs. The only durable host paths a profile may define are NFS_ARCHIVE_DIR and NFS_DATA_DIR. After a YAML tweak, make reload APP=<app> recreates that one service; make up is first bring-up, a vendor pin bump, or a profile switch.

Security model

Internal hops always use static bearer tokens on the isolated docker network. The single profile knob is LAB_DEV_MODE.

  • Hardened: enterprise gateway, redacted labinfo, random-if-missing secrets (empty leftover token files are reminted). labinfo serve fail-closes on a missing or empty token file. Leaving dev mode (marker secrets/.lab-dev-mode) unlinks and remints orchestrator tokens, runs LabLDAP setupsecrets --force and labgen -force, reloads running containers, and removes the marker last. LabLDAP and LabSSO CAs are not rotated (extra SANs are mode-independent; leaves may still be re-signed if LAB_PUBLIC_HOST is missing).
  • Dev: open gateway, revealed credentials staged world-readable under secrets/labinfo-creds/ (LDAP bind, RADIUS and TACACS+ shared secrets, TacLab lab-user passwords, LabLDAP CA PEM, LabSSO CA PEM, IdP Alice password, optional TacLab client certs), and secret files reconciled from dev-credentials.yaml (including TacLab lab-user passwords and AAA shared secrets: first mint / pin-bump via labgen -secrets-from; catalog-only enter-dev still pins after labgen; fail-closed if missing; no merge with default; default profile ships lab-dev-* values, inert unless this knob is on; LabMail, LabMITM, LabNTP, and LabSSO tokens must be at least 32 bytes). Catalog reconcile never inspects MCPJUNGLE_MODE. mcplab secrets reloads running containers whose files changed (or when the marker is missing / reloads is not done, or when LabLDAP or LabSSO leaves were re-signed for LAB_PUBLIC_HOST) and re-registers the gateway when registrar tokens change; make up skips those apps. The reloads=pending marker is written before catalog apply and setupsecrets/labgen, so a crash after those files land still retries. mcplab creds / make creds prints the same sheet from files on disk (fails closed outside dev; never prints TLS private keys). Dev-mode make smoke asserts those catalog values on the wire (Alice bind, RADIUS taclabAdmin, connections_list equal to disk). Default-profile smoke stays on random secrets and redaction.
  • Phase 1 puts OAuth 2.1 / OIDC on a proxy in front of the gateway. The tools do not change.

Vendoring and pins

  • MCPJungle 0.4.6 — MCPJUNGLE_IMAGE_TAG (compose default :-0.4.6). This lab’s default is 0.4.6, not upstream Compose latest / latest-stdio. Do not set MCPJUNGLE_BIND_HOST (unset = all interfaces). Operator dashboard is GET / in development mode only (enterprise 404s). Observed GHCR digest 2026-08-29: sha256:59940d2e3a586ab9a063cf24fa37460bc993686fa056729fd1ada25436123dd9 (informational; the tag is the lock).
  • LabDNS v1.4.1 — operator console on GET /. MCPJungle uses spec.management.mcp.allowLegacyClients: true in the profile YAML (no local patch). Over-length names answer on management resolve / explain only; do not add them to lab.test..
  • LabLDAP v0.7.0 — native is the default engine; no local patch. Compose is compose.yaml + compose.ephemeral.yaml plus overlay. Overlay maps LABLDAP_MANAGEMENT_ALLOWED_HOSTS from LAB_PUBLIC_HOST (mapping merge, not environment: !override). Leaf certs include LAB_PUBLIC_HOST as a DNS or IP SAN in both modes; the lab CA private key is not committed. Changing LAB_PUBLIC_HOST needs mcplab secrets plus make reload APP=labldap.
  • TacLab v1.6.0 — MCP pin relaxed with the upstream allow_legacy_clients knob. In LAB_DEV_MODE=true, first mint and pin-bump pass labgen -secrets-from so Argon2id is labgen-minted; catalog-only enter-dev still post-processes after labgen (ApplyDevSecrets; no PKI wipe). Leave-dev is labgen -force without the flag. The flag does not replace EnableLegacyClientsDir. Dev mode does not patch the vendor.
  • LabMail v1.0.0-rc.4 — MCP pin relaxed with allowLegacyClients: true in labmail/bootstrap.yaml. Default originAllowlist: ["*"] for remote inbox JS. Compose service name stays maildev.
  • LabMITM v1.7.2 — MCP http://labmitm:8088/mcp. Compose must pass --management-listen=:8088. Unauthenticated data plane; intercept :443 only (CONNECT to LabLDAP/TacLab TLS is tunnel-not-decrypt). Overlay writes httpAuth.enabled: false and 1.2 nested flags present-and-off. Native /v1 catalog 31 (includes features.get); GET /v1/features / MCP mitm_features_list is the 11-row hop/accept catalog. Exact Origins (no "*"). Token 0o644. No local patch. make reload APP=labmitm does not re-register.
  • LabNTP v1.0.0-rc.3 (commit 83d568a; GHCR index digest informational when published; the vendor tag is the lock). MCP http://labntp:8088/mcp. Compose must pass --management-listen=:8088 and cap_add: [NET_BIND_SERVICE]. Host UDP 10123 (ADR 0014). SPA allowedOrigins deny-all (no "*"). Token 0o644. No Go userland-proxy probe. make reload APP=labntp does not re-register. No labgraph NTP fan-out.
  • LabSSO v1.0.0-rc.4. MCP http://labsso:8080/mcp. Compose must pass --management-listen=:8080. No NET_BIND_SERVICE. Host HTTPS dest-443; management 18443. Dedicated CA (dir 0o755). Signing key mint-if-missing (empty leftover reminted), out of catalog. Issuer YAML must match derived issuer; catalog URLs omit :443. SAML on; generic clothes. No allowedOrigins. Loopback management is unauthenticated admin. Token 0o644. No local patch. make reload APP=labsso does not re-register. No labgraph SSO fan-out.
  • labgraph (first-party) — LabScenario orchestrator on LABGRAPH_PORT 18091. REST + MCP + SPA. Native fan-out DNS → MITM → mail → LDAP → TacLab. Named packs at labgraph://fixtures/{id} / fixture.apply. Empty default scenario is a no-op. Jenkins is not in the integrator. make reload APP=labgraph drops the in-memory journal.
  • ratarmount-rs v0.1.34 — NFSv3 export; NFSv4.1 is packaged but not enabled. Persist still copies the compressed prefix; remount uses the patched sidecar and does not rescan prefix frames.

Full write-up, mermaid topology, and the OAuth plan: docs/architecture.md. Configure a profile: Configure.