Guide 02

Configure the lab

Everything a team varies lives in a profile. Compose files stay generic. Generated secrets (secrets/, third_party/*/secrets/) are gitignored. dev-credentials.yaml is a documented lab-only catalog, inert unless LAB_DEV_MODE=true. Runtime mutations are wipeable. The snippets below are taken from profiles/default.

Who owns what

  • This repo owns profiles, secrets layout, compose overlays, the mcplab CLI, and gateway policy.
  • Vendored repos own the appliances. Do not edit third_party/ in place — add a patch under patches/ and send it upstream.
  • Generated files — secrets/ and third_party/*/secrets/ — are produced by mcplab secrets and never committed. Documented lab-only values in dev-credentials.yaml are allowed and inert unless LAB_DEV_MODE=true.

A profile directory

profiles/<name>/
  profile.env              ports, LAB_PUBLIC_HOST, LAB_DOCKER_SUBNET, LAB_DEV_MODE, storage
  dev-credentials.yaml     lab-only catalog (used iff LAB_DEV_MODE=true)
  labdns/bootstrap.yaml    permanent DNS zones and records
  labldap/scenario.yaml    directory users, ACLs, TLS, MCP features
  labinfo/services.yaml    endpoint + connection catalog
  labmail/bootstrap.yaml   LabMail desired state (relay keys rejected)
  labmitm/bootstrap.yaml   LabMITM desired state (exact Origins; no "*")
  labntp/bootstrap.yaml    LabNTP desired state (allowedOrigins deny-all; no "*")
  labsso/bootstrap.yaml    LabSSO desired state (issuer must match derived; no allowedOrigins)
  labgraph/bootstrap.yaml  LabGraph service bootstrap (SPA Origins; no "*")
  scenarios/*.yaml         LabScenario files (empty default is a no-op; named packs: broken-bind, expired-cert, split-horizon-dns, mitm-intercept-extra-port)
  mcpjungle/
    servers/*.json         upstream MCP registrations
    groups/integration.json curated tool group

TacLab’s baseline is generated, not hand-written. labgen materializes users, groups, clients, policies, PKI, and shared secrets into third_party/go-lab-tacacs-mcp/deployments/compose/ on first make up. The profile owns the host ports (TACLAB_*). When LAB_DEV_MODE=true, first mint and pin-bump feed the catalog into labgen (-secrets-from); PKI stays labgen-random. Catalog-only enter-dev still pins secret files after labgen and leaves PKI and YAML alone.

cp -a profiles/default profiles/teamx
# edit profiles/teamx/profile.env and the YAML/JSON under it
cp .env.example .env
# PROFILE=teamx
make up PROFILE=teamx

Never hardcode a port in docker-compose.yaml — add a variable to profile.env with a compose default.

Env precedence

Highest wins:

  1. Process environment
  2. .env at the repo root (selects PROFILE= and may override any value)
  3. profiles/<name>/profile.env
  4. Compose-file defaults (match the default profile)
# .env
PROFILE=default
# PROFILE=teamx

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). Container listen ports are unprivileged and irrelevant to consumers. Management planes stay high. LabSSO is dest-443. LabMITM is a forward proxy (SUT sets HTTP_PROXY), not dest 443. Today's default-profile numbers that are not native (DNS 10053, LDAP 3389/3636, SMTP 1025, NFS 20490) are residual, not a second policy — remaps are follow-on PRs. Operator escape (a non-native profile.env port) is allowed when preflight cannot free the IANA port; it is an escape, not the default (AGENTS.md rule 15).

make preflight (also run by make up and make register) checks profile drift and that every published host port is free or already held only by this lab's containers. Permission-denied on privileged ports (TacLab 49/300 as a non-root user) is not treated as occupied — dockerd can still publish them. Preflight then checks /proc/net for a real listener (TCP LISTEN / UDP bound) instead of skipping those ports. Fail closed if a non-lab process holds the port. Failures stop bring-up before compose dies mid-deploy.

When a lab feature depends on a Docker daemon or host setting, preflight must fail 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: write /etc/docker/daemon.json {"userland-proxy": false} then systemctl restart docker. 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 (SUTs that cannot set dest port cannot follow the escape). NTP 123 held by systemd-timesyncd (when the profile opts into LABNTP_NTP_PORT=123): stop/disable timesyncd (lab host clock may drift; LabNTP never settimeofday) or extra IP or keep LABNTP_NTP_PORT=10123 (timesyncd/W32Time cannot). 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).

profile.env — every knob

VariableDefaultWhat it is
LABDNS_DNS_PORT10053Residual. Native dest is 53. DNS data plane (udp+tcp).
LABDNS_REST_PORT18080LabDNS REST /v1 + MCP /mcp + operator console GET /.
LABLDAP_LDAP_PORT3389Residual. Native dest is 389. LDAP (StartTLS). Cleartext simple bind is disabled.
LABLDAP_LDAPS_PORT3636Residual. Native dest is 636. LDAPS. Cert SAN is directory plus LAB_PUBLIC_HOST (DNS or IP).
LABLDAP_HTTPS_PORT8443LabLDAP UI + REST + MCP, lab TLS.
MCP_GATEWAY_PORT8080MCPJungle streamable HTTP.
MCPJUNGLE_IMAGE_TAG0.4.6MCPJungle GHCR tag for gateway + registrar. Omit to keep compose :-0.4.6. Do not copy upstream latest / latest-stdio.
NFS_PORT20490Residual. Native dest is 2049. ratarmount-rs userspace NFSv3 (writable overlay).
TACLAB_LEGACY_PORT49Already native. TACACS+ (RFC 8907).
TACLAB_TLS_PORT300Already native. TACACS+ TLS 1.3 (RFC 9887).
TACLAB_RADIUS_ACCESS_PORT1812Already native. RADIUS access (udp). Message-Authenticator required.
TACLAB_RADIUS_ACCT_PORT1813Already native. RADIUS accounting (udp).
TACLAB_RADIUS_RADSEC_PORT2083RadSec. Published; listener default off.
TACLAB_RADIUS_DYNAUTH_PORT3799RADIUS DAS. Published; listener default off.
TACLAB_HTTP_PORT18049TacLab UI + REST + MCP.
MAILDEV_SMTP_PORT1025Residual. Native dest is 25. Receive-only SMTP ingest.
MAILDEV_WEB_PORT1080LabMail UI + /email + /v1 + MCP.
MAILDEV_WEB_USERadminWeb basic-auth user. Frozen at admin. Password is minted.
LABMITM_PROXY_PORT18888Unauthenticated HTTP/1.1 forward proxy (absolute-form + CONNECT). Not an IANA dest-443 service; do not move this to 443.
LABMITM_WEB_PORT18088LabMITM inspector UI + /v1 + MCP.
LABINFO_PORT18090Service-directory MCP.
LABGRAPH_PORT18091LabScenario orchestrator REST / MCP / SPA.
LABNTP_NTP_PORT10123LabNTP NTPv3/v4 data plane (UDP). FR / ADR 0014 default, not residual-as-design. Privileged 123 is opt-in.
LABNTP_REST_PORT18123LabNTP operator console + REST /v1 + MCP /mcp.
LABSSO_HTTPS_PORT443LabSSO HTTPS data plane (dest-443). Non-443 is an operator escape.
LABSSO_REST_PORT18443LabSSO operator console + REST /v1 + MCP /mcp.
LABGRAPH_LABLDAP_SCENARIO_NAMEmcp-integration-labCompiled LabLDAP scenario name for reset.
LAB_PUBLIC_HOSTlocalhostHostname labinfo puts in every URL, LabSSO issuer (no :443 when dest is 443), a DNS or IP SAN on LabLDAP and LabSSO leaf certs (both modes), and LabLDAP management Host extras (overlay LABLDAP_MANAGEMENT_ALLOWED_HOSTS). Changing it needs mcplab secrets plus make reload APP=labldap / APP=labsso, and an issuer edit in labsso/bootstrap.yaml.
LAB_DOCKER_SUBNET10.99.42.0/24IPv4 CIDR for mcplab-shared (/24–/27). Docker's default is a /16 per user-defined network; this lab uses one /24. Leftover /16: make down then make up.
LAB_DEV_MODEfalseSingle security knob. Also consumes dev-credentials.yaml.
NFS_ARCHIVE_DIR.data/nfsEmpty-root fixtures.tar.zst (writable; live commit replaces it).
NFS_DATA_DIR.data/nfs-workIndexes plus the durable write overlay. Give it real disk.
NFS_COMMIT_OVERLAY_INTERVAL15mHow often overlay writes are spliced into the .tar.zst.

Reload one app (not a full redeploy)

make up vendors, mints or reconciles secrets, builds every image, starts all three compose projects, and registers the gateway. Use it for first bring-up, a vendor pin bump, or a profile switch. After a dev-credentials.yaml or LAB_DEV_MODE edit, or a LAB_PUBLIC_HOST SAN change, mcplab secrets reloads running apps whose files changed; make up skips those names. Changing LAB_PUBLIC_HOST also needs make reload APP=labldap so the control container picks up LABLDAP_MANAGEMENT_ALLOWED_HOSTS (mcplab secrets re-signs LabLDAP and LabSSO leaf SANs and reloads a running labsso). Escape-port or host changes also need an issuer edit in labsso/bootstrap.yaml. After that, recreate one application:

make reload APP=labdns|maildev|nfs|labinfo|mcpjungle|labldap|labtacacs|labmitm|labgraph|labntp|labsso

YAML edits for LabDNS, LabMail, LabMITM, LabNTP, LabSSO, labinfo, or LabLDAP do not need a full stack bounce. Gateway JSON is make register (no container restart). Gateway container reload also re-registers because SQLite is tmpfs. LabLDAP reload re-seeds ephemeral /data. Mail reload wipes the inbox. LabMITM reload wipes captured flows and does not re-register. LabNTP and LabSSO reload do not re-register.

LabDNS

labdns/bootstrap.yaml is the desired state, mounted read-only. After editing, make reload APP=labdns. MCP dns_state_reset returns the service to this file. Non-loopback peers present the bearer in secrets/labdns-token.

LabDNS v1.4.1 serves an operator console at GET / on port 18080 (spec.ui.enabled, default true). Paste the bearer. Loopback Origins are allowed. A remote browser must list its Origin in spec.management.allowedOrigins (exact http(s)://host[:port]; no "*" sentinel). Profile YAML sets spec.management.mcp.allowLegacyClients: true for MCPJungle. Over-length desired-state owners answer on management resolve / explain only (the DNS wire is still RFC 1035); do not add them to lab.test..

apiVersion: labdns.dev/v1alpha1
kind: LabDNS
metadata:
  name: mcp-integration-lab
spec:
  listeners:
    dns: { address: ":5353", protocols: [udp, tcp] }
    management: { address: ":8080", restPath: /v1, mcpPath: /mcp }
  ui: { enabled: true }
  management:
    auth: { profile: bearer, secretRef: /etc/labdns/token }
    mcp: { allowLegacyClients: true }
  defaults: { ttl: 30s, negativeTTL: 10s }
  zones:
    - id: lab-zone
      name: lab.test.
      mode: authoritative
      nameservers: [ns1.lab.test.]
      records:
        - { id: ns1-a,  owner: ns1,      type: A, values: [10.42.0.53] }
        - { id: nfs-a,  owner: nfs,      type: A, values: [10.42.0.30] }
        - { id: ldap-a, owner: ldap,     type: A, values: [10.42.0.40] }
        - { id: tools-wildcard-a, owner: "*.tools", type: A, values: [10.42.0.20] }

Add a zone or a record here and make reload APP=labdns. Runtime records added through MCP vanish on dns_state_reset.

LabLDAP

labldap/scenario.yaml is a LabScenario. LabLDAP v0.7.0 defaults to native labldapd; this lab still sets directory.engine: native. Management TLS from lab-CA files, mutation and password MCP tools enabled. After editing, make reload APP=labldap re-seeds ephemeral /data.

apiVersion: labldap.dev/v1alpha1
kind: LabScenario
metadata:
  name: mcp-integration-lab
spec:
  directory:
    engine: native
    suffix: "dc=example,dc=test"
    peopleRDN: "ou=people"
    groupsRDN: "ou=groups"
  lifecycle: { storageMode: ephemeral, startupMode: merge, softReset: true }
  transport:
    insecureLabMode: true
    ldap:  { enabled: true, port: 3389 }
    ldaps: { enabled: true, port: 3636 }
    startTLS: true
    allowCleartextBind: false
    allowAnonymousBind: false
  management:
    listen: "0.0.0.0:8443"
    tls: { mode: files, certFile: /run/secrets/management.crt, keyFile: /run/secrets/management.key }
    mcp:
      enabled: true
      registerMutations: true
      registerPassword: true
  users:
    - { id: alice, uid: alice, passwordFile: /run/secrets/user-alice, enabled: true }
  groups:
    - id: staff
      members: [{ user: alice }]
  acls:
    - id: staff-read
      principal: { kind: group, ref: staff }
      target: { kind: suffix }
      permissions: [read, search, compare]
      attributes: { allow: ["*"], deny: [userPassword] }
  • Bind as uid=alice,ou=people,dc=example,dc=test.
  • LDAPS cert SAN is directory plus LAB_PUBLIC_HOST (DNS name, or an IP SAN when that value is an IPv4/IPv6 literal). Extra SANs are mode-independent. Trust third_party/go-lab-ldap-mcp/secrets/tls/ca.crt. The CA private key is not committed. setuptls --dns/--ip is unused; labtlsEnsure still mints the SANs.
  • Management HTTP Host extras come from overlay LABLDAP_MANAGEMENT_ALLOWED_HOSTS (LAB_PUBLIC_HOST) union LoopbackHosts. Literal IP Hosts are accepted. "*" is rejected. Extra names can go in spec.management.allowedHosts. Default localhost is already a LoopbackHost; an unlisted Host 400 does not prove this wiring.
  • Add users, groups, and ACLs in this file. MCP mutations are ephemeral.

TacLab

Do not hand-write TacLab configs. mcplab secrets runs labgen and sets api.mcp.allow_legacy_clients: true so the gateway’s older MCP client can connect. What you own in the profile:

TACLAB_LEGACY_PORT=49
TACLAB_TLS_PORT=300
TACLAB_RADIUS_ACCESS_PORT=1812
TACLAB_RADIUS_ACCT_PORT=1813
TACLAB_HTTP_PORT=18049
# published, listeners default off
TACLAB_RADIUS_RADSEC_PORT=2083
TACLAB_RADIUS_DYNAUTH_PORT=3799

Lab-user passwords land in third_party/go-lab-tacacs-mcp/deployments/compose/secrets/PASSWORDS.txt. In LAB_DEV_MODE=true, first mint and pin-bump pass labgen -secrets-from a YAML generated from dev-credentials.yaml (secrets/taclab-secrets-from.yaml, gitignored, 0o600) so Argon2id is labgen-minted. Enter-dev on an existing baseline skips labgen and still post-processes secret files (token, shared secrets, lab-user passwords, Argon2id verifiers) and leaves PKI and YAML alone. Leave-dev is labgen -force without the flag (unlinks leftover YAML). Always EnableLegacyClientsDir. RADIUS Access-Requests must carry Message-Authenticator (RFC 3579).

LabMail

The compose service name stays maildev. Desired state is labmail/bootstrap.yaml (LabMail v1.0.0-rc.4). The orchestrator rejects leftover maildev/maildev.yaml and every relay/outbound key. allowLegacyClients: true is required for MCPJungle. After editing, make reload APP=maildev (wipes the inbox). Hashed inbox JS sends Origin; this profile sets originAllowlist: ["*"] so remote browsers can load the SPA. Bearer + Basic still required; CORS stays off. Tighten with "private" or an exact Origin. Optional spec.smtp.behavior is deterministic and default-off — not a chaos engine; leave it omitted.

apiVersion: labmail.dev/v1alpha1
kind: LabMail
metadata:
  name: lab-sink
spec:
  listeners:
    smtp: { address: ":1025" }
    management: { address: ":1080", restPath: /v1, mcpPath: /mcp, compatEnabled: true }
  smtp:
    hostname: labmail.lab
    auth: { mode: none }
    tls: { mode: off }
  management:
    auth:
      mode: bearer_and_basic
      tokens:
        - { id: admin, secretFile: /run/secrets/labmail-token, role: administrator }
      basic:
        username: admin
        passwordFile: /run/secrets/maildev-web-password
        tokenRef: admin
    mcp:
      allowLegacyClients: true
    originAllowlist: ["*"]

Point the system under test at <lab-host>:1025 (residual; native dest is 25). Read captured mail at :1080. Native REST is /v1; MCP is /mcp. Nothing is relayed.

LabMITM

Desired state is labmitm/bootstrap.yaml (LabMITM v1.7.2), a lab-owned overlay copy. allowLegacyClients: true is required for MCPJungle. Compose must pass --management-listen=:8088. After editing, make reload APP=labmitm (wipes captured flows; generate-mode CA rotates; does not re-register). The HTTP/1.1 data plane is unauthenticated; the overlay writes spec.proxy.httpAuth.enabled: false and 1.2 nested flags present-and-off. Native /v1 catalog is 31 (includes features.get); GET /v1/features / MCP mitm_features_list is the frozen 11-row hop/accept catalog. HTTPS intercept is :443 only; CONNECT to LabLDAP LDAPS or TacLab TLS is tunnel-not-decrypt. Origin allowlist is exact Origins (loopback already allowed; no "*"). When LAB_PUBLIC_HOST is not loopback, add http://<LAB_PUBLIC_HOST>:18088 or the inspector SPA 403s /v1.

Desired-state shape (1.1–1.4 knobs included, default off except D22-carve hop gates) lives in profiles/default/labmitm/bootstrap.yaml. Do not recopy from the upstream examples tree.

Point systems under test at <lab-host>:18888 as HTTP_PROXY (forward proxy — not dest 443). Inspector / REST / MCP is :18088 (bearer in secrets/labmitm-token).

LabNTP

Laboratory NTPv3/v4 unicast (catalog id labntp, pin v1.0.0-rc.3). Desired state is labntp/bootstrap.yaml, a lab-owned overlay copy. allowLegacyClients: true is required for MCPJungle. Compose must pass --management-listen=:8088 and cap_add: [NET_BIND_SERVICE]. Token file is 0o644. Host UDP default is 10123 (FR / ADR 0014, not residual-as-design); privileged 123 is opt-in. SPA allowedOrigins is deny-all (loopback already allowed; no "*"). Host-published UDP is SNAT'd (NAT collision); there is no Go userland-proxy probe. make reload APP=labntp does not re-register. No labgraph NTP fan-out in this pin.

Point systems under test at <lab-host>:10123 (UDP). Operator console / REST / MCP is :18123 (bearer in secrets/labntp-token).

LabSSO

Laboratory OIDC/OAuth2 + SAML IdP (catalog id labsso, pin v1.0.0-rc.4). Desired state is labsso/bootstrap.yaml, a lab-owned overlay copy. allowLegacyClients: true lives under spec.listeners.management.mcp. Compose must pass --management-listen=:8080. No NET_BIND_SERVICE. Token, Alice password, TLS files, and the PKCS#8 signing PEM are 0o644 (TLS dir 0o755). Signing key mint remints an empty leftover (Docker bind-mount of a missing file). Dedicated LabSSO CA — do not reuse the LabLDAP CA. Host HTTPS default is 443 (dest-443). Management is :18443. spec.issuer must equal the derived issuer. Catalog issuer URLs omit :443. Do not write allowedOrigins. SAML is on; clothes are generic. Loopback management is unauthenticated admin; remote peers need the bearer. make reload APP=labsso does not re-register. No labgraph SSO fan-out.

Point systems under test at https://<lab-host>/ (trust secrets/labsso-tls/ca.crt). Operator console / REST / MCP is :18443 (bearer in secrets/labsso-token).

LabGraph

First-party LabScenario orchestrator (catalog id labgraph, host port LABGRAPH_PORT 18091). Desired state is labgraph/bootstrap.yaml plus scenarios/*.yaml. The default scenario has spec: {} so list/get/validate/plan/apply/status are no-ops. Named packs (broken-bind, expired-cert, split-horizon-dns, mitm-intercept-extra-port) load at labgraph://fixtures/{id} and apply via fixture.apply. Apply order is DNS → MITM → mail → LDAP → TacLab. LabLDAP control-plane disableUser is allowed; any labldap key besides operations/reason/expectedRevision and TacLab apply fail closed. Serve requires a non-empty token file. The operator console at GET / is a split inspector (list | five-node order; Reset confirm; cookie + memory CSRF). mcplab scenario / mcplab fixture apply are HTTP clients of labgraph (secrets/labgraph-token). make reload APP=labgraph drops the in-memory journal. Origin allowlist is exact Origins (no "*"). Jenkins is not in the integrator.

NFS

NFS_ARCHIVE_DIR holds the empty-root fixture .tar.zst (fixtures.tar.zst contains only ./) after make fixtures. The export is writable: -w overlay plus live commit (NFS_COMMIT_OVERLAY_INTERVAL, default 15m, and on process exit). NFS_DATA_DIR is indexes plus the durable overlay. Plan ~2× compressed size on the archive dir during a commit.

NFS_PORT=20490
NFS_COMMIT_OVERLAY_INTERVAL=15m
NFS_ARCHIVE_DIR=.data/nfs
NFS_DATA_DIR=.data/nfs-work

mount -t nfs -o vers=3,tcp,nolock,port=20490,mountport=20490 \
  <lab-host>:/ /mnt

20490 is residual; native dest is 2049. AUTH_SYS only. No MCP wrapper yet (phase 1). ratarmount-rs v0.1.34 also ships NFSv4.1 (--nfs-vers 4); this lab stays on v3. After a zstd overlay commit, remount patches the SQLite sidecar instead of reindexing the whole TAR. After changing the interval or the image pin, make reload APP=nfs.

labinfo catalog

Every service in labinfo/services.yaml must carry a connection block — labinfo refuses to start without one. URLs are ${VAR} templates over the profile env. Host comes from LAB_PUBLIC_HOST. Credentials point at staged copies under secrets/labinfo-creds/ and are revealed only when LAB_DEV_MODE=true. The default catalog includes the LabLDAP CA PEM, LabSSO CA PEM, IdP Alice password, TacLab lab-user passwords, the TACACS+ shared secret, and optional TacLab client certs. There is no labinfo catalog service for the inbound labinfo-token. Serve requires a non-empty token file (empty used to disable bearer on the published port). mcplab creds / make creds prints the same sheet from those staged files (dev mode only).

services:
  - id: labldap
    name: LabLDAP control plane
    urls:
      - { name: Web UI,   url: https://${LAB_PUBLIC_HOST}:${LABLDAP_HTTPS_PORT}/ }
      - { name: REST API, url: https://${LAB_PUBLIC_HOST}:${LABLDAP_HTTPS_PORT}/api/v1 }
    credential:
      file: /run/lab-secrets/labldap-token-admin
      usage: "HTTP header 'Authorization: Bearer <token>'"
    connection:
      endpoints:
        - { name: LDAPS, protocol: ldaps, address: ldaps://${LAB_PUBLIC_HOST}:${LABLDAP_LDAPS_PORT} }
      parameters:
        base_dn: dc=example,dc=test
        bind_dn_example: uid=alice,ou=people,dc=example,dc=test
      credentials:
        - name: bind-password-alice
          file: /run/lab-secrets/labldap-user-alice
          usage: "simple-bind password for alice"

Adding a service: write the compose service, add a mcpjungle/servers/<name>.json (filename must match JSON name), and add the catalog entry. New ports must be exported into the labinfo container environment so expansion sees them.

MCPJungle

Pinned 0.4.6. MCPJUNGLE_IMAGE_TAG is profile-owned; copied profiles that omit the key still get compose :-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).

mcpjungle/servers/*.json register upstreams with ${ENV} token injection. Filename must match the JSON name.

{
  "name": "labldap",
  "transport": "streamable_http",
  "description": "Native Go directory lab (labldapd).",
  "url": "https://control:8443/mcp",
  "bearer_token": "${LABLDAP_TOKEN}"
}

The curated tool group — this is what most agents should attach to:

{
  "name": "integration",
  "description": "Curated tool subset: LabDNS, LabLDAP, TacLab, LabMail, LabMITM, labgraph, LabNTP, LabSSO, labinfo.",
  "included_servers": ["labdns", "labldap", "labtacacs", "labinfo", "labmail", "labmitm", "labgraph", "labntp", "labsso"]
}

Gateway SQLite sits on tmpfs; make register reapplies the JSON. Mode follows LAB_DEV_MODE unless you pin MCPJUNGLE_MODE.

One knob: LAB_DEV_MODE

  • false (default) — enterprise gateway. Clients send Authorization: Bearer $(cat secrets/mcp-client-token). labinfo describes auth and never reveals secrets. Secret files are random-if-missing. If secrets/.lab-dev-mode is present, mcplab secrets remints orchestrator tokens, force-runs LabLDAP setupsecrets and TacLab labgen, 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).
  • true — open gateway, labinfo reveals web tokens and connection secrets (LDAP bind password, RADIUS and TACACS+ shared secrets, TacLab lab-user passwords, LabLDAP CA PEM, LabSSO CA PEM, IdP Alice password), and mcplab secrets writes this profile's dev-credentials.yaml (fail-closed if missing or incomplete; no merge with default), 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). The default profile ships lab-dev-* values; they are inert unless this knob is on. LabMail, LabMITM, LabNTP, and LabSSO tokens must be at least 32 bytes (appliance auth.MinTokenBytes). make creds prints the shareable sheet from files on disk (never TLS private keys). Never default a shared team profile to dev mode. Set the knob in that profile's profile.env (process env on default fails preflight).

Catalog reconcile follows LAB_DEV_MODE only — never MCPJUNGLE_MODE. mcplab secrets reloads running apps whose files changed (and re-registers when registrar tokens change); make up skips LabLDAP/TacLab if they already reloaded this process. make smoke against a dev profile asserts catalog values on the wire (Alice bind, RADIUS taclabAdmin, connections_list equal to disk); default-profile smoke stays random secrets and redaction. CI copies profiles/default to gitignored profiles/ci-dev/ and sets the knob in that profile.env — never as process env on default.

Markdown twin: docs/guides/configuration.md. Design and phase-1 OAuth plan: docs/architecture.md.