---
id: 2
title: "Port Discovery: Solving Multi-Instance mdboard"
tags: [architecture, dx, multi-instance]
created: 2026-02-10
updated: 2026-02-10
revision: 1
---

## Problem

Running multiple mdboard instances (one per project) is common but painful. Every instance defaults to port 8080. The user must:

1. Manually pick a free port: `uvx mdboard --port 8083`
2. Tell each project's Claude instance what port to use (via CLAUDE.md, verbally, etc.)
3. Remember which port maps to which project

This is pure friction — the kind of thing that should be zero-config.

---

## Current State

- Port comes from `--port` CLI flag (default: `8080`)
- No port is persisted anywhere after startup
- No discovery mechanism exists
- If port 8080 is taken, the server crashes with a bind error
- Claude instances have no way to find the port programmatically

---

## Design Goals

1. `uvx mdboard` should Just Work even when other instances are running
2. A Claude instance in a project should discover the port without being told
3. No new dependencies (mdboard is zero-dep by design)
4. Stale state from crashed servers must not block startup
5. Explicit `--port` must still work as an override

---

## Approaches Considered

### 1. Port file in project root (`.mdboard.port`)

On startup, write a JSON file to the project root:

```json
{"port": 8080, "pid": 12345, "tasks_dir": "/abs/path/to/tasks"}
```

- **Discovery**: Claude reads `.mdboard.port` from cwd — trivial
- **Stale handling**: Check if PID is alive; if not, delete and proceed
- **Multi-instance**: Each project has its own file
- **Cleanup**: `atexit` handler removes the file; stale files caught on next startup
- **Git**: Add `.mdboard.port` to scaffold's `.gitignore`

**Verdict**: Simple, project-scoped, standard pattern (Rails, PostgreSQL, Jupyter all do variants of this). **Recommended as part of the solution.**

### 2. Auto-increment port on conflict

Try 8080, then 8081, 8082, ... up to 8089.

- **Discovery**: Doesn't help on its own — Claude still doesn't know which port was chosen
- **Multi-instance**: Works automatically
- **UX**: No more crash on port conflict

**Verdict**: Good UX improvement but **must be combined with a port file** to be useful.

### 3. Deterministic port from project path

Hash the absolute tasks directory path to a port in a fixed range:

```python
port = 8080 + (hash(abs_tasks_dir) % 100)  # range: 8080-8179
```

- **Discovery**: Claude can compute the same hash — no file needed
- **Stale handling**: N/A — no state
- **Collisions**: Possible but unlikely with 100-port range
- **Predictability**: Same project always gets same port

**Verdict**: Clever but fragile — hash collisions silently break things, and it's surprising behavior. Not recommended as primary mechanism.

### 4. Config in `tasks/config.yaml`

Add a `port:` key to the existing config file.

- **Discovery**: Claude already reads config for column info
- **Stale handling**: N/A — it's config, not runtime state
- **Multi-instance**: Each project gets its own config

**Verdict**: This is a **static preference**, not runtime discovery. Good for "I always want project X on port 9000" but doesn't solve auto-assignment. **Useful as optional override.**

### 5. Unix domain sockets

Replace TCP ports with filesystem sockets (`/tmp/mdboard-{hash}.sock`).

- **Discovery**: Deterministic path from project dir
- **Stale handling**: OS cleans up on crash
- **Multi-instance**: No conflicts possible

**Verdict**: Elegant but breaks browser access (needs a proxy or `--port` mode). Python's `http.server` doesn't natively support it. **Too much complexity for the gain.**

### 6. Global registry (`~/.mdboard/instances.json`)

Central file tracking all running instances.

- **Discovery**: Read registry, look up project path
- **Multi-instance**: Designed for it
- **Stale handling**: PID checks on read

**Verdict**: Over-engineered for this use case. Project-local files are simpler. Could be added later if there's a need for `mdboard list` or similar commands.

---

## Recommendation

Combine approaches **1 + 2 + 4**: port file + auto-increment + optional config.

### Startup flow

```
1. Check tasks/config.yaml for explicit `port:` setting
2. If --port flag given, use that (override everything)
3. Otherwise, start from configured port (or 8080 default)
4. If port is taken:
   a. Check .mdboard.port — if PID alive, it's a real conflict
   b. If PID dead, clean up stale file and retry same port
   c. If genuinely taken, try port+1, port+2, ... up to +9
5. Bind to chosen port
6. Write .mdboard.port with {port, pid, tasks_dir}
7. Register atexit cleanup
```

### Discovery for Claude

Add to the scaffolded CLAUDE.md template:

```markdown
### mdboard Port Discovery
The board writes `.mdboard.port` on startup. Read it to find the running port:
cat .mdboard.port  # → {"port": 8080, "pid": 12345, ...}
```

Claude instances simply read the file. No configuration needed.

### Changes required

| File | Change |
|------|--------|
| `server.py` | Add `write_port_file()`, `cleanup_port_file()`, `find_available_port()` |
| `server.py` | Update `run_server()` startup sequence |
| `init.py` | Add `.mdboard.port` to `.gitignore` in scaffold |
| `init.py` | Add port discovery instructions to CLAUDE.md scaffold |
| `config.yaml` | Document optional `port:` key |

### Estimated scope

~50 lines of Python. No new dependencies. Fully backwards-compatible (`--port` still works, just smarter defaults).

---

## Edge Cases

- **Crash without cleanup**: Next startup checks PID, finds it dead, removes stale `.mdboard.port`, proceeds normally.
- **Two instances same project**: Second startup sees live PID in `.mdboard.port`, auto-increments to next port, writes its own `.mdboard.port` (overwriting — last writer wins). This is fine because you shouldn't run two boards for the same tasks dir.
- **Permission issues**: `.mdboard.port` is in the project root where the user already has write access (they're running the server).
- **Explicit `--port` conflicts**: If the explicit port is taken, fail loudly (don't auto-increment when user was explicit — they probably have a reason).
