pm2-mcp
Provides structured read and limited write access to PM2 process manager services on a host. Read tools list all PM2 services (with optional status filtering) and return typed fields like status, uptime, restarts, CPU and memory; fetch full detail for a single service (script path, cwd, args, log paths, creation time); tail recent log output; and report server/PM2 health including version and counts by status. Write tools restart, stop, start (resume) and gracefully reload services — validating service names first — plus persist the process list to disk and flush logs. Deliberately does not register new processes, delete processes, or manage the PM2 daemon itself.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
pm2-mcp
An MCP server that gives agents structured read and limited write access to PM2 services. Built with FastMCP, transport is streamable-http bound to localhost.
I built this because every other approach to PM2 inspection from an agent involves either raw shell access (homelab-ops run_command) or scraping human-readable pm2 status output. This server speaks directly to pm2 jlist, returns typed fields, and validates service names before issuing any write operations.
flowchart LR
subgraph shell["Shell access"]
direction LR
s1["agent"] -->|"run_command\npm2 jlist"| s2["unparsed\nJSON string"]
end
subgraph mcp["pm2-mcp"]
direction LR
m1["agent"] -->|"list_services"| m2["typed fields\nstatus · uptime_ms\ncpu_pct · memory_mb"]
endTools
Read
Tool | Description |
| List all PM2 services. Optional |
| Full detail for one service by name — script path, cwd, args, log file paths, created_at, plus all summary fields. |
| Tail recent log output for one registered service. |
| Server metadata and PM2 health summary — the host/port actually bound, the env-allowlist mode ( |
Write
Tool | Description |
| Restart a service. Validates the name first — returns |
| Stop a service. Does not remove it from the PM2 process list. |
| Resume a stopped service already registered in PM2. Does not register new processes. |
| Gracefully reload a service (zero-downtime). Preferred over |
| Persist the current PM2 process list to disk. Call after write operations to survive reboots. |
| Clear log files for a service. |
Response shape — list_services
[
{
"name": "my-service",
"pm_id": 12,
"status": "online",
"pid": 18432,
"uptime_ms": 3720000,
"restarts": 0,
"cpu_pct": 0.2,
"memory_mb": 48.5,
"exec_mode": "fork_mode"
}
]get_service extends this with script, cwd, args, log_file, error_file, and created_at.
Related MCP server: agent-comms-mcp
Non-goals
Things this server deliberately does not do. Each is a decision, not a gap:
It does not register new PM2 processes.
start_serviceresumes a process PM2 already knows about; it will not create one. Registering a process means deciding its script, interpreter, working directory, log paths and environment — that belongs in a reviewedecosystem.config.js, not in an agent tool call.There is no
deleteverb, and there will not be one.pm2 deletediscards the process definition, and re-creating it re-captures the calling shell's environment — the exact mechanism behind vikunja#767. The destructive verb with the worst failure mode is the one least worth automating.It has no authentication, by design. It binds loopback only and is expected to stay that way. Adding auth would imply the port could safely be exposed, which is not the posture this server is built for. See docs/threat-model.md.
It does not publish an installable artefact. No PyPI package, no version badge. It is built from a checkout and installed into that checkout's venv from
uv.lock, then run as a PM2 process.It does not manage the PM2 daemon itself — no
pm2 kill, no daemon resurrection, no startup-script installation. Those are host administration, not process inspection.
Setup
Requirements
Python 3.11+
PM2 installed and in PATH for the user running the server
Dependencies are locked in uv.lock. Install the server into a venv, from the lock and
non-editable, so the running code is the installed package rather than the checkout:
uv sync --frozen --no-dev --no-editableThis puts the pm2-mcp entry point at .venv/bin/pm2-mcp.
Run as a PM2 process (recommended)
This repository ships the ecosystem.config.js it actually deploys with — use that rather
than the sketch this section used to contain:
env -i HOME="$HOME" PATH=/usr/bin:/bin PM2_HOME="${PM2_HOME:-$HOME/.pm2}" \
pm2 start ecosystem.config.js --only pm2-mcp
# check what it captured before saving: docs/operations.md, "Starting it cleanly"
pm2 saveThree things in it are host-specific: script (the venv's pm2-mcp entry point), cwd,
and the log paths. Leave the rest alone — in particular env: {}, which is empty deliberately and
carries a comment explaining why. See examples/ and
docs/operations.md.
Never a bare pm2 start from an interactive shell. pm2 start hands the calling
shell's entire environment to the app, and pm2 save writes it to disk. That is how a live
credential ended up in a world-readable dump.pm2 for nine days. env -i above is what
prevents it, and docs/operations.md has the check
to run before pm2 save.
The server manages itself like any other PM2 service — it will appear in its own list_services output.
Run directly
.venv/bin/pm2-mcp # 127.0.0.1:8486
.venv/bin/pm2-mcp --port 9000 # override the port only
.venv/bin/pm2-mcp --host 127.0.0.2 --port 9000 # both
.venv/bin/pm2-mcp --help # full usageNon-loopback binds are refused; see Bind address and port.
Bind address and port
The bind is resolved from three sources, in this order of precedence:
Precedence | Source | Example |
1 (highest) | Command-line flags |
|
2 | Environment variables |
|
3 (lowest) | Built-in defaults |
|
Precedence is per-field, not all-or-nothing: passing only --host leaves the port to
MCP_PORT or the default.
Variable | Default | Description |
|
| Bind address. Must be loopback. |
|
| Port for the MCP server |
| unset | Extra |
|
| Environment allowlist mode: |
.env.example lists the bind variables. The server does not load a .env file — set these in
the process environment (or pass the flags) instead. Blank environment variables are treated
as unset and use the defaults shown above.
The bind is loopback-only, and that is enforced
--host and MCP_HOST accept 127.0.0.0/8, ::1 and localhost. Anything else exits
non-zero instead of starting — including 0.0.0.0, a LAN address, and any hostname.
This is deliberate and there is no override flag. pm2-mcp has no authentication of any
kind, and its write verbs can stop or restart any PM2 process on the host. A --host that
accepted 0.0.0.0 would turn a documented-safe posture into a one-word footgun, so the
refusal is in code rather than in a comment. See docs/threat-model.md.
A hostname is refused rather than resolved: a name can point anywhere, and can be repointed after the check passes without the process restarting, so resolution is not a security boundary.
Note: these flags were accepted but silently ignored before v0.4.0 —
server.pyhad no argument parsing and read onlyMCP_HOST/MCP_PORT. They are live as of vikunja#770.
Wiring to Claude Code
Register it as an HTTP server:
claude mcp add --transport http pm2 http://127.0.0.1:8486/mcpor, per project, in .mcp.json:
{
"mcpServers": {
"pm2": {
"type": "http",
"url": "http://127.0.0.1:8486/mcp"
}
}
}Security
The server binds loopback only, and that is enforced in code rather than defaulted — a non-loopback --host or MCP_HOST exits non-zero instead of starting, with no override flag. See Bind address and port. Any client that can reach port 8486 can restart or stop services, because there is no authentication. That is intentional for local agent use: keep it loopback-only and don't proxy it externally.
A loopback bind keeps other machines out but not a browser on the same host, which DNS rebinding can point at 127.0.0.1. So every HTTP request is also checked for its Host and Origin headers (since v0.5.0): a Host that is not 127.0.0.1, localhost, [::1] or the bound address, followed by : and a port, gets 421, and a request carrying a non-empty Origin gets 403, because only browsers send one and no browser is a client. The port match is mcp's prefix wildcard, so it accepts anything after the :; a browser can only send digits there. PM2_MCP_ALLOWED_HOSTS adds exact extra Host values; it is unset by default, and a client connecting to 127.0.0.1 or localhost does not need it.
The write tools (restart_service, stop_service, start_service, reload_service, flush_logs) and get_logs validate service names against the live PM2 process list before acting. An unrecognized name returns {ok: false, error: "service '...' not found"} without touching PM2.
Before invoking the pm2 CLI the server scrubs its own environment. Today that means a denylist: PM2's IPC variables, and anything matching the CLAUDE prefix. pm2 start and pm2 restart --update-env copy the caller's whole environment into the target app and pm2 save persists it, so this is the difference between a credential staying in memory and being written to disk.
A named allowlist (vikunja#610) is also implemented and will replace that prefix-match denylist once enabled — but it is not live yet. It runs in shadow mode by default: it computes and logs which variables it would withhold, per pm2 invocation, and changes nothing. PM2_MCP_ENV_MODE=enforce turns it on; the decision is tracked in vikunja#771, and since v0.5.0 an integration test runs every write verb against a real pm2 in both modes. get_status reports which mode is running. Which environment reaches the pm2 child, and why, is the most consequential thing about running this server — see docs/threat-model.md §2 for the full reasoning rather than re-deriving it here.
Full posture, including the gaps that are accepted rather than fixed: docs/threat-model.md.
Testing
uv sync --frozen --extra dev
uv run pytest -vThe default run mocks _run_pm2, so no PM2 installation is required. The write verbs are
also tested against a real pm2 CLI and a private, temporary daemon (vikunja#771). That run
needs pm2 on PATH and is separate:
uv run pytest -m integration --no-cov -vCoverage and its floor are configured in pyproject.toml, so a bare pytest enforces them; there is no separate CI-only flag. Changes to _clean_env or _run_pm2 should also pass the mutation gate:
rm -rf mutants && PATH="$PWD/.venv/bin:$PATH" ./scripts/mutation-gate.shSee CONTRIBUTING.md for what that gate does and, more importantly, what it does not cover.
Documentation
Request flow, module lifecycle, and the environment boundary | |
Deploying, health checks, recovery | |
What's protected, what isn't, and why | |
Real client config and a worked crash-loop diagnosis | |
Dev setup, test loop, mutation gate | |
Reporting a vulnerability |
License
MIT — see LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
MCP server for agentverse documentation, generated by doc2mcp.
MCP Server for an Agent Task Marketplace
An MCP server that provides read access to your cloud storage providers, bank accounts and more.
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
Related MCP Servers
- AlicenseAqualityAmaintenanceMCP server exposing memory search, index, and stats tools for agents, with honesty guards to prevent re-litigation of settled decisions.538 PyPI6AGPL 3.0
- AlicenseNot gradedqualityAmaintenanceMCP server for permissioned, structured agent-to-agent communications, enabling agents to coordinate and negotiate through scoped, typed messages with authentication and audit logging.234 PyPIMIT
- FlicenseAqualityCmaintenanceAn MCP server that gives an agent terminal and file-management capabilities, including running shell/Python commands, searching and reading files, and creating/editing/deleting files and folders.21-
- AlicenseAqualityCmaintenanceMCP server that lets agents launch, drive, and read game instances over HTTP, with a dynamic toolset discovered from the running game.71MIT