Skip to content

Add minimal Python MCP recon server with fixed nmap/curl/whois tools - #52

Merged
canstralian merged 4 commits into
mainfrom
codex/build-minimal-local-mcp-server
Mar 29, 2026
Merged

canstralian merged 4 commits into
mainfrom
codex/build-minimal-local-mcp-server

Conversation

@canstralian

@canstralian canstralian commented Mar 27, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

  • Provide a minimal, single-process MCP server that exposes a locked set of reconnaissance tools so an LLM can perform basic remote recon without a shell.
  • Enforce strict safety and failure-containment rules (explicit target input, timeout, truncation, no free-form flags) so the service is robust under missing binaries, long-running processes, and malformed input.
  • Make the server easy to bootstrap and run locally for inspection via MCP clients using a small, auditable code surface.

Description

  • Add server.py which registers an MCP named pentest-mcp-v1 and exposes three tools: nmap_scan, curl_head, and whois_lookup, each mapped to a fixed command pattern.
  • Implement a subprocess bridge _run_tool that performs strict target validation, logs executed commands, enforces TOOL_TIMEOUT_SECONDS and MAX_OUTPUT_CHARS, truncates large outputs, and returns the structured ToolResult JSON contract (ok, command, exit_code, stdout, stderr, truncated).
  • Map common failures explicitly (missing binary → exit_code 127, timeout → 124, validation failures → exit_code 2, unexpected exceptions → 1) and avoid crashing the server on tool errors.
  • Add setup.sh to bootstrap the mcp dependency and check for nmap, curl, and whois, and add README_PENTEST.md documenting the tool surface, output contract, safety guarantees, and example MCP client invocation.

Testing

  • Ran syntax check on the Python server with python3 -m py_compile server.py, which succeeded.
  • Performed a shell syntax check with bash -n setup.sh, which succeeded.
  • No runtime tool invocations were executed in CI; runtime behavior should be validated on a host with nmap, curl, and whois installed.

Codex Task

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced MCP Python PenTest Server (v1) with three reconnaissance tools: nmap scan, curl head request, and whois lookup.
    • Built-in safety features: target validation, execution timeout, output truncation, and comprehensive error handling.
  • Documentation

    • Added README detailing tool specifications, safety rules, and setup/run instructions.
  • Chores

    • Added setup script for dependency and binary validation.

Copilot AI review requested due to automatic review settings March 27, 2026 10:24
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a minimal MCP Python PenTest Server providing tools for port scanning, HTTP header inspection, and WHOIS lookups. The server includes target validation and subprocess management. Feedback highlights the need to redact credentials from logs for security, remove unreachable null checks in the target validation, and update the setup script to fail if required binaries are missing.

Comment thread server.py Outdated
Comment thread server.py
Comment on lines +59 to +60
if target is None:
return "target is required"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The function signature specifies target: str, which means target cannot be None. Therefore, this check for target is None is unreachable code and can be removed. The MCP framework will ensure that required arguments are provided.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:::writing{variant=“standard” id=“18452”}
Good catch. Since target is declared as str, None is not a valid value, and MCP already enforces required arguments at the tool boundary. That makes the target is None branch unreachable in normal execution, so I’ll remove it.

If we want defensive handling for malformed callers outside MCP, the better approach would be to widen the signature explicitly, for example target: str | None, and then validate inside the function. As written, the current check is redundant.
:::

Comment thread setup.sh Outdated
canstralian and others added 2 commits March 27, 2026 20:26
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a standalone, local Python MCP server (“pentest-mcp-v1”) that exposes a fixed, minimal recon tool surface (nmap/curl/whois) via controlled subprocess execution, plus bootstrap/docs to run it.

Changes:

  • Added server.py implementing an MCP server with nmap_scan, curl_head, and whois_lookup, including timeout + output truncation and structured results.
  • Added setup.sh to install the Python mcp dependency and check for required binaries.
  • Added README_PENTEST.md documenting tool surface, output contract, and basic usage.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.

File Description
setup.sh Bootstraps Python dependency installation and checks for nmap, curl, whois.
server.py Implements FastMCP server + subprocess runner with validation, timeout, and truncation.
README_PENTEST.md Documents the tool surface, result contract, and how to run via stdio MCP clients.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server.py
Comment on lines +85 to +86
redacted_cmd_str = re.sub(r"(://[^:]+:)[^@]+@", r"\1<redacted>@", cmd_str)
logger.info("executing command=%s", redacted_cmd_str)

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logging the fully rendered command (cmd_str) can leak secrets if the target is a URL containing credentials or tokens (e.g., https://user:pass@host or query params). Either redact sensitive URL components before logging, or log only the tool name plus a minimally identifying target (e.g., host) to avoid credential exposure.

Copilot uses AI. Check for mistakes.
Comment thread server.py
Comment on lines +88 to +94
try:
completed = subprocess.run(
command,
capture_output=True,
text=True,
timeout=TIMEOUT_SECONDS,
check=False,

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

subprocess.run(..., capture_output=True) buffers the full stdout/stderr in memory and truncates only after completion, so a noisy command can still cause high memory usage before truncation kicks in. If you want a hard output cap, consider streaming reads with a byte/char limit (or redirecting to a temp file) so the process can’t exceed MAX_OUTPUT_CHARS in memory.

Copilot uses AI. Check for mistakes.
Comment thread setup.sh
echo "python3 is required" >&2
exit 1
fi

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setup.sh assumes python3 -m pip exists and installs packages into whatever environment python3 points to (often the system interpreter), which can fail without permissions and can pollute global site-packages. Consider checking for pip explicitly and guiding users to create/activate a venv (or use pipx) before installing mcp.

Suggested change
# Ensure python3 has pip available
if ! python3 -m pip --version >/dev/null 2>&1; then
echo "python3 pip module is required but was not found." >&2
echo "Please install pip for python3 (for example, using 'python3 -m ensurepip --upgrade'" >&2
echo "or your OS package manager), then re-run this script." >&2
exit 1
fi
# Require a virtual environment to avoid installing into the system Python
in_venv="$(python3 - <<'PY'
import sys
in_venv = getattr(sys, "base_prefix", sys.prefix) != sys.prefix
print("1" if in_venv else "0")
PY
)"
if [ "$in_venv" != "1" ]; then
cat >&2 <<'EOF'
This script should be run inside a Python virtual environment to avoid
installing packages into the system Python.
To create and activate a virtual environment, you can run:
python3 -m venv .venv
. .venv/bin/activate
Then re-run this script.
Alternatively, you can install "mcp" using pipx:
pipx install "mcp>=1.0.0"
EOF
exit 1
fi

Copilot uses AI. Check for mistakes.
Comment thread server.py
Comment on lines +141 to +145
@mcp.tool(
name="nmap_scan",
description="Run fast port discovery with fixed pattern: nmap -F <target>",
)
def nmap_scan(target: str) -> ToolResult:

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because _TARGET_PATTERN permits "/", nmap_scan currently accepts CIDR/range-style targets (e.g., 10.0.0.0/8), which can generate a large amount of traffic before the timeout triggers. If the intent is “basic recon” on a single host, consider tightening validation for nmap/whois to disallow CIDR/ranges/paths and require a hostname/IP only.

Copilot uses AI. Check for mistakes.
Comment thread server.py
Comment on lines +61 to +65
candidate = target.strip()
if not candidate:
return "target cannot be empty"
if len(candidate) > 254:
return "target is too long"

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_validate_target() strips the target into candidate, but the sanitized value is never returned/used. This means inputs like "example.com " can pass validation and still be executed with the original (whitespace-containing) argument, which is inconsistent with the safety guarantees. Consider returning the sanitized target (or a tuple of (sanitized, error)) and building the command from that value.

Copilot uses AI. Check for mistakes.
Comment thread server.py
Comment on lines +43 to +45
# Allows domains, IPv4, IPv6, hostnames, and optional URL-ish punctuation used by curl.
_TARGET_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/?&=%#@+\-\[\]]{0,253}$")

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shared _TARGET_PATTERN allows URL schemes/paths (":" and "/"), which lets curl_head accept file://... and other non-HTTP(S) schemes. That can exfiltrate local files from the host running the server, contradicting the “remote recon” safety intent. Recommend tool-specific validation for curl that only permits http:// and https:// (and rejects file:, scp:, etc.).

Copilot uses AI. Check for mistakes.
@coderabbitai

coderabbitai Bot commented Mar 27, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR introduces a new MCP (Model Context Protocol) server implementation for penetration testing tools. It includes documentation defining the server contract, a Python FastMCP server exposing three recon tools (nmap_scan, curl_head, whois_lookup) with built-in validation and safety controls, and a setup script verifying required dependencies.

Changes

Cohort / File(s) Summary
Documentation
README_PENTEST.md
Defines the MCP PenTest Server specification, including three exposed tools, JSON output contract, validation rules, timeout/truncation limits, error handling, and setup instructions.
Server Implementation
server.py
FastMCP server exposing nmap, curl, and whois tools with shared _run_tool function. Includes target validation (character pattern, length limits), subprocess execution with timeout handling (20s default), output truncation (20000 chars default), error handling for missing binaries (exit 127) and timeouts (exit 124), command logging with credential redaction, and structured ToolResult response format.
Setup & Dependencies
setup.sh
Bash script enforcing strict execution mode, validating python3 availability, upgrading pip, installing mcp>=1.0.0 package, and verifying external CLI tools (nmap, curl, whois) exist in PATH.

Sequence Diagram

sequenceDiagram
    participant Client as MCP Client
    participant Server as MCP Server
    participant Subprocess as Subprocess<br/>(nmap/curl/whois)
    
    Client->>Server: Call tool (e.g., nmap_scan)<br/>with target parameter
    Server->>Server: Validate target<br/>(non-empty, pattern match,<br/>length limits)
    alt Validation fails
        Server-->>Client: Return ToolResult<br/>(ok=false, error details)
    else Validation passes
        Server->>Server: Build command<br/>(fixed pattern +<br/>validated target)
        Server->>Subprocess: Execute with<br/>timeout & capture output
        alt Subprocess succeeds
            Subprocess-->>Server: stdout, stderr,<br/>exit code
        else Timeout occurs
            Server->>Server: Capture partial output<br/>+ timeout message
        else Binary missing
            Server->>Server: Set exit code 127
        end
        Server->>Server: Truncate output<br/>(if > MAX_OUTPUT_CHARS)
        Server->>Server: Log command
        Server-->>Client: Return ToolResult<br/>(ok, command, exit_code,<br/>stdout, stderr, truncated)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 With whiskers twitching, I explore,
MCP servers and tools galore,
Nmap hops, curl bounds so free,
Validation guards what we see,
A pentest warren, safe and sure! 🔍

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main change: adding a Python MCP server with three fixed reconnaissance tools (nmap, curl, whois).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/build-minimal-local-mcp-server

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server.py`:
- Around line 58-70: Validation currently strips the input inside
_validate_target but the caller (_run_tool) continues to pass the original
unstripped target to the subprocess, letting inputs like " example.com " bypass
the README rule; fix by normalizing once and using the normalized value
downstream: either change _validate_target to return the cleaned/stripped string
(or raise/return an error) so callers can use that returned value, or ensure
_run_tool explicitly does before calling
_validate_target and before launching the subprocess; update references to
_validate_target and any subprocess invocation in _run_tool to use the stripped
value.
- Around line 149-154: The curl_head tool currently invokes curl in curl_head
and accepts non-HTTP protocols; modify the argument list passed to _run_tool in
curl_head to include the flag --proto '=http,https' so curl is restricted to
only HTTP/HTTPS, and update the curl_head description if needed to reflect the
enforced protocol restriction; target the curl_head function and the call to
_run_tool for this change.
- Around line 115-117: The TimeoutExpired handler is discarding partial output
because it checks for str; update the except block that sets partial_stdout and
partial_stderr (the subprocess.TimeoutExpired handler) to accept bytes by
handling bytes or str: if exc.stdout/exc.stderr are bytes, decode them (e.g.,
using .decode('utf-8', errors='replace')), if they are already str use as-is,
and default to empty string when None; ensure you apply the same logic to both
partial_stdout and partial_stderr so partial output is preserved for logging or
reporting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e3f9b0f4-aa4e-4f1d-8eca-056169a64be1

📥 Commits

Reviewing files that changed from the base of the PR and between 433eaa7 and bda8a4c.

📒 Files selected for processing (3)
  • README_PENTEST.md
  • server.py
  • setup.sh

Comment thread server.py
Comment on lines +58 to +70
def _validate_target(target: str) -> str | None:
if target is None:
return "target is required"
candidate = target.strip()
if not candidate:
return "target cannot be empty"
if len(candidate) > 254:
return "target is too long"
if " " in candidate or "\t" in candidate or "\n" in candidate:
return "target cannot contain whitespace"
if not _TARGET_PATTERN.match(candidate):
return "target contains unsupported characters"
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't strip for validation unless you also execute the stripped value.

Line 61 normalizes into candidate, but _run_tool() still passes the raw target to the subprocess. " example.com " therefore clears validation even though README_PENTEST.md Line 31 says whitespace is rejected.

Suggested fix
 def _validate_target(target: str) -> str | None:
     if target is None:
         return "target is required"
-    candidate = target.strip()
-    if not candidate:
+    if not target.strip():
         return "target cannot be empty"
+    if target != target.strip():
+        return "target cannot contain leading or trailing whitespace"
+    candidate = target
     if len(candidate) > 254:
         return "target is too long"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server.py` around lines 58 - 70, Validation currently strips the input inside
_validate_target but the caller (_run_tool) continues to pass the original
unstripped target to the subprocess, letting inputs like " example.com " bypass
the README rule; fix by normalizing once and using the normalized value
downstream: either change _validate_target to return the cleaned/stripped string
(or raise/return an error) so callers can use that returned value, or ensure
_run_tool explicitly does before calling
_validate_target and before launching the subprocess; update references to
_validate_target and any subprocess invocation in _run_tool to use the stripped
value.

Comment thread server.py
Comment on lines +115 to +117
except subprocess.TimeoutExpired as exc:
partial_stdout = exc.stdout if isinstance(exc.stdout, str) else ""
partial_stderr = exc.stderr if isinstance(exc.stderr, str) else ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python3 - <<'PY'
import subprocess, sys
cmd = [
    sys.executable,
    "-c",
    "import sys,time; print('out'); print('err', file=sys.stderr); sys.stdout.flush(); sys.stderr.flush(); time.sleep(1)",
]
try:
    subprocess.run(cmd, capture_output=True, text=True, timeout=0.1)
except subprocess.TimeoutExpired as exc:
    print(type(exc.stdout).__name__, repr(exc.stdout))
    print(type(exc.stderr).__name__, repr(exc.stderr))
PY

Repository: canstralian/promptcrafting-mcp

Length of output: 1789


🏁 Script executed:

#!/bin/bash
python3 - <<'PY'
import subprocess, sys
cmd = [
    sys.executable,
    "-c",
    "import sys,time; print('out'); print('err', file=sys.stderr); sys.stdout.flush(); sys.stderr.flush(); time.sleep(1)",
]
try:
    subprocess.run(cmd, capture_output=True, text=True, timeout=0.1)
except subprocess.TimeoutExpired as exc:
    print(type(exc.stdout).__name__, repr(exc.stdout))
    print(type(exc.stderr).__name__, repr(exc.stderr))
PY

Repository: canstralian/promptcrafting-mcp

Length of output: 1874


🏁 Script executed:

# Find and examine server.py
find . -name "server.py" -type f

Repository: canstralian/promptcrafting-mcp

Length of output: 1874


🌐 Web query:

Python subprocess TimeoutExpired stdout stderr text=True bytes or str

💡 Result:

In Python's subprocess module, when using subprocess.run with text=True (or encoding specified) and a timeout that expires, the resulting subprocess.TimeoutExpired exception has stdout, stderr, and output attributes that are bytes (or None), not str as expected for text mode. This is the documented and current behavior as of Python 3.13 (docs.python.org/3.13/library/subprocess.html): "This is always bytes when any output was captured regardless of the text=True setting." To handle this and get strings, manually decode after catching the exception: try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=5) except subprocess.TimeoutExpired as e: stdout = e.stdout.decode('utf-8', errors='replace') if e.stdout is not None else '' stderr = e.stderr.decode('utf-8', errors='replace') if e.stderr is not None else '' print(f"Timeout. stdout: {stdout}, stderr: {stderr}") Note: Decoding may fail if output is incomplete (e.g., partial UTF-8 sequences), hence using errors='replace'. This behavior is unchanged in recent Python versions (3.13, 3.14, 3.15 docs) and is a known issue (github.com/python/cpython/issues/87597) with an open PR (#95579) to fix it, but not yet merged due to API compatibility concerns.

Citations:


TimeoutExpired stdout/stderr are bytes, not str—partial output is lost.

Python's subprocess.TimeoutExpired always exposes stdout/stderr as bytes (even with text=True), per official documentation. Lines 116-117's isinstance(exc.stdout, str) check will always fail, returning empty strings instead of partial output.

Suggested fix
     except subprocess.TimeoutExpired as exc:
-        partial_stdout = exc.stdout if isinstance(exc.stdout, str) else ""
-        partial_stderr = exc.stderr if isinstance(exc.stderr, str) else ""
+        partial_stdout = (
+            exc.stdout.decode(errors="replace")
+            if isinstance(exc.stdout, bytes)
+            else (exc.stdout or "")
+        )
+        partial_stderr = (
+            exc.stderr.decode(errors="replace")
+            if isinstance(exc.stderr, bytes)
+            else (exc.stderr or "")
+        )
         stdout, stdout_truncated = _truncate(partial_stdout)
         stderr, stderr_truncated = _truncate(partial_stderr)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server.py` around lines 115 - 117, The TimeoutExpired handler is discarding
partial output because it checks for str; update the except block that sets
partial_stdout and partial_stderr (the subprocess.TimeoutExpired handler) to
accept bytes by handling bytes or str: if exc.stdout/exc.stderr are bytes,
decode them (e.g., using .decode('utf-8', errors='replace')), if they are
already str use as-is, and default to empty string when None; ensure you apply
the same logic to both partial_stdout and partial_stderr so partial output is
preserved for logging or reporting.

Comment thread server.py
Comment on lines +149 to +154
@mcp.tool(
name="curl_head",
description="Run HTTP header inspection with fixed pattern: curl -I <target>",
)
def curl_head(target: str) -> ToolResult:
return _run_tool(["curl", "-I", target], target)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
command -v curl >/dev/null

printf 'Current behavior:\n'
curl -I file:///etc/hosts 2>&1 | sed -n '1,5p' || true

printf '\nRestricted behavior:\n'
curl --proto '=http,https' -I file:///etc/hosts 2>&1 | sed -n '1,5p' || true

Repository: canstralian/promptcrafting-mcp

Length of output: 1789


🌐 Web query:

curl --proto flag http https protocol restriction documentation

💡 Result:

The --proto option in curl restricts the protocols (URL schemes) that curl is allowed to use in a transfer. It takes a comma-separated list of protocols, such as --proto '=http,https' to allow only HTTP and HTTPS. Use + prefix to enable specific protocols (add to default), / to disable specific ones (remove from default), or = to set exactly those (replaces default). By default, curl allows all protocols it supports. This is useful for security, to prevent use of unintended protocols like file:// or ftp://. Related options: - --proto-redir: Restricts protocols for redirects (when -L/--location is used). - --proto-default: Sets default protocol when no scheme is provided in URL. Examples: curl --proto '=http,https' https://example.com # Only HTTP/HTTPS allowed curl --proto '+https' http://example.com # Adds HTTPS to allowed list This corresponds to libcurl's CURLOPT_PROTOCOLS option.

Citations:


🌐 Web query:

curl -I file:// ftp:// protocol handling default behavior

💡 Result:

The default behavior of curl -I (or --head) for file:// and ftp:// protocols is to fetch headers only, without transferring the file body. This displays file metadata like size and last modification time if the file exists, or returns an error if it does not. For file:// URLs (local files): - curl stats the file and generates HTTP-like headers: HTTP/1.1 200 OK, Content-Length: , Last-Modified: , Accept-Ranges: bytes. - Works for files on Unix-like systems; directories may fail on Windows (error 37: Couldn't open file). - Implemented in libcurl's lib/file.c, which sends headers without reading the body when no_body is set (via -I). For ftp:// URLs (FTP servers): - curl sends FTP SIZE and MDTM commands to get file size and modification time, then outputs similar HTTP-like headers. - Errors if the file does not exist (e.g., 550). - Anonymous login uses anonymous: ftp@example.com if no credentials provided. - Confirmed for FTPS with --ssl --head. Examples: - curl -I file:///path/to/existingfile.txt → Headers with size/mtime. - curl -I ftp://example.com/file.txt → Headers if exists, error otherwise. This is the standard "HEAD" equivalent for non-HTTP protocols, as documented in curl man pages and official resources. Note: file:// does not support remote access; only local files.

Citations:


curl_head accepts all protocols, not just HTTP(S).

The description claims "HTTP header inspection," but curl -I by default supports file://, ftp://, ftps://, and other protocols beyond HTTP and HTTPS. This allows the tool to access local files or non-HTTP resources from the host, contradicting its stated purpose.

Add --proto '=http,https' to restrict the tool to only HTTP and HTTPS:

Suggested fix
 def curl_head(target: str) -> ToolResult:
-    return _run_tool(["curl", "-I", target], target)
+    return _run_tool(["curl", "--proto", "=http,https", "-I", target], target)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@mcp.tool(
name="curl_head",
description="Run HTTP header inspection with fixed pattern: curl -I <target>",
)
def curl_head(target: str) -> ToolResult:
return _run_tool(["curl", "-I", target], target)
`@mcp.tool`(
name="curl_head",
description="Run HTTP header inspection with fixed pattern: curl -I <target>",
)
def curl_head(target: str) -> ToolResult:
return _run_tool(["curl", "--proto", "=http,https", "-I", target], target)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server.py` around lines 149 - 154, The curl_head tool currently invokes curl
in curl_head and accepts non-HTTP protocols; modify the argument list passed to
_run_tool in curl_head to include the flag --proto '=http,https' so curl is
restricted to only HTTP/HTTPS, and update the curl_head description if needed to
reflect the enforced protocol restriction; target the curl_head function and the
call to _run_tool for this change.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Mar 29, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
promptcrafting-mcp 62d2c5b Mar 29 2026, 06:57 AM

@canstralian
canstralian requested a review from Copilot March 29, 2026 06:58
@canstralian
canstralian merged commit 339356b into main Mar 29, 2026
4 of 5 checks passed
@canstralian
canstralian deleted the codex/build-minimal-local-mcp-server branch March 29, 2026 06:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread setup.sh
fi

python3 -m pip install --upgrade pip
python3 -m pip install "mcp>=1.0.0"

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setup.sh installs mcp>=1.0.0, but server.py imports mcp.server.fastmcp.FastMCP (added in mcp v1.2.0). As-is, a fresh setup can succeed but the server will fail at import time. Bump the minimum to a version that includes FastMCP (e.g. mcp>=1.2.0) and keep README/setup consistent.

Suggested change
python3 -m pip install "mcp>=1.0.0"
python3 -m pip install "mcp>=1.2.0"

Copilot uses AI. Check for mistakes.
Comment thread setup.sh
Comment on lines +9 to +11
python3 -m pip install --upgrade pip
python3 -m pip install "mcp>=1.0.0"

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This script uses python3 -m pip install ... into the system environment. On many distros with PEP 668 “externally managed” Python, this fails unless the user creates a venv or uses pipx/--user. Consider updating setup.sh (and README) to create/activate a virtualenv and install into it, or document the supported installation method explicitly.

Suggested change
python3 -m pip install --upgrade pip
python3 -m pip install "mcp>=1.0.0"
VENV_DIR="${VENV_DIR:-.venv}"
if [ ! -d "$VENV_DIR" ]; then
python3 -m venv "$VENV_DIR"
fi
# shellcheck disable=SC1090
. "$VENV_DIR/bin/activate"
pip install --upgrade pip
pip install "mcp>=1.0.0"

Copilot uses AI. Check for mistakes.
Comment thread server.py
Comment on lines +31 to +33
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
TIMEOUT_SECONDS = int(os.getenv("TOOL_TIMEOUT_SECONDS", "20"))
MAX_OUTPUT_CHARS = int(os.getenv("MAX_OUTPUT_CHARS", "20000"))

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIMEOUT_SECONDS / MAX_OUTPUT_CHARS are parsed with int(os.getenv(...)) at import time. If the env vars are set to non-integer values, the module import will raise ValueError and crash the server (contradicting the “failure containment” goal). Consider defensive parsing with defaults/clamping so the server can still start with a structured/logged error.

Copilot uses AI. Check for mistakes.
Comment thread server.py
Comment on lines +85 to +105
redacted_cmd_str = re.sub(r"(://[^:]+:)[^@]+@", r"\1<redacted>@", cmd_str)
logger.info("executing command=%s", redacted_cmd_str)

try:
completed = subprocess.run(
command,
capture_output=True,
text=True,
timeout=TIMEOUT_SECONDS,
check=False,
)
stdout, stdout_truncated = _truncate(completed.stdout)
stderr, stderr_truncated = _truncate(completed.stderr)
return {
"ok": completed.returncode == 0,
"command": cmd_str,
"exit_code": completed.returncode,
"stdout": stdout,
"stderr": stderr,
"truncated": stdout_truncated or stderr_truncated,
}

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cmd_str is redacted for logging, but the returned ToolResult includes the unredacted command. Since targets may include URL userinfo (e.g. http://user:pass@host), this can leak credentials back to the MCP client/logs downstream. Consider returning the redacted command string (or separately returning both raw+redacted, with the raw omitted by default).

Copilot uses AI. Check for mistakes.
Comment thread server.py
Comment on lines +43 to +69
# Allows domains, IPv4, IPv6, hostnames, and optional URL-ish punctuation used by curl.
_TARGET_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/?&=%#@+\-\[\]]{0,253}$")


def _invalid_target_result(command: str, reason: str) -> ToolResult:
return {
"ok": False,
"command": command,
"exit_code": 2,
"stdout": "",
"stderr": f"invalid target: {reason}",
"truncated": False,
}


def _validate_target(target: str) -> str | None:
if target is None:
return "target is required"
candidate = target.strip()
if not candidate:
return "target cannot be empty"
if len(candidate) > 254:
return "target is too long"
if " " in candidate or "\t" in candidate or "\n" in candidate:
return "target cannot contain whitespace"
if not _TARGET_PATTERN.match(candidate):
return "target contains unsupported characters"

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current target validation permits arbitrary schemes/paths (e.g. file:///..., gopher://...) and localhost/private IPs. With curl -I <target>, that enables local file probing and SSRF to local/internal services, which conflicts with the stated goal of “basic remote recon” and “strict safety”. Consider restricting curl_head targets to http:///https:// (or host[:port] only) and optionally blocking loopback/link-local/private ranges.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants