Building a Custom MCP Server: The Port & Process Manager
We've all been there: you run npm run dev or docker-compose up, only to get slammed with a cryptic error: EADDRINUSE: address already in use :::3000. Now you have to open a terminal, remember the system-specific incantation (lsof -i :3000 or netstat -ano), grab a PID, and manually run a kill command.
What if your LLM copilot could handle that for you? Since Claude, Codex, Cursor, Antigravity, and a growing number of modern developer tools and AI agents support the Model Context Protocol (MCP), we can equip them with tools to inspect open ports and clean up ghost processes directly from chat. This tutorial details how to build a robust, cross-platform Port & Process Manager server using Python's FastMCP and psutil.
Why Not Just Run Shell Commands?
You could theoretically write a basic bash or powershell terminal tool and let the model execute raw commands. However, that approach is highly fragile. Parsing stdout from shell commands like lsof is error-prone, handles cross-platform discrepancies terribly, and introduces command injection vectors. By writing a dedicated python service wrapper utilizing Python's system utilities library psutil, we get clean data structures, structured logs, and cross-platform compatibility out of the box.
1. Setup the Project
Using uv, setup the project environment and add the necessary dependencies. We need mcp[cli] for protocol communication and psutil to handle the heavy OS-level process matching.
# Initialize a new python project
uv init port-manager-mcp
cd port-manager-mcp
# Add dependencies
uv add "mcp[cli]" psutil
2. Code the Server (server.py)
We'll construct our server in a single server.py file. Our server exposes two tools (to search by port and terminate by PID) and one dynamic resource (to fetch the active connections table).
Here is the full implementation:
import logging
import sys
import psutil
from mcp.server.fastmcp import FastMCP
# 1. Initialize the MCP Server
mcp = FastMCP("Port-Manager")
# Configure logging to write exclusively to stderr.
# Writing to stdout will corrupt the protocol channel.
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
logger = logging.getLogger(__name__)
# Helper to retrieve active network connections with non-privileged fallback
def get_connections():
try:
return psutil.net_connections(kind='inet')
except psutil.AccessDenied:
# Fallback for macOS and non-root users: iterate over own processes
connections = []
for proc in psutil.process_iter(['pid']):
try:
connections.extend(proc.connections(kind='inet'))
except (psutil.AccessDenied, psutil.NoSuchProcess):
continue
return connections
# 2. Register Tool: Find process occupying a specific port
@mcp.tool()
def find_process_by_port(port: int) -> str:
"""Find which process is listening on a specific network port.
Args:
port: The local port number (e.g. 3000, 8080, 5432) to inspect.
"""
logger.info(f"Scanning connections for port {port}...")
try:
connections = get_connections()
for conn in connections:
# We look for connections that match our port and are in LISTEN state
if conn.laddr.port == port and conn.status == psutil.CONN_LISTEN:
pid = conn.pid
if pid is None:
continue
# Fetch details of the process, handling permission errors gracefully
try:
proc = psutil.Process(pid)
name = proc.name()
status = proc.status()
cmdline = " ".join(proc.cmdline())
cpu = f"{proc.cpu_percent(interval=0.1)}%"
mem = f"{proc.memory_info().rss / (1024 * 1024):.2f} MB"
except psutil.AccessDenied:
name = "Access Denied"
status = "Unknown"
cmdline = "Access Denied"
cpu = "Access Denied"
mem = "Access Denied"
except psutil.NoSuchProcess:
continue
return (
f"Found active process listening on port {port}:\n"
f"- PID: {pid}\n"
f"- Name: {name}\n"
f"- Status: {status}\n"
f"- Command: {cmdline}\n"
f"- CPU %: {cpu}\n"
f"- Memory (RSS): {mem}"
)
return f"No process found listening on port {port}."
except Exception as e:
logger.error(f"Error checking port {port}: {str(e)}")
return f"Error scanning port {port}: {str(e)}"
# 3. Register Tool: Terminate process by PID
@mcp.tool()
def kill_process(pid: int, force: bool = True) -> str:
"""Forcefully or gracefully terminate a process by its Process ID (PID).
Args:
pid: The Process ID of the process to kill.
force: True to kill instantly (SIGKILL/TerminateProcess), False to request exit (SIGTERM).
"""
logger.info(f"Attempting to terminate PID {pid} (force={force})...")
try:
proc = psutil.Process(pid)
name = proc.name()
# Kill the process
if force:
proc.kill()
action = "forcefully killed"
else:
proc.terminate()
action = "terminated"
return f"Successfully {action} process '{name}' (PID: {pid})."
except psutil.NoSuchProcess:
return f"Error: No process with PID {pid} exists."
except psutil.AccessDenied:
return f"Error: Access denied. Insufficient permissions to terminate PID {pid}."
except Exception as e:
logger.error(f"Error terminating PID {pid}: {str(e)}")
return f"Error: Failed to terminate PID {pid}: {str(e)}"
# 4. Register Resource: Stream list of active listening ports
@mcp.resource("ports://active")
def list_active_ports() -> str:
"""Return a plain text list of all active listening ports on the host system."""
logger.info("Generating list of active listening ports...")
try:
connections = get_connections()
lines = [f"{'PORT':<8} {'PID':<8} {'PROCESS NAME':<20}"]
lines.append("-" * 40)
for conn in connections:
if conn.status == psutil.CONN_LISTEN:
pid = conn.pid
port = conn.laddr.port
name = "Unknown"
if pid is not None:
try:
name = psutil.Process(pid).name()
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
pid_str = str(pid) if pid is not None else ""
lines.append(f"{port:<8} {pid_str:<8} {name:<20}")
return "\n".join(lines)
except Exception as e:
logger.error(f"Error listing active ports: {str(e)}")
return f"Failed to retrieve active port lists: {str(e)}"
if __name__ == "__main__":
mcp.run(transport="stdio")
The macOS & Windows Permissions Nuance
Because network table queries and process termination are sensitive system operations, you may run into permission barriers depending on your platform:
- macOS: By default,
psutil.net_connections()raisespsutil.AccessDeniedon macOS unless run with root privileges (due to OS-level kernel restrictions). To handle this gracefully, the server code falls back to iterating over user-owned processes usingpsutil.process_iter(), which successfully retrieves connections for local development processes you own. If you need to monitor or manage ports owned by other users or the system, you must run the server with root privileges (e.g., usingsudo). - Windows: Standard user accounts can scan active ports, but you may receive
psutil.AccessDeniederrors if you attempt to inspect or terminate system-owned processes (e.g. processes run bySYSTEM). Running your IDE or terminal as Administrator resolves this.
3. Connect to Your LLM Client / Editor
Register the server using the standard configuration format. Whether you're using Cursor (project-scoped or global), Claude Desktop, Codex, Antigravity, Windsurf, or extensions like Cline, the server definition uses the exact same JSON structure.
For a project-scoped setup in Cursor, create a .cursor/mcp.json file at the root of your workspace (or add the entry to your global Claude Desktop/Windsurf config):
{
"mcpServers": {
"port-manager": {
"command": "uv",
"args": [
"--directory",
"/absolute/path/to/port-manager-mcp",
"run",
"server.py"
]
}
}
}
Make sure to replace /absolute/path/to/port-manager-mcp with the literal absolute path of the directory you initialized in Step 1.
Testing the Integration
Once connected, test the capability directly in Composer or Chat. Try starting a test HTTP server on port 8000 using python:
python -m http.server 8000
Now ask your assistant:
"What is listening on port 8000?"
The model will run find_process_by_port(port=8000) and correctly return the python process details. Then request:
"Free up port 8000 so I can run my server."
The assistant will trigger kill_process(pid=...) using the PID resolved in the previous step. The port is immediately freed without you typing a single shell command.
This implementation handles standard cross-platform details, isolates process execution from shell injection risks, and serves as a solid foundation for adding further container control or local server management capabilities.