A refusal takes milliseconds
Eight refusals across three server starts. None started a process, opened a connection or sent a signal.
Once enabled, echo hello took 4.1 ms, process start included.
An MCP server that gives an agent 18 tools for local network, system and SSH work. Running shell commands, opening SSH sessions, running remote commands and killing processes stay denied until the operator opts in.
It began as a way to let an agent diagnose a device on the LAN itself, instead of listing commands for a person to type. That raised the real design question: the model, not the operator, decides when a tool runs. So the four tools that run commands, open SSH sessions or end processes start switched off, and the switch sits in the server's environment, where no tool call can reach it.
Plays the six stages below in order, about 58 seconds. Scroll or press Esc to stop.
Captured by sending MCP JSON-RPC to the server over stdio. Each → line is the request; below it is the tool result text. The handshake is left out. The Read session is an earlier capture on a Mac, where port 8765 was a listener opened for it.
The operator decides which gated tools may run, in the MCP client config that launches the server. Each tool has its own variable, and with no variable the tool stays off.
LNMCP_ENABLE_EXEC opens execute_local_command, LNMCP_ENABLE_SSH_EXEC opens ssh_connect and ssh_execute, and LNMCP_ENABLE_KILL opens kill_process.1, true, yes or on count, in any case and with surrounding spaces ignored. Anything else, a typo included, leaves the tool off.Which values turn a tool on
MCP client config Adapted from the README
{
"mcpServers": {
"local-network": {
"command": "/path/to/local-network-mcp/.venv/bin/python",
"args": ["/path/to/local-network-mcp/network_mcp_server.py"],
"env": {
"LNMCP_ENABLE_EXEC": "1"
}
}
}
}
13 values the tests try, for each of the four gated tools
get_environment_variables calls over stdio, filtered to LNMCP. The config is the README's example with the variable changed to match them.When a client connects, tools/list advertises all 18 tools. Each gated tool that is off carries a note naming the variable that would turn it on, so the agent knows before it spends a turn trying.
LNMCP_ENABLE_SSH_EXEC removes two notes, because ssh_connect and ssh_execute share it. Each other variable removes one, and a value that doesn't count, such as maybe, removes none.18 tools, as the agent sees them
| Tool | Description |
|---|
tools/list responses, one per server start, captured over stdio. Names and descriptions are verbatim; they are grouped here in the order the server lists them.14 tools are always on: local IP, network scan, ping, port checks, listing and closing pooled SSH sessions, system and disk stats, process, connection and environment listings, directory listings and file search.
scan_network is a TCP connect probe, not a ping. An address counts as a host only if one of ports 80, 443, 22, 445 or 8080 accepts within half a second.scan_network on loopback, then check_port
Elapsed0.0 sPlayed at 4× speed
scan_network and check_port calls over stdio on a Mac's loopback, so no packet left it. They come from an earlier capture; neither tool has changed since. Nothing listens or answers at 127.0.0.2 to 127.0.0.5. Times are the measured response times; the animation runs four times faster. Port 8765 was a listener opened for the capture.The check is the first thing each of the four functions does. A refusal returns before a process starts, an SSH client is built or a signal is sent.
It comes back as an ordinary tool result that names the variable to set, so the agent can tell the operator exactly what would allow the call. What the agent can't do is set it.
execute_local_command takes an env argument of its own. That argument goes to the child process, which never starts when the call is refused, so putting LNMCP_ENABLE_EXEC in it changes nothing. A test pins this, and the second call below shows it over the wire.
Five calls to a server with no opt-ins
sleep started for the capture; it was still running after the refusal. Line numbers are from network_mcp_server.py.ClearedEverything above holds on a server started with no opt-ins. Below this line, the operator has set a variable and restarted the server.
Restarted with LNMCP_ENABLE_EXEC=1, an echo hello call crosses the line. The command runs through the shell with the server's permissions, and the result carries the exit code, stdout and stderr.
ssh_execute and kill_process still return the refusal.kill or ssh itself, so turning on LNMCP_ENABLE_EXEC implies the other two, and the README says so.There is no allowlist: once on, the tool runs whatever command it is given. An allowlist mode is first on the roadmap.
Three calls to a server started with LNMCP_ENABLE_EXEC=1
echo hello, with cwd set to /.kill_process sends SIGTERM, or SIGKILL when force is set, then waits up to five seconds for the process to exit. It runs only when the server was started with LNMCP_ENABLE_KILL turned on.
sleep 300 started for the capture, and the same pid met three servers in turn.psutil.Process for a function that raises, and the refusal still comes back clean.One pid, three servers
sleep 300, pid 383470running
Every number here comes from the stdio captures on this page, measured at the client from sending a request to reading its response. The refusals and the command were timed on one Linux machine; the scan was timed in the earlier capture on a Mac.
Eight refusals across three server starts. None started a process, opened a connection or sent a signal.
Once enabled, echo hello took 4.1 ms, process start included.
Five ports, a 0.5-second timeout each, one address after another. For the four empty loopback addresses that predicts 4 × 5 × 0.5 = 10.0 seconds.
An address that refuses at once costs almost nothing. A scan can be narrowed with start_ip and end_ip.
What each start advertised, and what its gated calls did. EXEC, SSH_EXEC and KILL are short for LNMCP_ENABLE_EXEC, LNMCP_ENABLE_SSH_EXEC and LNMCP_ENABLE_KILL.
| Started with | Marked off | Gated calls |
|---|---|---|
| No opt-ins | 4 of 18 | 5 refused |
EXEC=1 | 3 of 18 | exec ran, 2 refused |
EXEC=maybe | 4 of 18 | 1 refused |
SSH_EXEC=1 | 2 of 18 | none sent |
KILL=1 | 3 of 18 | kill ran |
82 are in tests/test_policy.py, and they assert the policy, never that a command ran. The arguments are harmless on purpose: echo hello, a documentation address, a pid that can't exist. If a gate ever broke, a test would fail instead of doing damage.
The other 12, in tests/test_ssh_host_keys.py, run a Paramiko SSH server in the test process over a socket pair, so the real host-key check runs end to end with no network and no sshd.
subprocess.run, psutil.Process, an SSH connection or an SSH client; ssh_connect and ssh_execute share one variable; the variable is read at call time; a tool argument can't open the gate.LNMCP_ variable the server reads is named in the README, and the entry point is callable.known_hosts; a recorded key stays trusted after the switch is turned off; a non-standard port is stored as [host]:port; the file's location can be overridden.AutoAddPolicy or WarningPolicy.The probes check the policy marker rather than success: false, because each tool's catch-all exception handler would turn a probe's AssertionError into a failure result too.
CI runs ruff, pytest and a clean-runner check that all four opt-in tools start denied, on Python 3.11 and 3.12.
execute_local_command runs any shell command with the server's permissions, with no sandbox. An allowlist or denylist mode is first on the roadmap.git clone https://github.com/ahmed-hashim-pro/local-network-mcp.git cd local-network-mcp python3 -m venv .venv source .venv/bin/activate pip install -e . # Prints the policy and exits. python -c "import network_mcp_server as s; print({t: s.is_tool_enabled(t) for t in s.DESTRUCTIVE_TOOLS})" # Register with Claude Code. The gated tools stay off. claude mcp add local-network -- /path/to/local-network-mcp/.venv/bin/python \ /path/to/local-network-mcp/network_mcp_server.py # The tests need no network and no credentials. pip install -e ".[dev]" pytest
It isn't on PyPI, so install from a clone. To opt in, add an env block to the server's entry in your MCP client config, for example "LNMCP_ENABLE_SSH_EXEC": "1", and restart the server. Add only the ones you want.
With SSH on, host keys are still checked against ~/.ssh/known_hosts, /etc/ssh/ssh_known_hosts and the server's own file. A changed key is always refused. An unknown host is refused unless LNMCP_SSH_TRUST_ON_FIRST_USE is set, and then its key is recorded in the server's own file (~/.config/local-network-mcp/known_hosts by default, or LNMCP_SSH_KNOWN_HOSTS), never in yours.