Port Keeper MCP
Leases development port blocks to each clone or git worktree, writes .env files and resolves service names to URLs so parallel agents get their own ports.
What can you do with Port Keeper MCP?
- Fifth working copy, no arithmetic
- Open the right admin without remembering anything
- Share a database between two app environments
- Find out who is squatting on your port
- Migrate an existing project without breaking bookmarks
Install
The installation steps are in the documentation below. Documentation
Documentation
From the project's README on GitHub, refreshed weekly. Links open on GitHub.
Contents
port-keeper-mcp
A local ledger for development ports, with an MCP server on top, written in Go. It gives every parallel coding agent its own set of ports, so each one can run the whole stack at the same time as the others.
The problem
Coding agents pay off when you run them in parallel: five agents, five
branches, five working copies. git worktree gives each agent its own files.
It does not give each agent its own ports.
For a web service, that is where it stops. Every working copy carries the same
.env, so every copy's dev server, API and database ask for the same numbers.
The first agent to start gets them. The others hit address already in use,
or, worse, quietly run their tests against another agent's server and
database.
An agent that cannot bring up its own stack cannot check its own work. It can write code, run static analysis, and unit-test the pure functions. It cannot run an end-to-end test or any test that goes through a database: those need the application running, which takes one environment per agent, each holding as many ports as the stack has services. Without that, every agent queues for the single environment that works. A small CLI tool never notices. A large web service does: the work is parallel until it has to be verified, then it is serial, and the reason for running agents in parallel is gone.
Agents run at the same time and do not talk to each other, so a naming convention or a wiki table of port ranges will not hold. Handing out ports has to work like a protocol: one place that every agent asks, and that answers correctly when several ask at once.
What port-keeper does
port-keeper is that place.
- One environment per agent. Each working copy gets a slot, and each slot gets its own block of ports. No two leases ever share a port, across every project on the machine, even when several agents ask at the same moment.
- Agents ask instead of guessing. Over MCP an agent gets the URL of a service by name, and a hook puts the slot's ports into the agent's shell at session start. A fresh worktree is refused the main copy's ports until it has a slot of its own.
- Nothing to keep running. No daemon, no proxy, no network listener. Every command opens the ledger, does its work and exits.
A project declares its services in a small manifest (names and env-var names,
never numbers). port-keeper hands each slot (a parallel copy of the project:
one per working copy, whether that is a clone or a git worktree) a block of
ports from a pool, writes them into your .env, and answers "what is the URL
of shop/5/admin?" from the CLI or over MCP. Neither you nor your coding agent
has to remember a port number again. It runs only when called, writes nothing
outside the ledger except the files you point it at inside your project, and
never opens a network listener.
See docs/DESIGN.md for the full design (Japanese).
Small on purpose
Before writing port-keeper we surveyed what already existed. The tools fall
into a few kinds: local proxies that hide ports behind hostnames and have to
stay running; agent-coordination platforms where ports are one feature among
sessions, locks and messaging; wrappers that want to launch your dev server
for you; free-port finders that keep no record of who owns what; and port
registries for agents that know nothing about parallel working copies or
.env files. Several are good at what they do. None of them was a ledger and
nothing but a ledger.
port-keeper is the simplest thing that solves the problem above:
- One job. It decides which port belongs to which service of which working copy, and answers when asked. Starting servers stays with your task runner. Pretty hostnames stay with a proxy, if you want one. port-keeper can feed both and replaces neither.
- Few parts. One static binary, one SQLite file, one small manifest per project. No daemon, no proxy, no DNS, no certificates, no account.
- A small surface for agents. Eight MCP tools, six of them on by default. An agent takes in the whole interface at a glance, and it costs almost no context.
- Fits what you already run. It writes plain environment variables into
.env, your shell, direnv or mise. Your dev command does not change. docs/examples/ shows the wiring for mise, direnv, docker compose, Vite, Playwright and reverse proxies. - Easy to leave. Remove the MCP entry and the hook, then delete the binary
and the ledger file. The
.env.localit wrote is an ordinary file and keeps working.
What it leaves out is listed, with the reasons, under Non-goals.
Install
port-keeper is one static binary with no runtime dependencies. Put it on your
PATH: your shell, the agent hooks and your MCP client all call the same
port-keeper command, so one install serves all three and there is only ever
one version on the machine.
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/gridhra/port-keeper-mcp/main/scripts/install.sh | sh
# Windows (PowerShell). Built and cross-compiled in CI; not yet verified on a real Windows machine
irm https://raw.githubusercontent.com/gridhra/port-keeper-mcp/main/scripts/install.ps1 | iex
The script picks the archive for your OS and CPU from the latest
GitHub Release, refuses
to install anything unless its SHA-256 matches the release's checksums.txt,
and places port-keeper in ~/.local/bin. It never asks for sudo. Set
PORT_KEEPER_INSTALL_DIR to install elsewhere and PORT_KEEPER_VERSION to pin
a version. Run it again to update; it replaces the one binary and leaves your
ledger and config alone.
To do the same by hand, and to check that the archive was built by this repository's release workflow from the tagged source:
gh release download --repo gridhra/port-keeper-mcp --pattern '*darwin_arm64.tar.gz' --pattern checksums.txt
shasum -a 256 -c --ignore-missing checksums.txt
gh attestation verify port-keeper_*_darwin_arm64.tar.gz --repo gridhra/port-keeper-mcp
tar -xzf port-keeper_*_darwin_arm64.tar.gz port-keeper && mv port-keeper ~/.local/bin/
With a Go toolchain (1.25 or newer):
go install github.com/gridhra/port-keeper-mcp/cmd/port-keeper@latest
There is deliberately no container image and no npx launcher; see
Non-goals.
Quickstart
cd your-project
port-keeper init # writes port-keeper.toml (names only) and gitignores .env.local
$EDITOR port-keeper.toml # one [[service]] per port your project needs
port-keeper env # leases a block, writes the managed section of .env.local
port-keeper url web # http://localhost:20000 (add --open to launch the browser)
A second working copy of the same project gets its own slot:
git worktree add ../your-project-hotfix -b hotfix && cd ../your-project-hotfix
port-keeper slot new # leases a fresh block for this working copy
port-keeper env # writes its .env.local; nothing collides with the first copy
Give your coding agent the same view:
claude mcp add --scope user port-keeper -- port-keeper mcp
Then add the hook and the two-line instruction from Working with coding agents.
Why the usual workarounds fail
- Offset formulas break. "Base port + (slot − 1) × 1000" works until two services sit exactly 4000 apart; then slot 5 collides with slot 1 and the project is stuck at four environments with thousands of ports unused.
- Cross-project collisions are nobody's job. Every project on a laptop carves its own thousand-range, and the OS grabs a few conventional ports on top (on macOS, the AirPlay receiver in Control Center owns 5000 and 7000). Whoever starts second loses.
- Numbers leak. People paste
localhost:3001into chat, docs and issues because they had to memorise it. A name (shop/3/admin) leaks nothing and resolves on every machine. - Agents guess. A coding agent that needs a server picks 8000 or 3000 and tramples whatever was there. Give it a tool to ask instead.
Use cases
Fifth working copy, no arithmetic
"Spin up another copy of this project for the hotfix branch."
port-keeper slot new hotfixleases a fresh block, andport-keeper envwrites the.env.localsection; your usualmise run devstarts everything. No collisions with the other four slots or with any other project on the box.Open the right admin without remembering anything
"Open the admin screen of slot 3."
port-keeper url shop/3/admin --open, or theresolve_urlMCP tool from your agent. One round trip, one URL.Share a database between two app environments
"Slot 2 should use slot 1's MySQL and mail catcher."
port-keeper slot new 2 --infra-from 1. Services taggedtier = "infra"resolve to slot 1's ports; everything else gets its own.Find out who is squatting on your port
"The API says address already in use."
port-keeper statuscompares the ledger with what is actually listening and names the PID. When the listener's working directory is outside this working copy (and it is not a container runtime), the lease is markedhijacked; otherwise it is simplyactive. It never kills anything;port-keeper reassign <service>moves that service to a free port instead.- Migrate an existing project without breaking bookmarks
Keep the main environment on its historic numbers with
port-keeper pin web=3000 admin=3001 api=8080 --reason "docs and bookmarks". Pins are arbitrated across every project in the ledger, so two projects cannot both claim 3000. Every other slot moves to the pool (update those bookmarks withport-keeper url), anddoctorreminds you tounpinonce the docs sayport-keeper urlinstead.
Working with coding agents
port-keeper knows nothing about any particular agent. The contract every client uses is two commands:
port-keeper context --json: where am I (project, slot, whether this working copy is ready to use), whether the rendered.env.localis out of date (env_stale), and what to do next. No port numbers.port-keeper env --format export --if-present: the environment for the current slot asexportlines; silent outside a project.
Everything client-specific is an adapter on top of those two. port-keeper
hook claude is the adapter for Claude Code's hook protocol; it lives in one
file and other agents can get their own without touching the core.
Claude Code
Register the MCP server once, user-wide (there are no numbers in the config, only the command):
claude mcp add --scope user port-keeper -- port-keeper mcp
Add the hook in ~/.claude/settings.json. On SessionStart it exports this
slot's ports into the agent's shell and adds the project and slot to Claude's
context; on CwdChanged (Claude cds into another working copy) it swaps the
exported ports for that working copy's slot. Outside a project the command
prints nothing and exits 0, so the hook never fails a session.
{
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "port-keeper hook claude" } ] }
],
"CwdChanged": [
{ "hooks": [ { "type": "command", "command": "port-keeper hook claude" } ] }
]
}
}
Tell the agent the tool exists
The tool only helps if the agent knows it exists. Agents left to themselves
start python -m http.server 8000 or vite --port 3000 and trample whatever
was there. Two lines in your user-level instructions file (for Claude Code,
~/.claude/CLAUDE.md; other agents have an equivalent) are enough to stop
that, because they apply to every project on the machine:
## Local ports
Local dev ports on this machine are managed by port-keeper (MCP server `port-keeper`).
Never choose a port number yourself and never start a server on an ad-hoc port.
To find where something runs, call the `resolve_url` tool
(or run `port-keeper url <project>/<slot>/<service>`).
If a task needs a new port, add a service to `port-keeper.toml` and run `port-keeper env`.
To give a command this slot's ports, prefix it with `eval "$(port-keeper env --format export)"`; never paste numbers into a command or a file.
Refer to services by name (`shop/3/admin`), never by number, in docs, issues and chat.
Why user-level and not per-project: the servers that cause trouble are the ad-hoc ones an agent starts in a scratch directory or in a project that has no manifest yet. A project-level file never reaches those.
If you run several agents (Claude Code, Codex, Cursor, …), put the same block in each one's global instructions. The MCP registration is per client; the ledger is shared.
Guarantees
- No two leases share a port, across every project and slot on the machine, including pinned legacy ports. Enforced by a UNIQUE constraint in the ledger and a write transaction around every allocation.
- Pool only. Allocation never leaves the configured pool (default
20000–31999), which avoids the conventional ports, the ports macOS services
take, and the OS ephemeral ranges. The only way out of the pool is an
explicit, reasoned
pin. - Stable. A slot keeps its block until you release it, across reboots.
- Nothing is reclaimed behind your back. A slot keeps its ports until you
release it, however long its servers stay down.
port-keeper gclists the slots that look abandoned (nothing listening forstale_days, or the working copy is gone) and releases them only with--yes. A dev server that has been off for a month must not find its ports handed to another slot when it comes back. - One slot per working copy. A working copy that has no slot of its own
is refused the default slot's ports:
env,url,statusand the MCP tools ask you to runslot newfirst (or to pass--slot 1if sharing the main slot is what you want). That is what keeps a freshgit worktree addfrom starting servers on the main working copy's ports. - No listener, no daemon. Every command opens the ledger, does its work, and exits. Nothing of port-keeper's is ever reachable over the network.
- No secrets. The ledger has no column for passwords, tokens or
connection strings, and
.envrendering never writes into a tracked file.
Non-goals
These are deliberate. Please read this section before opening a feature request for one of them; the reasoning is the answer, and a request that argues with the reasoning is far more useful than one that restates the feature.
No reverse proxy / named hosts (http://admin.shop.localhost)
port-keeper's goal is that you do not have to think about port numbers, not
that you never see one. Once port-keeper url shop/5/admin (or the
resolve_url tool) gives you http://localhost:23417, the job is done;
--open even opens it for you.
A proxy would add a resident process that every slot depends on. When it
stops, every environment becomes unreachable at once, which is a worse failure
mode than any port collision. It needs a privileged port (80/443) on macOS,
drags TLS and WebSocket forwarding into scope, and contradicts the rule that
port-keeper never listens. Finally, browsers scope cookies and local storage
by origin including the port, so distinct ports are exactly what keep your
slots' sessions apart; hiding them behind one hostname would remove that
isolation. If you want pretty hostnames anyway, feed
port-keeper env --format json to a proxy that already does this well
(portless, localias, devenv; docs/examples/reverse-proxy.md
shows how). port-keeper will not grow one.
No process management (start / stop / restart / kill)
port-keeper reserves a number and tells you what it is. Who starts the server
on that number, when, and under which supervisor is the job of your task
runner (mise, just, direnv, docker compose, an IDE). port-keeper will
report that a port is in use and by which PID, but it will never kill a
process: a tool that an AI agent can call should not be able to take down
another environment's dev server by mistake or through prompt injection.
No daemon
Every command opens the SQLite ledger, works inside a write transaction, and
exits. Concurrent CLI or MCP processes cannot double-allocate because SQLite
serialises the writes and the port column is UNIQUE. A daemon would buy
pub/sub and TTL leases at the cost of a second access path to the ledger
(socket or HTTP) and one more thing to keep alive. The requirements do not
need it.
No shared or synced ledger
The ledger lists which services listen on which ports on your machine. That is exactly what an attacker enumerates first, so it stays local, mode 0600, and is never synced, committed or uploaded. Teams share the manifest, which has names and templates but no numbers.
No secrets in the ledger
There is no column for passwords, tokens or connection strings, and there will not be one. If a feature seems to need a secret, it belongs in your secret manager.
No allocation outside the pool
pin exists for migrating an existing project and nothing else. It requires a
--reason, is limited to one slot per project, and is arbitrated across every
project in the ledger; doctor nags you about it until you unpin. New
projects should never pin.
No container image
port-keeper has to see four things that belong to your machine: the host's
network stack (it checks a port by trying to bind it), the host's process
table (lsof tells it who is listening), the working directory your shell or
agent is in (that is how it finds the project and the slot), and the ledger
under your home directory. A container exists to isolate exactly those four.
Inside one, port-keeper would probe an empty network namespace and report
every port free, would not find your project, and would forget its leases when
the container exits. On macOS and Windows the container runtime itself runs in
a Linux VM, so not even host networking reaches the ports your dev servers
hold.
Mounts and flags can paper over part of this on Linux, and each one hands the
container another piece of the host until nothing is left of the isolation. So
there is no image, and port-keeper will not be listed anywhere as an OCI
package. The binary is a single static file; Install puts it on
your PATH with one command.
An npx launcher is out for a related reason. A launcher that fetches the
server on demand gives your MCP client a server, but gives your hooks and your
shell no port-keeper command, and it leaves two copies of different versions
sharing one ledger.
If you still want one of these
Open an issue that starts from the reasoning above and says which part of it does not hold in your case. "It would be convenient" is already accounted for; what changes the answer is a failure mode we did not consider, or a case where the non-goal blocks the actual goal (not thinking about port numbers).
Security model
port-keeper is a local, non-network tool. The ledger is a map of what listens
where on your machine; it is stored under ~/.local/state/port-keeper/ with
mode 0600 and is never transmitted. Against another user on the same machine
that is sufficient. Against a process running as you it is not, and no
local tool can be: such a process can already run lsof -i. What port-keeper
does about that is refuse to be a richer map than lsof (no secrets, no
tenant names, no descriptions beyond a short label) and refuse to disclose
more than the current project to an agent unless asked explicitly.
port-keeper doctor checks the permissions, the .gitignore, that nothing
of port-keeper's is listening, and that your MCP client configuration carries
no port number or token; it names the file and the entry, never a value. See
SECURITY.md for the reporting policy and what is in scope.
Reference
Manifest (port-keeper.toml)
Lives at the repository root and is meant to be committed. It contains names and templates; the numbers live only in your local ledger.
[project]
name = "shop"
block_size = 32 # ports per slot; grows to a second block if exceeded
slot_default = "1" # the slot a fresh checkout resolves to; the only slot that may pin
[[service]]
name = "web"
env = "WEB_PORT"
proto = "http"
label = "Storefront" # optional, short; cut to 64 chars before it reaches an agent
[[service]]
name = "admin"
env = "ADMIN_PORT"
proto = "http"
[[service]]
name = "api"
env = "API_PORT"
proto = "http"
[[service]]
name = "db"
env = "DB_PORT"
proto = "tcp" # no URL for tcp services
tier = "infra" # shareable via `slot new --infra-from`
[[derive]] # values built from ports, never typed by hand
env = "VITE_API_BASE"
value = "${url.api}"
[[derive]]
env = "ALLOWED_ORIGINS"
value = "${url.web},${url.admin}"
[render]
dotenv_path = ".env.local" # must be gitignored; `port-keeper env` refuses tracked files
dotenv_marker = "port-keeper" # marker text of the managed block
host = "localhost" # host used in every URL
Template variables: ${port.<service>}, ${url.<service>} (http/https
services only), ${slot}, ${slot.infra}, ${project}, ${block.base}.
CLI
| Command | Role |
|---|---|
init [--name] [--here] | Write the manifest skeleton and the .gitignore entry. --here writes into the current directory instead of the git top level (monorepos) |
slot new [name] [--from-branch] [--infra-from s] [--no-bind] / slot ls [--pins] / slot rm name [--force] [--cascade] | Create, list, release slots. new binds the current working copy to the slot unless it is already bound to another one; --from-branch names the slot after the current git branch |
env [--format f] [--stdout] [--if-present] | Render the current slot; dotenv (default) rewrites the marker block in .env.local. Formats: dotenv, export, json, mise, direnv, claude-env (alias of export) |
url <service> / url <project>/<slot>/<service> [--open] | Print (or open) one URL |
status [--json] | Ledger vs. what is listening; --json for scripts |
context [--json] [--if-present] | Project, slot, readiness and guidance for this working copy, and env_stale when .env.local no longer matches the manifest and the ledger. No numbers. The client-agnostic contract for agents and shells |
gc [--yes] | List (or release) slots idle for longer than stale_days, and slots whose working copy is gone |
doctor [--fix] | Permissions, .gitignore, pool sanity, stale slots, pins, no listener of our own, MCP client configs without numbers or tokens, tracked .env files that fix a managed variable, .env.local up to date. Exits 1 when a check fails |
pin <service> <port> --reason t [--force] or pin web=3001 api=3002 --reason t / unpin <service>… or unpin --all | Migration aid; see Guarantees. The batch form pins a whole legacy layout in one command |
reassign <service> | Move a service to another pooled port (after status reports hijacked) |
mcp | Serve MCP over stdio |
hook claude | Claude Code adapter for SessionStart and CwdChanged: context + env translated into $CLAUDE_ENV_FILE and hook JSON; silent outside a project |
completion <shell> | Print the completion script for zsh, bash or fish (eval "$(port-keeper completion zsh)"); candidates are names from the manifest and the ledger, never numbers |
version (or --version) | Print the version |
--slot <name> | Global flag: act on that slot instead of the resolved one |
A slot is resolved from --slot, then the slot bound to the current working
copy, then PORT_KEEPER_SLOT, then the manifest's slot_default. When a
stale PORT_KEEPER_SLOT in the shell disagrees with the binding, the binding
wins and a warning says so.
MCP tools (8)
| Tool | Role |
|---|---|
current_context | The project and slot resolved from the working directory, and the service names. No numbers (read-only) |
resolve_url | Full URL for one service, e.g. http://localhost:23417. Other projects require an explicit project argument (read-only) |
resolve_port | The bare port number for one service (read-only) |
render_env | Every env var for the current slot, in the requested format. Leases a port for any service that has none yet (idempotent) |
status | Ledger vs. reality for the current slot: leased (nothing listening), active, stale, or hijacked, with the owning PID when known (read-only) |
slot_new | Lease a block for a new slot; returns the existing slot if the name is taken or the working copy is already bound (idempotent) |
slot_release | Release a slot. Refuses while anything is listening. Requires confirm:true. Not registered unless enabled in config |
list_all_projects | Names of every project and slot in the ledger, no numbers. Not registered unless enabled in config |
Every tool accepts an optional cwd so a session that moves between working
copies keeps getting the right slot. Only resolve_url, resolve_port and
render_env carry port numbers, and only for what was asked; the other tools
speak in names, so that numbers do not accumulate in agent transcripts.
Configuration (~/.config/port-keeper/config.toml)
All optional:
stale_days = 30 # top-level key; must come before any [table]
[pool]
ranges = [[20000, 31999]]
deny_ports = [27017, 28015, 29092]
[mcp]
enable_release = false # set to true to register slot_release
enable_list_all = false # set to true to register list_all_projects
Unknown keys are an error, so a misplaced setting cannot silently do nothing.
Development
Go 1.25 or newer (the SQLite driver and the MCP SDK require it; go downloads
the toolchain automatically when GOTOOLCHAIN is left at its default).
go test ./... # unit, property and in-process MCP tests
go vet ./... && gofmt -l .
sh scripts/readme_sync_check.sh # the three READMEs mirror each other (CI runs it too)
sh scripts/agent_eval.sh --list # the manual agent eval; see RELEASING.md
Releases are cut by pushing a v* tag; RELEASING.md has the
procedure (Japanese). sh scripts/install_test.sh tests the install script.
sh scripts/agent_eval.sh gives an MCP-connected Claude Code three everyday
tasks and counts round trips and leaked numbers; it runs a real model, so it
is a manual gate before each release rather than a CI job.
The project uses OpenSpec (openspec/) for change proposals; run
openspec list to see them.
Package layout: internal/config (pool, paths) / internal/manifest /
internal/ledger (SQLite, allocation) / internal/probe (bind checks,
listener discovery) / internal/gitx / internal/render (dotenv, export,
json, mise, direnv, claude-env) / internal/app (resolution, sync, status,
pins) / internal/cli (commands; hook_claude.go is the Claude Code
adapter) / internal/mcpserver (stdio server) / cmd/port-keeper.
Name
A keeper holds the keys and the ledger and tells you which door is which;
it does not build the doors or open them for you. The -mcp suffix follows
the naming of MCP servers; the binary is just port-keeper.
License
MIT. See LICENSE.
Frequently asked questions
What is Port Keeper MCP?
Leases development port blocks to each clone or git worktree, writes .env files and resolves service names to URLs so parallel agents get their own ports.
How do I install Port Keeper MCP?
Follow the installation steps in its documentation, shown on this page and in its repository.
Is Port Keeper MCP open source?
Yes. Its code is on GitHub under the MIT license.
Who maintains Port Keeper MCP?
It is published by gridhra. AI.tn lists it but does not maintain or endorse it.
From the community awesome-mcp-servers lists and the project's GitHub repository. Listing is not an endorsement: review a server's code and permissions before giving it access to your data.