local-network-mcp

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.

View on GitHub Security model

Plays the six stages below in order, about 58 seconds. Scroll or press Esc to stop.

Language
Python 3.11+
Protocol
MCP over stdio, on mcp 1.29.1
Tools
18, of which 4 are gated
Tests
94 passing, no network or credentials needed
SSH and processes
Paramiko and psutil
License
MIT
python network_mcp_server.py → tools/call execute_local_command {"command": "echo hello"} { "success": false, "policy": "default-deny", "tool": "execute_local_command", "error": "execute_local_command is disabled by default because it can run commands or change state. Set LNMCP_ENABLE_EXEC=1 in the server's environment (the \"env\" block of your MCP client config) and restart the server to enable it." }

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.

1Set the switches before the server starts

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.
  • Only 1, true, yes or on count, in any case and with surrounding spaces ignored. Anything else, a typo included, leaves the tool off.
  • The variable is read again on every call. Nothing in a tool call writes to the server's environment, so turning a tool on means editing the config and restarting the server.

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

    The 13 values are the test suite's own parameters, each tried against all four gated tools: 52 cases, all passing. The two sessions at the bottom are real get_environment_variables calls over stdio, filtered to LNMCP. The config is the README's example with the variable changed to match them.

    2Mark the gated tools in the tool list

    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.

    • The note comes from the same check as the gate, recomputed on every listing, so the list and the gate can't disagree.
    • 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

    Gated, offGated, onAlways on
    The full listing for this server, verbatim
    ToolDescription
    Five real 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.

    3Look around without asking

    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.
    • Addresses are probed one at a time, five ports each, so an address with nothing behind it costs 2.5 seconds.
    • A machine that is up but listens on none of the five isn't reported. Here, 127.0.0.1 refused all five ports and came back as zero hosts.

    scan_network on loopback, then check_port

    Elapsed0.0 sPlayed at 4× speed

    No answer in 0.5 sRefused at onceAccepted: none here
    Real 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.

    4Refuse at the top of each gated tool

    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

    Requests, responses and response times are real, captured by sending MCP JSON-RPC over stdio. Select a call to see its full response. 192.0.2.1 is a documentation address, and the gate returns before any connection is tried. pid 383470 was a 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.

    5Run the tool the operator turned on

    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.

    • Each variable opens only its own tools. On this server, ssh_execute and kill_process still return the refusal.
    • The variables gate tools, not capabilities. A shell can run kill or ssh itself, so turning on LNMCP_ENABLE_EXEC implies the other two, and the README says so.
    • A command times out after 30 seconds unless the call sets its own timeout.

    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

    A real stdio capture from the same run as stage 4, after a restart with the variable set. The command was echo hello, with cwd set to /.

    6End a process when its switch is set

    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.

    • The target here was a sleep 300 started for the capture, and the same pid met three servers in turn.
    • A refusal never reaches psutil. The tests swap psutil.Process for a function that raises, and the refusal still comes back clean.

    One pid, three servers

    sleep 300, pid 383470running

    Three server starts, captured over stdio in one run. After each call the capture script checked whether pid 383470 was still alive. No other process was touched.

    Timed at the client

    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.

    A refusal takes milliseconds

    Eight refusals across three server starts. None started a process, opened a connection or sent a signal.

    exec, plain7.0 ms
    exec, env argument4.8 ms
    ssh_connect3.0 ms
    ssh_execute3.0 ms
    kill_process1.7 ms
    ssh_execute, exec on3.2 ms
    kill_process, exec on1.8 ms
    exec, maybe2.2 ms

    Once enabled, echo hello took 4.1 ms, process start included.

    An empty address costs 2.5 seconds

    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.

    127.0.0.2 to .510.273 s
    127.0.0.10.062 s

    An address that refuses at once costs almost nothing. A scan can be narrowed with start_ip and end_ip.

    Five server starts, counted

    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 withMarked offGated calls
    No opt-ins4 of 185 refused
    EXEC=13 of 18exec ran, 2 refused
    EXEC=maybe4 of 181 refused
    SSH_EXEC=12 of 18none sent
    KILL=13 of 18kill ran

    94 tests, and none of them runs a command

    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.

      What each class pins down

      • Default deny, 67: each of the four gated tools refused with no environment, and the refusal names its variable; six spellings that open each tool and seven that don't; seven always-on tools never gated.
      • The gate is real, 7: a refusal never reaches 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.
      • Tool listing, 3: off tools carry their variable in the listing, turning one on clears only its note, and always-on tools are never marked.
      • No drift, 5: every gated name is a listed, callable tool, every listed tool has a dispatch branch, every LNMCP_ variable the server reads is named in the README, and the entry point is callable.
      • Known hosts, 6: a matching key in the user's or the system file connects; an unknown host is refused and nothing is recorded; a changed key, or a key of another type, is refused whether trust on first use is on or off.
      • Trust on first use, 5: the key goes to the server's own file and never to the user's 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.
      • No accept-all policy, 1: the source never uses Paramiko's 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.

      Deliberately missing

      A command allowlist
      Once on, 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.
      An audit log
      Tool calls aren't logged, so an enabled server can't be reviewed afterwards. A log of every call is on the roadmap.
      Time and output caps per tool
      Commands default to a 30-second timeout, and output size isn't capped. Per-tool limits are on the roadmap.
      The mcp 2.x API
      2.x removed the low-level decorators this server is built on, so it stays pinned to mcp 1.29.1 until a port.

      Run it

      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.