A lightweight, file-based port registry for AI agents and dev tools.
AI coding agents (Claude Code, Codex, Gemini CLI, Cursor, Windsurf) routinely spin up dev servers, databases, and test runners — each needing a port. When multiple agents or manual processes run on the same machine, port collisions are inevitable:
- Agent A starts Next.js on port 3000. Agent B tries the same port. One fails silently.
- A test runner grabs port 8080. A dev server tries the same port minutes later.
- A process crashes without releasing its port. The next agent skips it forever.
Existing solutions are either point-in-time checks with TOCTOU race conditions (get-port, portfinder), static registries with no runtime enforcement, or heavyweight daemons.
portclaim is a CLI tool and MCP server that provides persistent, cross-process port coordination with no daemon.
- File-based registry — one JSON file per claimed port in
~/.portclaim/claims/. Inspectable withlsandcat. - No daemon — coordination via
flock(2)on a shared lock file. Any process can read or write atomically. - Crash-resilient — every
acquirecall reaps claims whose owning PID has exited. - Bind probing — actually attempts to bind on both IPv4 and IPv6 loopback before claiming.
- Agent-native — JSON output by default when piped. MCP server for any MCP-capable agent.
# Install
curl -fsSL https://raw.githubusercontent.com/kylejfrost/portclaim/main/install.sh | bash
# Or from source
cargo install --path .
# Claim a port for your dev server
PORT=$(portclaim acquire --service next-dev --prefer 3000)
next dev --port $PORT
# See what's running
portclaim list
# Release when done
portclaim release $PORTDownload from GitHub Releases, or use the installer:
curl -fsSL https://raw.githubusercontent.com/kylejfrost/portclaim/main/install.sh | bashSupports Linux (x86_64, aarch64) and macOS (x86_64, aarch64). Installs to ~/.local/bin/ by default.
Requires Rust 1.70+.
git clone https://github.com/kylejfrost/portclaim.git
cd portclaim
cargo install --path .Note:
portclaim watchrequires Linux (usesinotify). On macOS the binary builds and runs, butwatchexits with a friendly error — useportclaim list --jsonpolling or the HTTP dashboard instead.
portclaim works with any AI coding tool. It provides two integration methods:
- MCP Server — for tools that support the Model Context Protocol
- CLI — for tools that can execute shell commands
Claude Code supports both MCP and skills for the deepest integration.
MCP Server (recommended):
claude mcp add portclaim -- portclaim mcp-serveOr add to your project's .mcp.json:
{
"mcpServers": {
"portclaim": {
"command": "portclaim",
"args": ["mcp-serve"]
}
}
}Skill file (teaches Claude Code when to use portclaim automatically):
# User-global (all projects)
mkdir -p ~/.claude/skills/portclaim
cp skill/SKILL.md ~/.claude/skills/portclaim/SKILL.md
# Or project-local
mkdir -p .claude/skills/portclaim
cp skill/SKILL.md .claude/skills/portclaim/SKILL.mdAdd to your project's AGENTS.md or instruct Codex directly:
When starting dev servers or any process that needs a port, use portclaim:
PORT=$(portclaim acquire --service <name> --prefer <port>)
<command> --port $PORT
Always release with: portclaim release $PORT
Check what's running with: portclaim listCodex can also use the MCP server. Add to your MCP configuration:
{
"mcpServers": {
"portclaim": {
"command": "portclaim",
"args": ["mcp-serve"]
}
}
}Gemini CLI supports MCP servers. Add to your ~/.gemini/settings.json:
{
"mcpServers": {
"portclaim": {
"command": "portclaim",
"args": ["mcp-serve"]
}
}
}Or add project-level instructions in GEMINI.md:
Use `portclaim acquire --service <name> --prefer <port>` before binding to any port.
Release with `portclaim release $PORT` when done.Cursor supports MCP servers. Go to Settings > MCP and add:
{
"mcpServers": {
"portclaim": {
"command": "portclaim",
"args": ["mcp-serve"]
}
}
}Or add a .cursor/mcp.json file in your project root with the same config.
Windsurf supports MCP. Add to your MCP configuration in Settings > Cascade > MCP:
{
"mcpServers": {
"portclaim": {
"command": "portclaim",
"args": ["mcp-serve"]
}
}
}Continue supports MCP servers. Add to your Continue config (~/.continue/config.json):
{
"experimental": {
"modelContextProtocolServers": [
{
"transport": {
"type": "stdio",
"command": "portclaim",
"args": ["mcp-serve"]
}
}
]
}
}Add to Cline's MCP settings (VS Code: Cline > MCP Servers > Configure):
{
"mcpServers": {
"portclaim": {
"command": "portclaim",
"args": ["mcp-serve"]
}
}
}The MCP server speaks JSON-RPC 2.0 over stdio:
portclaim mcp-serveIt exposes five tools: portclaim_acquire, portclaim_release, portclaim_find, portclaim_list, portclaim_reap.
For agents that can run shell commands but don't support MCP:
# Acquire a port
PORT=$(portclaim acquire --service my-service --prefer 3000)
# Find a service
portclaim find --service postgres
# List all claims
portclaim list --json
# Release
portclaim release $PORTAll commands output JSON when stdout is not a TTY, making them easy to parse programmatically.
| Tool | Description | Required Params |
|---|---|---|
portclaim_acquire |
Claim an available port | service |
portclaim_release |
Release a claimed port | port, service, or project |
portclaim_find |
Look up a port by service/project | service or project |
portclaim_list |
List all claimed ports | (none) |
portclaim_reap |
Clean up stale claims | (none) |
Full parameter reference
portclaim_acquire:
| Parameter | Type | Required | Description |
|---|---|---|---|
service |
string | yes | Logical service name |
project |
string | no | Project identifier (defaults to cwd basename) |
prefer |
integer | no | Preferred port to try first |
lease |
string | no | "ephemeral" (default) or "persistent" |
portclaim_release:
| Parameter | Type | Description |
|---|---|---|
port |
integer | Specific port number to release |
service |
string | Release by service name |
project |
string | Release all ports for a project |
portclaim_find:
| Parameter | Type | Description |
|---|---|---|
service |
string | Service name to look up |
project |
string | Filter by project |
portclaim_list:
| Parameter | Type | Description |
|---|---|---|
project |
string | Filter by project |
agent |
string | Filter by agent |
portclaim_reap:
| Parameter | Type | Description |
|---|---|---|
dry_run |
boolean | Report stale claims without removing |
All commands support --json to force JSON output. JSON is used automatically when stdout is not a TTY.
Claim an available port for a service. Prints the assigned port to stdout.
portclaim acquire --service next-dev --prefer 3000
portclaim acquire --service postgres --lease persistent --prefer 5432
portclaim acquire --service api --project my-app --prefer 4000 --jsonPort resolution: --prefer first, then project config range, then global pool.
| Flag | Default | Description |
|---|---|---|
--service |
(required) | Logical service name |
--project |
cwd basename | Project identifier |
--prefer |
(none) | Preferred port. Falls back to services.<name>.port from config if unset. |
--lease |
ephemeral |
ephemeral or persistent. Falls back to services.<name>.lease from config if unset. |
--agent |
$PORTCLAIM_AGENT |
Agent identifier |
--pid |
current process | Override the PID stored in the claim. Use when registering on behalf of another process (e.g., PM2). The owning PID must stay alive — claims are reaped on PID death. |
portclaim release 3000
portclaim release --service next-dev
portclaim release --project my-appportclaim list
portclaim list --project my-app
portclaim list --jsonportclaim find --service postgres # prints: 5432
portclaim find --service next-dev --project my-app --jsonStream real-time claim/release events via inotify (Linux only):
portclaim watch
portclaim watch --json # newline-delimited JSONOn non-Linux platforms, portclaim watch exits with a friendly message;
poll portclaim list --json instead.
Clean up stale claims (dead PIDs, unbound ports):
portclaim reap
portclaim reap --dry-run
portclaim reap --max-age 1h --lease ephemeralRun a registry health check. Reaps stale claims (dead PIDs), releases
phantom claims (claim file present but port not bound), sweeps orphan
.json.tmp files left by interrupted writes, and verifies the config and
lock file:
portclaim doctorStart embedded dashboard:
portclaim http # binds 127.0.0.1:8484
portclaim http --port 8484
portclaim http --bind 0.0.0.0 # opt-in LAN exposureDefaults to 127.0.0.1 so the dashboard is loopback-only. Pass
--bind 0.0.0.0 to expose it on the local network.
The dashboard exposes:
GET /— HTML status viewGET /api/claims— JSON list of all claimsPOST /api/reap— reap stale claims (POST-only to prevent CSRF; the dashboard's "Reap Stale" button is a<form method="post">)
Print a shell completion script:
portclaim completions bash >> ~/.bashrc
portclaim completions zsh > "${fpath[1]}/_portclaim"
portclaim completions fish > ~/.config/fish/completions/portclaim.fishConfig lives at ~/.portclaim/config.toml, created by portclaim init.
[pool]
range = "3000-9999" # Port range for dynamic assignment
exclude = [5432, 6379] # Skip externally managed ports
[projects.my-app]
prefer = 3000 # Try this port first
range = "3000-3009" # Fallback range
[services.postgres]
port = 5432 # Conventional port for this service
lease = "persistent" # Lease default if --lease isn't passed
[services.redis]
port = 6379
lease = "persistent"[services.<name>] entries are consulted by acquire whenever the
service name matches. Both fields are defaults that the CLI can
override:
portis used as the preferred port when--preferis not provided.leasesets the default lease when--leaseis not provided.
~/.portclaim/
├── .lock # flock file for atomic operations
├── config.toml # Port pool and service definitions
└── claims/
├── 3000.json # One file per claimed port
├── 5432.json
└── 8080.json
Acquire flow:
- Take exclusive
flockon~/.portclaim/.lock - Reap stale claims whose PID is dead (ephemeral and persistent)
- Try the effective preferred port (CLI
--prefer, falling back toservices.<name>.portfrom config), then the project range, then the global pool - Verify the candidate port is free via
bind()on127.0.0.1and[::1] - Write the claim atomically (write
.tmp→fsync→rename) - Release the lock, print the port
Crash recovery: every acquire reaps claims whose PID no longer exists,
regardless of lease type. Persistent leases are protected only against
age-based reaping (reap --max-age); they still go away when the
owning process dies. Use --pid <pid> to record a different process's PID
when registering on its behalf.
{
"port": 3000,
"pid": 48291,
"agent": "claude-code",
"project": "my-app",
"service": "next-dev",
"claimed_at": "2026-03-17T14:32:01.123456Z",
"lease": "ephemeral"
}The contrib/ directory includes optional helper scripts:
-
portclaim-start— wrapper that acquires a port, runs your command, and releases on exit:portclaim-start next-dev --prefer 3000 -- next dev --port
-
portclaim-pm2-sync— syncs PM2 process manager state with the portclaim registry:portclaim-pm2-sync # register all PM2 services portclaim-pm2-sync --dry-run # preview changes
| Influence | What portclaim took from it |
|---|---|
| get-port | Probe-then-claim pattern, preference lists |
| Docker PortAllocator | In-memory bitmap tracking, range-based allocation |
| Kubernetes NodePort | Dual-band allocation (preferred vs. fallback) |
| systemd socket activation | Crash resilience via lifecycle-based cleanup |
The gap: no existing tool provides a lightweight, persistent, cross-process port registry for local dev. portclaim fills it with no daemon, crash resilience via PID reaping, and native AI agent integration via MCP.
- Fork the repository
- Create a feature branch
- Run the tests:
cargo test --all-targets cargo clippy --all-targets -- -D warnings cargo fmt --check - Submit a pull request