Exhaustive reference for every field in mdp.yaml. For the narrative guide with more worked examples, see Config.

At a glance

port_range: "10000-60000"

global:
  env_file: ./.mdp.env
  env:
    API_URL: "http://localhost:${api.port}"

services:
  web:
    command: npm run dev
    dir: ./frontend
    proxy: 3000
    env:
      API_URL: "http://localhost:${api.port}"

  api:
    command: go run ./cmd/server
    dir: ./backend
    proxy: 4000
    depends_on: [db]
    env:
      DATABASE_URL: "postgres://localhost:${db.port}/app"

  db:
    command: docker compose up db --wait
    # no proxy — DB is internal only

Top-level

Key Type Default Notes
services map of name → service {} Each key is a service name. Shown in the TUI and referenced by depends_on.
port_range "MIN-MAX" string "10000-60000" Range for auto-allocating service ports (when port is 0 / unset).
stable_ports bool true Reuse each branch's previously-assigned ports across restarts (when still free), stored under ~/.mdp/ports/. Set false to allocate fresh ports every run. See Stable ports.
global global {} Project-wide env export block.
inputs map of name → input {} Values prompted for by mdp run -i, referenced elsewhere as ${inputs.<name>}. Declaration order is the prompt order.
links map of repogroup {} Cross-repo peer-lookup groups, equivalent to the --link repo=group CLI flag. Values may reference an input (e.g. ${inputs.api_branch}). See links.
port_range: "20000-25000"   # narrow range if you want predictable ports for firewall rules

services:
  web:
    command: npm run dev
    proxy: 3000
  api:
    command: ./api
    proxy: 4000

global

Key Type Default Notes
global.env_file string path "" (none) Aggregate .env file to write at startup. Relative paths resolve against the mdp.yaml directory. ~ is expanded. Empty = no file written.
global.env map of name → scalar | {ref: …, default: …} {} Env vars to write to env_file. Scalar values support ${svc.port} / ${svc.env.VAR} / ${@repo.svc...} interpolation with optional :-default fallback. The mapping form takes a ref: (e.g. svc.port, @repo.svc.port) and an optional default: used when the ref cannot be resolved.

Writing an aggregate .env for external tools

global:
  env_file: ./.mdp.env
  env:
    # Scalar form — any string, with ${...} interpolation.
    API_URL: "http://localhost:${api.port}"
    DB_URL:  "postgres://app:app@localhost:${db.env.DB_PORT}/app"

    # Mapping form — pass another service's port or env var through as-is.
    API_PORT:
      ref: api.port
    DB_PORT:
      ref: db.env.DB_PORT

After resolution .mdp.env contains:

# Managed by mdp — mdp-managed keys are updated on start.
API_PORT="42301"
API_URL="http://localhost:42301"
DB_PORT="20045"
DB_URL="postgres://app:app@localhost:20045/app"

Values written by mdp are double-quoted so the file is safe to source from a shell or load with standard .env parsers. When the file is created from scratch, keys are sorted alphabetically.

If the file already exists, mdp updates only the lines for keys it manages (in place, preserving an export prefix and any inline comment) and appends new keys at the end in sorted order — comments, blank lines, unrelated variables, and their order are left untouched. A consequence: a key removed from mdp.yaml lingers in the file with its last-written value until you delete the line by hand.

The file is written after all ports are allocated and before any service command runs. Point your editor's run config or shell tooling at it when you need the same values outside mdp.

inputs

inputs declares values that mdp run -i prompts for at startup. Each resolved value is referenced elsewhere as ${inputs.<name>}. The motivating case is supplying a cross-repo branch name interactively instead of typing --link repo=branch on the command line.

${inputs.<name>} is supported in these fields: a service's command, dir, setup, shutdown, post_start, env (scalar value, ref:, and default:), and env_file; the global.env block and global.env_file; and links values. Using it in group, tls_cert, or tls_key is a load-time error (use links for cross-repo groups); it is not expanded in other fields such as port_range or health_check.

Input names may contain only letters, digits, and underscore (so they are always referenceable as ${inputs.<name>}).

Keys under inputs.<name>:

Key Type Default Notes
prompt string the input name Question shown when prompting.
default string none Fallback used when not prompting, or when the answer is empty. Omitting the key (or writing a bare default: with no value) means no default — such an input errors under plain mdp run and must be prompted with -i. Use default: "" for an explicit empty default. Must be a plain literal — it cannot contain ${...} references.
choices string "" Set to groups to offer a numbered pick-list of the orchestrator's currently active groups (you may still type a custom value). Any other value is rejected at load.
repo string "" Only valid with choices: groups. Limits the pick-list to groups containing services from this repo — useful when the input chooses a peer repo's branch for a link. Must be a plain literal.
inputs:
  api_branch:
    prompt: "Which branch is the API running on?"
    default: main
    choices: groups
    repo: api

links:
  api: ${inputs.api_branch}

services:
  web:
    command: npm run dev
    env:
      VITE_API_URL: "http://localhost:${@api.server.port}"
      VITE_TARGET_BRANCH: "${inputs.api_branch}"

Resolution:

  • mdp run -i: each input is prompted in declaration order through an interactive terminal UI, which requires both stdin and stderr to be a real terminal (it errors if either is piped or redirected). A choices: groups input shows the active groups (filtered by repo if set) plus a final @{current} entry — "this checkout's default group" — for links that should fall back to the caller's own group (marking the default); use Up/Down to browse the list (each selection fills the text box) or type a custom value, and press Enter to submit whatever's in the box. A choices: groups input with no matching active groups and a declared default is skipped silently — exactly like plain mdp run — so -i only prompts when there is actually something to select; with no default it degrades to a free-text prompt since a value is still required. If the group listing fails (e.g. the orchestrator can't be reached), the input also falls back to a free-text prompt rather than silently taking the default. Submitting an input that has no default with an empty box re-prompts; Esc or Ctrl-C aborts the run.
  • mdp run (no -i): every input resolves to its default. An input with no default is an error — provide a default or run with -i.

Input values are plain literals: an answer (or default) containing ${...} is rejected, so an input can never smuggle in a port or peer reference. An undeclared ${inputs.X} reference in a supported field is a load-time error, and the service name inputs is reserved.

links maps a peer repo name to the group its services run in — the config-file equivalent of the repeatable --link repo=group CLI flag. A value may be a literal group or an ${inputs.<name>} reference. The CLI --link flag overrides config links per repo. See Cross-group lookups.

links:
  api: ${inputs.api_branch}   # group chosen interactively
  auth: stable                # fixed group
  db: "@{current}"            # explicitly the caller's own group

The special value @{current} means "the caller's own group" — the lookup behaves as if the link were not declared. It is accepted anywhere a link group can come from: a literal links: value, an input answer or default, or --link repo=@{current}. Note that YAML requires quoting ("@{current}") because a plain scalar cannot start with @. A link that resolves to an empty group (e.g. from an empty input) is still an error — @{current} is the explicit way to fall back. The sentinel can never collide with a real group: git forbids @{ anywhere in a branch name (it's reserved for symbolic revisions like @{u}).

Service

Keys under services.<name>.

Key Type Default Notes
command string "" Shell command to run. If empty and port > 0, the service is treated as externally managed: registered with the proxy but not started by mdp.
setup list of strings [] Commands run sequentially before command. First non-zero exit fails the service and command is not started. Shares dir and env.
shutdown list of strings [] Commands run sequentially after command exits (for any reason). Best-effort with a 30s per-step timeout. Shares dir and env.
post_start list of strings | {commands: [...], on_restart: bool} [] Commands run sequentially after the service becomes ready (TCP-reachable, plus the docker gate when health_check names compose services). Best-effort and non-blocking: failures only warn, and depends_on dependents never wait on them. Shares dir and env. See post_start hooks.
dir string path "" Working directory for command, setup, and shutdown. Relative paths resolve against the mdp.yaml directory. ~ is expanded.
port int 0 Fixed upstream port (for externally managed processes like Docker containers). When 0, a free port is allocated from port_range. Ignored when ports: is set.
proxy int 0 Proxy port to register this service on. 0 means do not register with any proxy — useful for DB-only services.
group string detected git branch in batch mode, else "" Logical grouping. Displayed as <group>/<name> in the TUI and used for cookie-based switching.
scheme "http" | "https" "http" (auto "https" when tls_cert is set) Scheme the proxy uses to connect to this service's upstream.
tls_cert string path "" TLS certificate path. Relative paths resolve against the mdp.yaml directory. ~ is expanded. Setting this auto-infers scheme: https.
tls_key string path "" TLS key path. Paired with tls_cert.
env_file string path "" Path to write this service's resolved env vars as a .env file. Relative paths resolve against the service's dir if set, else the mdp.yaml directory. ~ is expanded.
env map of name → scalar | {ref: …, default: …} {} Env vars for command, setup, and shutdown. Scalar values support: literal strings, auto (allocates a port in the corresponding ports[] entry), ${svc.port} / ${svc.NAMED_PORT} (own-mdp.yaml port references), ${@repo.svc.port} / ${@repo.svc.env.VAR} (cross-repo lookups via the orchestrator) with optional :-default fallback. The mapping form takes a ref: (e.g. api.port, @backend.api.env.URL) plus an optional default:. Cross-repo refs without a default are silently omitted when the peer is not registered.
ports list of port mapping [] Multi-port mode. When present, port is ignored and ports are allocated per entry.
log_split string | mapping "" Demultiplex combined-stream logs into per-sub-service colored lanes. Accepts the scalar "compose" (built-in docker-compose parser) or a mapping { regex: '<pattern>' } for arbitrary prefixes. See log_split.
depends_on list of service names [] Wait for each dependency to be TCP-reachable on its assigned port(s) before starting. 60s per-dependency timeout. Unknown names and cycles are rejected at config load.
health_check health check | "docker" nil (TCP on port) Liveness probe used by the registry pruner. When unset, the default is a TCP dial of the service's registered port. The docker: [svc, ...] variant also gates startup readiness. See Detached services and health checks.
restart bool false Automatically restart the service's process after it exits — crash or clean exit alike. mdp run --restart enables this for every service in addition to whatever's set here. See restart.

command — the basic case

services:
  web:
    command: npm run dev
    dir: ./frontend
    proxy: 3000

command omitted — register an externally managed process

When command is empty and port is set, mdp only registers the upstream with the proxy. You start the actual process however you like (Docker, systemd, another terminal).

services:
  auth:
    port: 8080        # already running somewhere on :8080
    proxy: 5000       # expose it through the proxy on :5000

setup and shutdown hooks

services:
  web:
    setup:
      - bun install
      - bun run build:assets
    command: bun dev
    shutdown:
      - rm -rf .cache/dev
    dir: ./frontend
    proxy: 3000

If bun install exits non-zero, web is marked failed and bun dev is never started. shutdown runs whenever command exits — clean exit, crash, or Ctrl-C.

When mdp run is connected to a terminal (unix only), hooks run on a pseudo-terminal so a hook that prompts for input doesn't hang silently. If a hook's output stalls mid-line (or right after a line ending in :, ?, or >), the hook takes focus of your terminal: the prompt is replayed, keystrokes go to the hook (Ctrl-C included), and all other service/log output is buffered in memory until focus releases — then it flushes and streaming resumes. Focus releases automatically once the hook moves past its prompt (resumes output that no longer looks like a prompt), when the hook exits, or when you press Ctrl-]. Before you type anything, an unterminated prompt holds focus even if the hook prints noise (a countdown, a periodic log line) while waiting. If several hooks need input, they take focus one at a time, with the in-between output flushed between them. Because a PTY has a single output stream, hook stderr is merged into stdout in this mode. This applies to setup, shutdown, and post_start hooks. Set MDP_NO_HOOK_PTY=1 to disable and run hooks on plain pipes; non-TTY environments (CI) and Windows always use plain pipes.

post_start hooks

post_start runs one-shot commands once the service is ready — its TCP port(s) accept connections, and, when health_check names docker compose services, those containers are running and healthy. Use it for work that needs the running service (seeding a database, warming a cache) without defining a separate service for it.

services:
  db:
    command: docker compose up
    health_check:
      docker: [postgres]
    post_start:
      - ./scripts/migrate.sh
      - ./scripts/seed-db.sh

Semantics:

  • Best-effort — a non-zero exit logs a warning and the next command still runs; the service is unaffected.
  • Non-blockingdepends_on dependents unblock on readiness as usual; they never wait for post_start to finish.
  • Commands run sequentially and share the service's dir and env, like setup and shutdown.
  • If the docker health gate never passes within the readiness timeout, the hooks are skipped with a warning.

By default the hooks run only on the initial start. To re-run them after a cross-repo peer change restarts the service, use the mapping form:

post_start:
  commands:
    - ./scripts/seed-db.sh
  on_restart: true

port — fixed vs auto-allocated

services:
  api:
    command: ./api
    proxy: 4000
    # no `port:` → mdp picks a free one from port_range and passes it as $PORT

  legacy:
    command: ./legacy-server --bind=:9000
    port: 9000        # legacy-server can't read $PORT, so pin it
    proxy: 4100

proxy: 0 — internal-only services

services:
  db:
    command: docker compose up db --wait
    # proxy omitted → the DB is allocated a free port but is NOT exposed
    # through a proxy listener. Other services reference it via ${db.port}.

group — organise multiple branches or repos

Usually group is auto-detected from the git branch in batch mode, but you can set it explicitly when running unrelated projects side-by-side:

services:
  web-main:
    command: npm run dev
    dir: ../main/web
    proxy: 3000
    group: main

  web-feature:
    command: npm run dev
    dir: ../feature-x/web
    proxy: 3000
    group: feature-x

Both services register on proxy :3000; the widget / TUI switches between main/web-main and feature-x/web-feature.

scheme, tls_cert, tls_key — HTTPS upstream

services:
  web:
    command: npm run dev -- --https
    proxy: 3000
    tls_cert: ./certs/localhost.pem
    tls_key:  ./certs/localhost-key.pem
    # scheme auto-becomes "https" — no need to set it

env_file — write one service's env to a dotfile

services:
  api:
    command: ./api
    dir: ./backend
    proxy: 4000
    env_file: .mdp.env     # resolves to ./backend/.mdp.env
    env:
      DATABASE_URL: "postgres://localhost:${db.port}/app"

The mdp-managed keys mirror exactly what the api process sees (merged into the existing file if one is already there, same rules as global.env_file — any other keys in the file are preserved but not passed to the process):

# Managed by mdp — mdp-managed keys are updated on start.
DATABASE_URL="postgres://localhost:42044/app"
PORT="20017"

env — literals, interpolation, and auto

services:
  api:
    command: ./api
    proxy: 4000
    env:
      LOG_LEVEL: debug                              # literal
      API_URL: "http://localhost:${api.port}"       # self-reference
      DB_URL:  "postgres://localhost:${db.port}"    # cross-service port

To read another service's env var, export it through global.env — service-level env values cannot use ${svc.env.VAR}.

auto is only meaningful inside multi-port services:

services:
  infra:
    command: docker compose up --wait
    env:
      API_PORT: auto      # mdp assigns a free port and writes it here
      DB_PORT:  auto
    ports:
      - env: API_PORT
        proxy: 4000
      - env: DB_PORT

ports — multi-port services

Use this when one command exposes several ports and you want to map (or not map) each one to a proxy individually:

services:
  infra:
    command: docker compose up --wait
    env:
      API_PORT: auto
      DB_PORT:  auto
    ports:
      - env: API_PORT
        proxy: 4000         # HTTP — registered with the 4000 proxy
      - env: DB_PORT        # no proxy — DB port is internal only

Every proxy-bearing port of a multi-port service registers under the parent service's key (<group>/<service>). The registered entry's env map carries all of the service's resolved env vars, so cross-repo refs select a specific port by env-var name: @<repo>.<service>.env.<ENV_VAR>. The bare form @<repo>.<service>.port is ambiguous when a service has more than one proxy-bearing port and will be rejected with a 409 — use the .env.<KEY> form. Two ports of the same service cannot share a proxy: value (the second would silently overwrite the first; mdp rejects the config at load time).

Other services reference the named ports as ${svc.NAME}, where NAME is the port mapping's env key:

services:
  web:
    command: npm run dev
    proxy: 3000
    env:
      API_URL: "http://localhost:${infra.API_PORT}"
      DB_URL:  "postgres://localhost:${infra.DB_PORT}/app"
    depends_on: [infra]

log_split — demultiplex combined-stream logs

When a service's command produces output from multiple sub-processes over a single stream (e.g. docker compose up), log_split parses each line and routes it to its own colored lane so each sub-process gets its own prefix. Two forms are accepted:

Built-in compose mode (scalar shorthand) — parses docker-compose's <name> | <message> format, including colorized output (--ansi=always / TTY).

services:
  infra:
    command: docker compose up
    log_split: compose
    env:
      API_PORT: auto
      AUTH_PORT: auto
    ports:
      - env: API_PORT
        proxy: 4000
      - env: AUTH_PORT
        proxy: 5000

Custom regex mode (mapping form) — provide any Go-regexp pattern with named captures name and msg. Useful for kubectl, honcho/foreman, bracket-prefixed tools, or anything else that multiplexes logs over one stream.

services:
  procfile:
    command: honcho start
    log_split:
      regex: '^(?P<name>[a-z]+)\s*\|\s(?P<msg>.*)$'

  k8s-app:
    command: kubectl logs --all-containers --prefix -f pod/api
    log_split:
      regex: '^\[pod/(?P<name>[^/]+)/[^\]]+\]\s*(?P<msg>.*)$'

Lines that don't match the parser fall through to the outer service prefix, so top-level status output (compose's Attaching to…, kubectl's reconnect messages, etc.) still appears under the service name. Stdout and stderr share the same name-to-color map so a sub-process's two streams collapse into one lane. Inside mdp.yaml, sub-lane labels are prefixed with the service name as <service>/<sub> so it's obvious which service an inner lane belongs to. Lane labels render at full length — short labels are right-padded to 12 characters for column alignment, longer labels expand past that and push their line out rather than being truncated.

For ad-hoc commands, pass the same value as a flag:

mdp run --log-split=compose -- docker compose up
mdp run --log-split='regex:^\[(?P<name>[^\]]+)\]\s*(?P<msg>.*)$' -- some-prefixed-tool

restart — auto-restart on crash

services:
  api:
    command: ./api
    proxy: 4000
    restart: true

When restart: true, mdp relaunches the service's process whenever it exits — a crash (non-zero exit) or a clean exit (0) are treated the same, since both mean the dev server is no longer running. There's a fixed 1s pause between the exit and the relaunch; restart continues indefinitely (no retry cap or backoff curve). It never fires on an mdp-initiated shutdown (Ctrl-C, mdp stop, orchestrator going away).

mdp run --restart turns this on for every service in a batch run, in addition to (not instead of) any per-service restart: true already in mdp.yaml. For an ad hoc command (mdp run -- some-command), --restart is the only way to enable it — there's no per-command YAML.

Auto-restart is incompatible with holding a session open for a detached command (e.g. docker compose up -d): with --restart, a clean exit relaunches immediately instead of watching the port and waiting for the detached process to stop.

depends_on — wait for readiness

services:
  db:
    command: docker compose up db --wait
    env:
      DB_PORT: auto
    ports:
      - env: DB_PORT

  api:
    command: ./api
    proxy: 4000
    depends_on: [db]

  web:
    command: npm run dev
    proxy: 3000
    depends_on: [api, db]

Services without depends_on start in parallel. Independent branches of the dependency graph also run in parallel — only direct dependents wait. Each dependency has a 60s TCP-readiness timeout. Failed deps cause dependents to be marked failed and skipped. Cycles and references to undefined services are rejected at config load.

Detached services and health checks

By default, mdp prunes a service from its proxy when the command process exits. That's wrong for detached commands like docker compose up -d: the foreground process exits quickly, but the real service is still listening on its port.

Instead, once the process exits mdp falls back to a liveness probe and keeps the entry around until the probe fails for ~30 seconds. The default probe is a TCP dial of the service's port. Override it with health_check:

services:
  # Default — TCP probe on svc.port.
  web:
    command: npm run dev
    proxy: 3000

  # Custom HTTP probe.
  api:
    command: bun run dev
    proxy: 4000
    health_check:
      http: http://localhost:4000/health

  # Shorthand for docker compose: runs `docker compose ps -q` in `dir`,
  # healthy as long as at least one container in the project is running.
  db:
    command: docker compose up -d
    dir: ./db
    port: 5432
    health_check: docker

  # Named compose services: each must be running, and healthy if it defines
  # a HEALTHCHECK. Also gates startup readiness — see below.
  stack:
    command: docker compose up -d
    dir: ./stack
    port: 5432
    health_check:
      docker: [db, redis]

  # For compose projects whose services are all short-lived, probe the
  # network directly instead.
  workers:
    command: docker compose up -d
    dir: ./workers
    port: 6000
    health_check:
      command: "docker network inspect workers_default"

Variants (mutually exclusive):

Field Healthy when
tcp: <port> a TCP connection to localhost:<port> succeeds
http: <url> an HTTP GET returns a 2xx or 3xx status
command: <shell tokens> the command exits 0 (run in the service's dir)
shorthand docker docker compose ps -q in the service's dir exits 0 with non-empty output
docker: [svc, ...] every named compose service has all containers in state running, and healthy if a HEALTHCHECK is defined

Unlike the other variants, which only affect post-exit liveness pruning, docker: [svc, ...] also gates startup readiness: the service isn't marked running (and depends_on dependents don't start) until the named compose services pass, in addition to TCP port reachability. It runs docker compose ps in the service's dir and requires Docker Compose v2.

Command parsing honors single/double quotes but does not invoke a shell, so write sh -c "..." explicitly if you need shell features. Timeouts: TCP 2s, HTTP 3s, command/docker 5s.

Port mapping

Entries in services.<name>.ports[].

Key Type Required Notes
env string yes Env var name that will hold the allocated port. Reference from other services' env with ${svc.NAME}, or from global.env with ${svc.env.NAME}.
proxy int no (default 0) Proxy port to register this port on. 0 = no proxy (use for DB / non-HTTP ports). Two ports of the same service cannot share a proxy: value.
protocol string no (default tcp) Transport protocol. udp marks the port as UDP: allocation uses a UDP-aware free-port check, and the depends_on readiness probe skips it (TCP probes never succeed on UDP). Incompatible with proxy.
services:
  infra:
    command: docker compose up --wait
    env:
      API_PORT:          auto
      WS_PORT:           auto
      DB_PORT:           auto
      JAEGER_AGENT_PORT: auto
    ports:
      - env: API_PORT
        proxy: 4000
      - env: WS_PORT
        proxy: 4001
      - env: DB_PORT     # internal only, no proxy
      - env: JAEGER_AGENT_PORT
        protocol: udp    # UDP-only; compose publishes "${JAEGER_AGENT_PORT}:6831/udp"

In the TUI this service appears as <group>/infra on both the 4000 and 4001 proxies — the parent service key is the registered identity. Internal-only ports (no proxy:) and UDP mappings are not registered against any proxy and do not appear in the TUI.

Interpolation

Service-level env values support port references only:

  • ${svc.port} — the primary allocated port of the service named svc (single-port services).
  • ${svc.NAME} — a named port from a multi-port service's ports[] entry, where NAME is that entry's env key (e.g. ${infra.API_PORT}).

global.env scalar values support everything above, plus:

  • ${svc.env.VAR} — the resolved value of another service's env var.

Using ${svc.env.VAR} inside a service-level env is a startup error ("env-var references are not allowed here"). All references resolve after port allocation and before any command runs, so you can freely cross-reference services without worrying about start order.

services:
  api:
    command: ./api
    proxy: 4000

  infra:
    command: docker compose up --wait
    env:
      GRPC_PORT: auto
    ports:
      - env: GRPC_PORT

  web:
    command: npm run dev
    proxy: 3000
    env:
      API_URL:  "http://localhost:${api.port}"          # single-port primary
      GRPC_URL: "localhost:${infra.GRPC_PORT}"          # multi-port named
    depends_on: [api, infra]

global:
  env_file: ./.mdp.env
  env:
    # global.env adds ${svc.env.VAR} — the resolved value of another service's env var.
    ADMIN_TOKEN: "${api.env.ADMIN_TOKEN}"

ref: mapping form

Scalar strings work in any env. The ref: mapping form passes the referenced value through without string-wrapping. It works in both global.env and per-service env:

global:
  env_file: ./.mdp.env
  env:
    # Scalar — wraps the value into a string template.
    API_URL: "http://localhost:${api.port}"

    # Ref — passes the value through on its own.
    API_PORT:
      ref: api.port
    ADMIN_TOKEN:
      ref: api.env.ADMIN_TOKEN

Cross-repo @<repo> references

mdp run instances in different repos all register with the same singleton orchestrator. To reference a service running in another repo, prefix the reference with @<repo-name>.. The lookup is scoped to the same group as the caller by default (typically the git branch); use --link to override the lookup group per peer repo (see below).

# In frontend's mdp.yaml
services:
  web:
    command: npm run dev
    env:
      # Interpolation form with optional :-default fallback.
      API_URL: "http://localhost:${@backend.api.port:-3001}"

      # Ref form with optional default: field.
      AUTH_TOKEN:
        ref: "@backend.api.env.AUTH_TOKEN"
        default: "dev-token"

Resolution semantics:

  • The frontend resolves @backend.* against the orchestrator at startup. Whatever the backend is registered as (port, exposed env vars), the frontend gets — for the same group.
  • If the peer is unresolved AND a default is provided, the default is used.
  • If the peer is unresolved AND no default is provided:
    • For ref: form, the env var is silently omitted (graceful "ignored" behavior).
    • For ${...} interpolation form, you must supply :-default; otherwise it is a hard error.
  • The supervisor watches each cross-repo peer for changes. When the peer's port or any referenced env var changes (or the peer (de)registers), the dependent service is killed and relaunched with refreshed values.

Notes:

  • @self and other reserved names have no special meaning — @<repo> is just whichever string was registered as the peer's repo (typically the basename of the directory containing the peer's mdp.yaml).
  • Cross-group references require --link <repo>=<group>. Without it, the peer must be in the same group as the resolver.

Use mdp run --link <repo>=<group> to redirect cross-repo @<repo>.* references to a different group than the caller's. The flag is repeatable and last-wins per repo. Common case: a frontend on a feature branch wiring to a backend that runs on main.

# Frontend on branch derek/foo, backend on main:
mdp run --link api=main
# In the frontend's mdp.yaml, no change needed:
services:
  web:
    command: npm run dev
    env:
      VITE_API_URL: "http://localhost:${@api.server.port}"

The override applies to all @<repo>.* references resolved during this mdp run invocation, both at startup and during peer-change watching.

To avoid typing the group on every run, declare it in the links section instead — and combine it with an input to choose the branch interactively:

inputs:
  api_branch:
    prompt: "Which branch is the API on?"
    default: main
    choices: groups
    repo: api
links:
  api: ${inputs.api_branch}

mdp run -i then prompts for api_branch (offering the active groups containing api services plus @{current}, or skipping straight to the default when there are none), and mdp run without -i uses the main default. A --link api=<group> on the command line still overrides the config value.

To default to the caller's own group instead of a fixed branch, use the @{current} sentinel (quoted — YAML reserves a leading @):

inputs:
  api_branch:
    prompt: "Which branch is the API on?"
    default: "@{current}"
    choices: groups
links:
  api: ${inputs.api_branch}

Now plain mdp run resolves @api.* references against the caller's own group, and mdp run -i lets you pick a different one. --link api=@{current} works the same way from the command line.

Path resolution

All paths support ~ expansion. Relative paths resolve as follows:

Field Base directory
dir mdp.yaml directory
tls_cert, tls_key mdp.yaml directory
global.env_file mdp.yaml directory
env_file (service-level) the service's dir if set, else the mdp.yaml directory

Absolute paths are used as-is.

# mdp.yaml lives at /repo/mdp.yaml
services:
  api:
    dir: ./backend              # → /repo/backend
    tls_cert: ~/certs/dev.pem   # → $HOME/certs/dev.pem
    env_file: .mdp.env          # → /repo/backend/.mdp.env (relative to dir)

  web:
    # no dir set
    env_file: .mdp.web.env      # → /repo/.mdp.web.env (falls back to mdp.yaml dir)

← Back to docs index