Model Context Protocol

The Model Context Protocol (MCP) is an open standard that allows connecting Large Language Models (LLM’s) to external tools. We’re going to be looking at allowing our models running in LM Studio to perform NMap scans.


MCP NMap Server

I’m using Ubuntu 25.04 as the host operating system. We will be using the Python MCP module to interface with LM Studio. Since this isn’t available in the Ubuntu package repository, you will need to install it in a virtual environment.

python3 -m venv .
./bin/pip3 install mcp

The MCP NMap server uses the code below. The server code will just be a single Python file “mcp_nmap_server.py”.

By default, the code will perform a TCP Connect portscan of the top 100 ports. It’s behaviour can be customised by asking the LLM to scan different ports, or perform service detection.

#!/usr/bin/env python3

#####################################################################################
# ██████╗░░█████╗░██████╗░██████╗░███████╗██████╗░░██████╗░░█████╗░████████╗███████╗#
# ██╔══██╗██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔════╝░██╔══██╗╚══██╔══╝██╔════╝#
# ██████╦╝██║░░██║██████╔╝██║░░██║█████╗░░██████╔╝██║░░██╗░███████║░░░██║░░░█████╗░░#
# ██╔══██╗██║░░██║██╔══██╗██║░░██║██╔══╝░░██╔══██╗██║░░╚██╗██╔══██║░░░██║░░░██╔══╝░░#
# ██████╦╝╚█████╔╝██║░░██║██████╔╝███████╗██║░░██║╚██████╔╝██║░░██║░░░██║░░░███████╗#
# ╚═════╝░░╚════╝░╚═╝░░╚═╝╚═════╝░╚══════╝╚═╝░░╚═╝░╚═════╝░╚═╝░░╚═╝░░░╚═╝░░░╚══════╝#
#####################################################################################
#                                   NMAP MCP Server                                 #
#####################################################################################

from __future__ import annotations

import asyncio
import ipaddress
import re
import shutil
import subprocess
from typing import List, Optional
import logging


logging.basicConfig(
    filename="mcp_server.log",
    level=logging.DEBUG,
    format="%(asctime)s [%(levelname)s] %(message)s"
)

try:
    from mcp.server.fastmcp import FastMCP
except ImportError as e:
    raise SystemExit(
        "mcp not installed. Run: pip install mcp\nDetails: " + str(e)
    )

APP_NAME = "nmap-mcp-server"
mcp = FastMCP(APP_NAME)

CIDR_RE = re.compile(r"^(?:\d{1,3}\.){3}\d{1,3}(?:/\d{1,2})?$")
HOST_RE = re.compile(r"^[a-zA-Z0-9_.-]+$")
PORT_SPEC_RE = re.compile(r"^[0-9,-]+$")

ALLOWED_SCAN_TYPES = {
    "syn": "-sS",
    "connect": "-sT",
    "udp": "-sU",
    "ping": "-sn",
}

MAX_TARGETS = 256
MAX_TIMEOUT = 600


def _split_targets(raw: str) -> List[str]:
    parts = [p.strip() for p in re.split(r"[\s,]+", raw) if p.strip()]
    if len(parts) > MAX_TARGETS:
        raise ValueError(f"Too many targets (>{MAX_TARGETS}) provided.")
    validated: List[str] = []
    for p in parts:
        if CIDR_RE.match(p):
            ip, _, prefix = p.partition("/")
            try:
                ipaddress.IPv4Address(ip)
                if prefix:
                    pr = int(prefix)
                    if not (0 <= pr <= 32):
                        raise ValueError
            except Exception:
                raise ValueError(f"Invalid CIDR or IPv4 address: {p}")
            validated.append(p)
        else:
            try:
                ipaddress.IPv4Address(p)
                validated.append(p)
                continue
            except Exception:
                pass
            # domain-ish
            if HOST_RE.match(p):
                validated.append(p)
            else:
                raise ValueError(f"Invalid host/label: {p}")
    return validated


def _ensure_nmap_exists() -> None:
    if shutil.which("nmap") is None:
        raise FileNotFoundError(
            "nmap not found in PATH. Please install Nmap and try again."
        )


async def _run_nmap(args: List[str], timeout: int) -> str:
    proc = await asyncio.create_subprocess_exec(
        *args,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.STDOUT,
    )
    try:
        out_bytes = await asyncio.wait_for(proc.communicate(), timeout=timeout)
    except asyncio.TimeoutError:
        proc.kill()
        return (
            "[MCP nmap] Scan timed out and was terminated. Try reducing scope "
            "or increasing the timeout."
        )
    output = (out_bytes[0] or b"").decode(errors="replace")
    return output


@mcp.tool(
    name="nmap_scan",
    description=(
        "Run a Nmap scan. Only permitted flags are exposed. "
    ),
)
async def nmap_scan(
    *,
    targets: str,                         # Comma/space‑separated list of IPv4/CIDR/hostnames.
    scan_type: str = "connect",           # One of {syn, connect, udp, ping}
    ports: Optional[str] = None,          # Port spec like "22,80,443" or ranges like "1-1024".
    top_ports: Optional[int] = 100,      # If set, uses --top-ports N
    service_versions: bool = False,       # Enable -sV
    skip_host_discovery: bool = False,    # Add -Pn.
    extra_timing: Optional[str] = None,   # One of {T0,T1,T2,T3,T4,T5}.
    timeout_seconds: int = 220,           # Hard cap for the subprocess. Max 600.
) -> str:
    logging.debug(f"[DEBUG] Received: targets={targets}, scan_type={scan_type}")
    _ensure_nmap_exists()

    targets_list = _split_targets(targets)

    if scan_type not in ALLOWED_SCAN_TYPES:
        raise ValueError(
            f"Unsupported scan_type '{scan_type}'. Choose one of: "
            + ", ".join(ALLOWED_SCAN_TYPES.keys())
        )

    if timeout_seconds <= 0 or timeout_seconds > MAX_TIMEOUT:
        raise ValueError(f"timeout_seconds must be 1..{MAX_TIMEOUT}")

    args: List[str] = ["nmap", ALLOWED_SCAN_TYPES[scan_type]]

    if skip_host_discovery:
        args.append("-Pn")

    if service_versions:
        args.append("-sV")

    if ports:
        if not PORT_SPEC_RE.match(ports):
            raise ValueError(
                "Invalid ports spec. Use digits, commas and dashes (e.g., '22,80,443' or '1-1024')."
            )
        args.extend(["-p", ports])

    if top_ports is not None:
        if top_ports <= 0 or top_ports > 10000:
            raise ValueError("top_ports must be between 1 and 10000")
        args.extend(["--top-ports", str(top_ports)])

    if extra_timing:
        if extra_timing not in {"T0", "T1", "T2", "T3", "T4", "T5"}:
            raise ValueError("extra_timing must be one of T0..T5")
        args.append(f"-{extra_timing}")

    args.extend(targets_list)

    rendered = " ".join(args)
    logging.info(f"[MCP nmap] Running scan: {rendered}")

    output = await _run_nmap(args, timeout_seconds)

    header = (
        f"[MCP nmap] Executed: {rendered}\n"
        f"[MCP nmap] Timeout: {timeout_seconds}s\n"
        f"[MCP nmap] Targets: {', '.join(targets_list)}\n"
        f"----------------------------------------\n"
    )
    return header + output


@mcp.tool(
    name="nmap_version",
    description="Return the installed Nmap version and path.",
)
async def nmap_version() -> str:
    _ensure_nmap_exists()
    path = shutil.which("nmap") or "nmap"
    try:
        out = subprocess.check_output(["nmap", "--version"], text=True)
    except subprocess.CalledProcessError as e:
        out = e.output or str(e)
    return f"Path: {path}\n{out}"


if __name__ == "__main__":
    logging.debug("[DEBUG] Starting MCP server...")
    mcp.run()

LM Studio Integration

You will need a fairly recent version of LM Studio, to ensure it has support for MCP. I’m using version 0.3.24.

In LM Studio, select Program > Install > Edit mcp.json. Add the following contents to allow it to invoke our MCP tool.

{
  "mcpServers": {
    "nmap-mcp": {
      "command": "/home/user/MCP_NMAP/bin/python3",
      "args": ["/home/user/MCP_NMAP/mcp_nmap_server.py"],
      "env": {
        "PATH": "/home/user/MCP_NMAP/bin:/usr/local/bin:/usr/bin:/bin"
      }
    }
  }
}

Open LM Studio, and load a suitable model. The model will need to support external tool usage. I’m using qwen3-4b-2507.

Near the chat box at the bottom of the screen, click on the plug icon and ensure mcp/nmap-mcp is enabled.

We can now ask the LLM for a list of available tools.

We can then query the installed NMap version.

And perform a port scans against a system.

If your scanning remote systems, the model may ask you to confirm if your actions are ethical.

Debug logs showing which hosts have been scanned will appear in .lmstudio/extensions/plugins/mcp/nmap-mcp/mcp_server.log

cat .lmstudio/extensions/plugins/mcp/nmap-mcp/mcp_server.log  | grep MCP
2025-08-31 13:33:04,745 [DEBUG] [DEBUG] Starting MCP server...
2025-08-31 13:33:19,471 [INFO] [MCP nmap] Running scan: nmap -sT 127.0.0.1
2025-08-31 13:33:43,817 [DEBUG] [DEBUG] Starting MCP server...
2025-08-31 13:33:46,211 [INFO] [MCP nmap] Running scan: nmap -sT --top-ports 1000 scanme.nmap.org

In Conclusion

Be aware that the LLM may occasional “hallucinate” and provide unintended input to the external tools. By default, LM studio will prompt you to make sure the parameters supplied to the tool are intended.