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
mcplabCLI, and gateway policy. - Vendored repos own the appliances. Do not edit
third_party/in place — add a patch underpatches/and send it upstream. - Generated files —
secrets/andthird_party/*/secrets/— are produced bymcplab secretsand never committed. Documented lab-only values indev-credentials.yamlare allowed and inert unlessLAB_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:
- Process environment
.envat the repo root (selectsPROFILE=and may override any value)profiles/<name>/profile.env- 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
| Variable | Default | What it is |
|---|---|---|
LABDNS_DNS_PORT | 10053 | Residual. Native dest is 53. DNS data plane (udp+tcp). |
LABDNS_REST_PORT | 18080 | LabDNS REST /v1 + MCP /mcp + operator console GET /. |
LABLDAP_LDAP_PORT | 3389 | Residual. Native dest is 389. LDAP (StartTLS). Cleartext simple bind is disabled. |
LABLDAP_LDAPS_PORT | 3636 | Residual. Native dest is 636. LDAPS. Cert SAN is directory plus LAB_PUBLIC_HOST (DNS or IP). |
LABLDAP_HTTPS_PORT | 8443 | LabLDAP UI + REST + MCP, lab TLS. |
MCP_GATEWAY_PORT | 8080 | MCPJungle streamable HTTP. |
MCPJUNGLE_IMAGE_TAG | 0.4.6 | MCPJungle GHCR tag for gateway + registrar. Omit to keep compose :-0.4.6. Do not copy upstream latest / latest-stdio. |
NFS_PORT | 20490 | Residual. Native dest is 2049. ratarmount-rs userspace NFSv3 (writable overlay). |
TACLAB_LEGACY_PORT | 49 | Already native. TACACS+ (RFC 8907). |
TACLAB_TLS_PORT | 300 | Already native. TACACS+ TLS 1.3 (RFC 9887). |
TACLAB_RADIUS_ACCESS_PORT | 1812 | Already native. RADIUS access (udp). Message-Authenticator required. |
TACLAB_RADIUS_ACCT_PORT | 1813 | Already native. RADIUS accounting (udp). |
TACLAB_RADIUS_RADSEC_PORT | 2083 | RadSec. Published; listener default off. |
TACLAB_RADIUS_DYNAUTH_PORT | 3799 | RADIUS DAS. Published; listener default off. |
TACLAB_HTTP_PORT | 18049 | TacLab UI + REST + MCP. |
MAILDEV_SMTP_PORT | 1025 | Residual. Native dest is 25. Receive-only SMTP ingest. |
MAILDEV_WEB_PORT | 1080 | LabMail UI + /email + /v1 + MCP. |
MAILDEV_WEB_USER | admin | Web basic-auth user. Frozen at admin. Password is minted. |
LABMITM_PROXY_PORT | 18888 | Unauthenticated HTTP/1.1 forward proxy (absolute-form + CONNECT). Not an IANA dest-443 service; do not move this to 443. |
LABMITM_WEB_PORT | 18088 | LabMITM inspector UI + /v1 + MCP. |
LABINFO_PORT | 18090 | Service-directory MCP. |
LABGRAPH_PORT | 18091 | LabScenario orchestrator REST / MCP / SPA. |
LABNTP_NTP_PORT | 10123 | LabNTP NTPv3/v4 data plane (UDP). FR / ADR 0014 default, not residual-as-design. Privileged 123 is opt-in. |
LABNTP_REST_PORT | 18123 | LabNTP operator console + REST /v1 + MCP /mcp. |
LABSSO_HTTPS_PORT | 443 | LabSSO HTTPS data plane (dest-443). Non-443 is an operator escape. |
LABSSO_REST_PORT | 18443 | LabSSO operator console + REST /v1 + MCP /mcp. |
LABGRAPH_LABLDAP_SCENARIO_NAME | mcp-integration-lab | Compiled LabLDAP scenario name for reset. |
LAB_PUBLIC_HOST | localhost | Hostname 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_SUBNET | 10.99.42.0/24 | IPv4 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_MODE | false | Single security knob. Also consumes dev-credentials.yaml. |
NFS_ARCHIVE_DIR | .data/nfs | Empty-root fixtures.tar.zst (writable; live commit replaces it). |
NFS_DATA_DIR | .data/nfs-work | Indexes plus the durable write overlay. Give it real disk. |
NFS_COMMIT_OVERLAY_INTERVAL | 15m | How 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
directoryplusLAB_PUBLIC_HOST(DNS name, or an IP SAN when that value is an IPv4/IPv6 literal). Extra SANs are mode-independent. Trustthird_party/go-lab-ldap-mcp/secrets/tls/ca.crt. The CA private key is not committed.setuptls --dns/--ipis unused;labtlsEnsurestill 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 inspec.management.allowedHosts. Defaultlocalhostis 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. Ifsecrets/.lab-dev-modeis present,mcplab secretsremints orchestrator tokens, force-runs LabLDAPsetupsecretsand TacLablabgen, 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 ifLAB_PUBLIC_HOSTis 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 secretswrites this profile'sdev-credentials.yaml(fail-closed if missing or incomplete; no merge withdefault), including TacLab lab-user passwords and AAA shared secrets (first mint / pin-bump vialabgen -secrets-from; catalog-only enter-dev still pins afterlabgen). The default profile shipslab-dev-*values; they are inert unless this knob is on. LabMail, LabMITM, LabNTP, and LabSSO tokens must be at least 32 bytes (applianceauth.MinTokenBytes).make credsprints 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'sprofile.env(process env ondefaultfails 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.