Skip to content

Staging - #17

Merged
Jeanm2005 merged 11 commits into
mainfrom
staging
Aug 8, 2026
Merged

Staging#17
Jeanm2005 merged 11 commits into
mainfrom
staging

Conversation

@Jeanm2005

@Jeanm2005 Jeanm2005 commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features
    • Added Docker Compose deployment for the Watchtower proxy and connected file and mail services.
    • Added HTTP-based MCP connectivity with configurable hosts, ports, server settings, and database location.
    • Added automatic upstream reconnection after service interruptions.
  • Documentation
    • Added comprehensive setup, architecture, usage, development, and roadmap documentation.
  • Bug Fixes
    • Improved service naming, connection handling, approval timeout behavior, and error messages.
  • Tests
    • Expanded end-to-end coverage for Docker deployments, routing, reconnection, detection, and security scenarios.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change moves MCP services and the proxy from stdio to streamable HTTP. It adds Docker Compose deployment, supervised upstream reconnection, HTTP-based integration tests, and expanded CI and attack-simulation validation.

Changes

HTTP and Docker runtime

Layer / File(s) Summary
Service and container runtime
requirements.txt, vulnerable-server/server.py, lab-server-b/mailserver.py, Dockerfile, docker-compose.yml, proxy/servers.yaml, proxy/storage.py
Services use environment-configured HTTP endpoints. Docker Compose defines the fileserver, mailserver, proxy, network, and persistent database volume.
Proxy gateway and upstream lifecycle
proxy/proxy.py, proxy/upstream_connection.py
The proxy serves /mcp over uvicorn and Starlette. UpstreamConnection manages HTTP sessions, failed calls, and exponential-backoff reconnects.
HTTP integration test migration
tests/smoke_test.py, tests/test_proxy_e2e.py, tests/test_rugpull_schema.py
Tests start HTTP subprocesses, wait for service readiness, exercise MCP requests, verify reconnection behavior, and clean up processes and temporary files.
Docker attack and CI validation
tools/run_attack_simulation.py, .github/workflows/ci.yml, .github/schedule-attack-simulation.yml, tests/test_docker_stack.py
Attack simulation and CI validate local and Docker scenarios. CI builds, starts, tests, logs, and tears down the Compose stack.
Project documentation and build context
README.md, .dockerignore
The README documents the gateway, deployment, development flow, and roadmap. Docker build exclusions cover local development files.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MCPClient
  participant proxy
  participant UpstreamConnection
  participant MCPServer
  MCPClient->>proxy: request tools or call a tool
  proxy->>UpstreamConnection: route managed request
  UpstreamConnection->>MCPServer: send MCP HTTP request
  MCPServer-->>UpstreamConnection: return MCP result
  UpstreamConnection-->>proxy: return result
  proxy-->>MCPClient: return response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title "Staging" is too vague to identify the pull request's primary changes. Replace the title with a concise description of the main change, such as migrating the MCP services and tests to HTTP and Docker Compose.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch staging

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.

@Jeanm2005
Jeanm2005 merged commit 340efb7 into main Aug 8, 2026
2 of 3 checks passed

@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: 14

🧹 Nitpick comments (5)
proxy/upstream_connection.py (1)

13-13: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the supported Streamable HTTP client API.

streamablehttp_client is deprecated in MCP 1.28.1 and delegates to streamable_http_client. Replace it with the supported API before the compatibility shim is removed.

Proposed fix
-from mcp.client.streamable_http import streamablehttp_client
+from mcp.client.streamable_http import streamable_http_client
...
-                    streamablehttp_client(self.url) as (read, write, _get_session_id),
+                    streamable_http_client(self.url) as (read, write, _get_session_id),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@proxy/upstream_connection.py` at line 13, Replace the deprecated
streamablehttp_client import with the supported streamable_http_client API in
the upstream connection setup, and update any references to the imported symbol
accordingly.
tools/run_attack_simulation.py (1)

98-109: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Close the connection when a query raises.

verify_counts runs each query between sqlite3.connect and conn.close(). If a table is missing, conn.execute raises OperationalError and the connection leaks, and the caller loses the per-check context. Use a with contextlib.closing(...) block, or wrap each query and record the error as a failure message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/run_attack_simulation.py` around lines 98 - 109, Update verify_counts
to guarantee the SQLite connection is closed when any check query raises, using
a contextlib.closing block or equivalent cleanup. Preserve per-check reporting
by catching query errors and appending a failure message that includes the
corresponding check message and exception details.
tests/test_proxy_e2e.py (1)

21-38: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Use an MCP handshake for the proxy readiness check.

wait_for_url only completes a TCP connect. uvicorn accepts connections before the Starlette lifespan finishes starting StreamableHTTPSessionManager, and the proxy upstream supervisors connect asynchronously. The subsequent session.initialize() at line 82 can therefore fail intermittently in CI. tests/smoke_test.py polls with a real MCP initialize call; use that approach for PROXY_URL too, or retry the session setup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_proxy_e2e.py` around lines 21 - 38, Update wait_for_url to verify
proxy readiness with a real MCP initialize handshake rather than only opening a
TCP connection. Follow the established polling approach in smoke_test.py,
retrying the session setup until timeout while preserving the existing timeout
and final error behavior; ensure PROXY_URL is not considered ready until
StreamableHTTPSessionManager and upstream supervisors are usable.
tests/smoke_test.py (1)

75-80: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Drain and print the server output during cleanup.

The subprocess is started with stdout=PIPE and stderr=STDOUT, but the pipe is never read. Two consequences follow. First, the server blocks if it writes more than the pipe buffer holds. Second, the test discards all server diagnostics when a call fails. tests/test_proxy_e2e.py already prints proxy output in its finally block; apply the same pattern here.

♻️ Proposed cleanup
     finally:
         proc.terminate()
         try:
             await asyncio.wait_for(proc.wait(), timeout=5)
         except TimeoutError:
             proc.kill()
+
+        if proc.stdout:
+            output = (await proc.stdout.read()).decode(errors="replace")
+            print("\n=== server output ===")
+            print(output)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/smoke_test.py` around lines 75 - 80, Update the subprocess cleanup in
the smoke test’s finally block to drain and print proc’s combined stdout/stderr,
following the established pattern in test_proxy_e2e.py. Ensure output is
consumed during cleanup while preserving the existing terminate, timeout wait,
and kill fallback behavior.
tests/test_rugpull_schema.py (1)

132-134: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Do not rely on assert for test failure in a standalone script.

This module runs as python tests/test_rugpull_schema.py, not under pytest. Python removes assert statements when it runs with -O. Raise an explicit error, or call sys.exit(1), so the failure always propagates to CI.

♻️ Proposed change
-        assert desc is not None and "<system>" in desc, (
-            f"proxy never picked up the poisoned description (last connection error: {last_error})"
-        )
+        if desc is None or "<system>" not in desc:
+            raise RuntimeError(
+                f"proxy never picked up the poisoned description (last connection error: {last_error})"
+            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_rugpull_schema.py` around lines 132 - 134, Replace the assert
guarding the poisoned-description check with explicit failure handling that
raises an error or exits with status 1 when desc is missing or lacks "<system>".
Preserve the existing diagnostic message including last_error, and ensure the
check remains effective when running tests/test_rugpull_schema.py directly or
with Python optimization enabled.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/schedule-attack-simulation.yml:
- Line 23: Move the schedule-attack-simulation workflow into the
.github/workflows directory, preserving its filename and existing configuration
so GitHub Actions loads and executes the scheduled attack simulation.

In @.github/workflows/ci.yml:
- Around line 9-14: Add a least-privilege permissions block to the test job
granting only read access to repository contents, and update the
actions/checkout@v4 step to disable credential persistence while preserving the
existing build and test flow.
- Around line 54-63: Update the “Wait for proxy to be reachable” step so that
exhausting all 30 attempts exits with a non-zero status and prints the proxy
logs before failing. Preserve the successful break path and existing retry
behavior.

In `@docker-compose.yml`:
- Around line 36-40: Update the ports mapping in the Docker Compose service to
bind port 8000 only to the host loopback interface instead of all interfaces,
preserving the existing container port and local development access.

In `@Dockerfile`:
- Around line 7-16: Update the Dockerfile to create a dedicated unprivileged
user, ensure /app/data exists and is writable by that user, then add a USER
instruction before the runtime command so vulnerable-server, lab-server-b, and
proxy run without root privileges.

In `@lab-server-b/mailserver.py`:
- Line 20: Correct the misspelled “subejct” label in the tool response returned
by the email-sending function, changing it to “subject” while preserving the
existing response format.

In `@proxy/proxy.py`:
- Around line 62-63: Update the upstream iteration in handle_list_tools to catch
timeout or connection failures from each conn.call invocation, emit an alert for
the affected server_name, and continue iterating so tools from connected
upstreams are still returned.

In `@README.md`:
- Around line 5-6: Update the scheduled workflow references in README.md,
including the badge link near the top and the repository-structure note, to use
the committed .github/schedule-attack-simulation.yml path instead of
scheduled-attack-simulation.yml.
- Around line 55-58: Update the Docker quickstart smoke-test instructions near
the `python tests/test_docker_stack.py` command to first install host test
dependencies with `python -m pip install -r requirements.txt`, then run the
existing test command.

In `@tests/test_proxy_e2e.py`:
- Around line 95-107: Prevent subprocess pipe backpressure in all three tests by
starting background drain tasks immediately after each relevant
create_subprocess_exec call: tests/test_proxy_e2e.py lines 95-107 for
filesrv_proc and proxy_proc, tests/smoke_test.py lines 75-80 for its server
process, and tests/test_rugpull_schema.py lines 136-144 for both processes,
including the filesrv restart at line 110. Collect drained output and print it
during each test’s finally cleanup instead of reading pipes only after
termination.

In `@tests/test_rugpull_schema.py`:
- Around line 26-43: Create a shared tests/harness.py containing wait_for_url
with the MCP-handshake readiness behavior from tests/smoke_test.py, plus
stop_proc, start_filesrv, the LOCAL_SERVERS_YAML template, and shared
REPO_ROOT/URL constants; update tests/test_rugpull_schema.py#L26-L43 and
tests/test_proxy_e2e.py#L21-L38 to import and use the shared helpers, removing
their local duplicates, and update tests/smoke_test.py#L14-L29 to use the shared
readiness helper.
- Around line 79-83: Set WATCHTOWER_CI_AUTO_APPROVE to true in the proxy_env
setup for the rug-pull proxy test, alongside the existing HOST, PORT, and
Watchtower configuration variables, so session.list_tools() cannot block waiting
for interactive approval.

In `@tools/run_attack_simulation.py`:
- Around line 59-76: Update the readiness loop around the curl invocation in the
simulation entry point so `up` is set only when the proxy responds successfully,
rather than merely when `result.stdout` is non-empty. Check curl’s return status
or require the HTTP status output to represent a successful response, while
preserving the existing retry, logging, and cleanup behavior.
- Around line 78-93: Ensure the test_result subprocess call in the main
simulation flow is covered by the existing teardown finally path so
TimeoutExpired also stops the Compose stack. Update the final docker compose
down command to include volume removal, matching the earlier teardown behavior
and ensuring watchtower-db is deleted after successful runs.

---

Nitpick comments:
In `@proxy/upstream_connection.py`:
- Line 13: Replace the deprecated streamablehttp_client import with the
supported streamable_http_client API in the upstream connection setup, and
update any references to the imported symbol accordingly.

In `@tests/smoke_test.py`:
- Around line 75-80: Update the subprocess cleanup in the smoke test’s finally
block to drain and print proc’s combined stdout/stderr, following the
established pattern in test_proxy_e2e.py. Ensure output is consumed during
cleanup while preserving the existing terminate, timeout wait, and kill fallback
behavior.

In `@tests/test_proxy_e2e.py`:
- Around line 21-38: Update wait_for_url to verify proxy readiness with a real
MCP initialize handshake rather than only opening a TCP connection. Follow the
established polling approach in smoke_test.py, retrying the session setup until
timeout while preserving the existing timeout and final error behavior; ensure
PROXY_URL is not considered ready until StreamableHTTPSessionManager and
upstream supervisors are usable.

In `@tests/test_rugpull_schema.py`:
- Around line 132-134: Replace the assert guarding the poisoned-description
check with explicit failure handling that raises an error or exits with status 1
when desc is missing or lacks "<system>". Preserve the existing diagnostic
message including last_error, and ensure the check remains effective when
running tests/test_rugpull_schema.py directly or with Python optimization
enabled.

In `@tools/run_attack_simulation.py`:
- Around line 98-109: Update verify_counts to guarantee the SQLite connection is
closed when any check query raises, using a contextlib.closing block or
equivalent cleanup. Preserve per-check reporting by catching query errors and
appending a failure message that includes the corresponding check message and
exception details.
🪄 Autofix

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 Plus

Run ID: 6b12f26d-6ec2-4ce7-a7fe-c12ca0b7ac9b

📥 Commits

Reviewing files that changed from the base of the PR and between 326e8bd and 8df0e63.

📒 Files selected for processing (20)
  • .dockerignore
  • .github/schedule-attack-simulation.yml
  • .github/workflows/ci.yml
  • Dockerfile
  • README.md
  • docker-compose.yml
  • lab-server-b/mailserver.py
  • proxy/proxy.py
  • proxy/servers.yaml
  • proxy/storage.py
  • proxy/upstream_connection.py
  • requirements.txt
  • tests/smoke_test.py
  • tests/test_cascade.py
  • tests/test_docker_stack.py
  • tests/test_multi_server_routing.py
  • tests/test_proxy_e2e.py
  • tests/test_rugpull_schema.py
  • tools/run_attack_simulation.py
  • vulnerable-server/server.py
💤 Files with no reviewable changes (2)
  • tests/test_cascade.py
  • tests/test_multi_server_routing.py

run: pip install -r requirements.txt

- name: Run full attack simulation
- name: Run full attack simulation (self-contained scenarios + Docker stack)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# List all workflow-shaped YAML files and show which live in .github/workflows.
fd -e yml -e yaml . .github

Repository: Jeanm2005/MCP-security-proxy

Length of output: 230


Move this workflow into .github/workflows.

GitHub only loads workflow files from .github/workflows/, so .github/schedule-attack-simulation.yml will not run the scheduled attack simulation. Rename or move the file to .github/workflows/schedule-attack-simulation.yml if the schedule is intended to execute.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/schedule-attack-simulation.yml at line 23, Move the
schedule-attack-simulation workflow into the .github/workflows directory,
preserving its filename and existing configuration so GitHub Actions loads and
executes the scheduled attack simulation.

Comment thread .github/workflows/ci.yml
Comment on lines 9 to +14
jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: |
pip install -r requirements.txt

- name: Lint
run: ruff check . --output-format=github

- name: Run smoke test (direct to vulnerable server)
run: python tests/smoke_test.py

- name: Run end-to-end proxy test
env:
WATCHTOWER_CI_AUTO_APPROVE: "true"
run: python tests/test_proxy_e2e.py

- name: Run rug-pull schema-change test
env:
WATCHTOWER_CI_AUTO_APPROVE: "true"
run: python tests/test_rugpull_schema.py

- name: Run cascade detection test
env:
WATCHTOWER_CI_AUTO_APPROVE: "true"
run: python tests/test_cascade.py
- name: Verify detection actually fired (fail build if not)
run: |
python -c "
import sqlite3
conn = sqlite3.connect('proxy/watchtower.db')
flagged_calls = conn.execute('SELECT COUNT(*) FROM calls WHERE flags IS NOT NULL').fetchone()[0]
desc_findings = conn.execute('SELECT COUNT(*) FROM description_findings').fetchone()[0]
rug_pulls = conn.execute(\"SELECT COUNT(*) FROM tool_fingerprints WHERE last_flag = 'rug_pull'\").fetchone()[0]
cascade_findings = conn.execute('SELECT COUNT(*) FROM cascade_findings').fetchone()[0]
assert flagged_calls > 0, 'expected at least one flagged call, found none'
assert desc_findings > 0, 'expected at least one description finding, found none'
assert rug_pulls > 0, 'expected at least one rug-pull detection, found none'
assert cascade_findings > 0, 'expected at least one cascade finding, found none'
print(f'OK: {flagged_calls} flagged calls, {desc_findings} description findings, {rug_pulls} rug pulls, {cascade_findings} cascade findings')
" No newline at end of file
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Set an explicit least-privilege permissions block.

The job inherits the repository default GITHUB_TOKEN permissions. This workflow only builds and tests, so it needs read access to contents. Also disable credential persistence in the checkout step, because later steps run project code and Docker builds in the same workspace.

🔒 Proposed change
 jobs:
   test:
     runs-on: ubuntu-latest
+    permissions:
+      contents: read
 
     steps:
       - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
📝 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
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.txt
- name: Lint
run: ruff check . --output-format=github
- name: Run smoke test (direct to vulnerable server)
run: python tests/smoke_test.py
- name: Run end-to-end proxy test
env:
WATCHTOWER_CI_AUTO_APPROVE: "true"
run: python tests/test_proxy_e2e.py
- name: Run rug-pull schema-change test
env:
WATCHTOWER_CI_AUTO_APPROVE: "true"
run: python tests/test_rugpull_schema.py
- name: Run cascade detection test
env:
WATCHTOWER_CI_AUTO_APPROVE: "true"
run: python tests/test_cascade.py
- name: Verify detection actually fired (fail build if not)
run: |
python -c "
import sqlite3
conn = sqlite3.connect('proxy/watchtower.db')
flagged_calls = conn.execute('SELECT COUNT(*) FROM calls WHERE flags IS NOT NULL').fetchone()[0]
desc_findings = conn.execute('SELECT COUNT(*) FROM description_findings').fetchone()[0]
rug_pulls = conn.execute(\"SELECT COUNT(*) FROM tool_fingerprints WHERE last_flag = 'rug_pull'\").fetchone()[0]
cascade_findings = conn.execute('SELECT COUNT(*) FROM cascade_findings').fetchone()[0]
assert flagged_calls > 0, 'expected at least one flagged call, found none'
assert desc_findings > 0, 'expected at least one description finding, found none'
assert rug_pulls > 0, 'expected at least one rug-pull detection, found none'
assert cascade_findings > 0, 'expected at least one cascade finding, found none'
print(f'OK: {flagged_calls} flagged calls, {desc_findings} description findings, {rug_pulls} rug pulls, {cascade_findings} cascade findings')
"
\ No newline at end of file
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
🧰 Tools
🪛 zizmor (1.29.0)

[warning] 14-14: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 9 - 14, Add a least-privilege
permissions block to the test job granting only read access to repository
contents, and update the actions/checkout@v4 step to disable credential
persistence while preserving the existing build and test flow.

Source: Linters/SAST tools

Comment thread .github/workflows/ci.yml
Comment on lines +54 to +63
- name: Wait for proxy to be reachable
run: |
for i in $(seq 1 30); do
if curl -s -o /dev/null http://localhost:8000/mcp; then
echo "proxy is up"
break
fi
echo "waiting for proxy... ($i/30)"
sleep 1
done

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Fail the step when the proxy never becomes reachable.

The loop breaks on success, but it exits normally after 30 attempts when the proxy stays down. The step then reports success, and the next step fails with an unclear error. Exit non-zero after the loop, and print the proxy logs.

🐛 Proposed fix
       - name: Wait for proxy to be reachable
         run: |
           for i in $(seq 1 30); do
             if curl -s -o /dev/null http://localhost:8000/mcp; then
               echo "proxy is up"
-              break
+              exit 0
             fi
             echo "waiting for proxy... ($i/30)"
             sleep 1
           done
+          echo "proxy never became reachable"
+          exit 1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 54 - 63, Update the “Wait for proxy to
be reachable” step so that exhausting all 30 attempts exits with a non-zero
status and prints the proxy logs before failing. Preserve the successful break
path and existing retry behavior.

Comment thread docker-compose.yml
Comment on lines +36 to +40
HOST: "0.0.0.0"
PORT: "8000"
WATCHTOWER_DB_PATH: "/app/data/watchtower.db"
ports:
- "8000:8000"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Do not publish an unauthenticated MCP endpoint on all interfaces.

Line 40 publishes the proxy on every host interface. The supplied proxy has no authentication middleware, and tests/test_docker_stack.py shows that clients can invoke filesrv__read_secret_file. An untrusted network client can therefore invoke every policy-permitted tool.

Bind the development endpoint to loopback by default. If remote access is required, add authentication, TLS, and network access controls before publishing it.

Proposed fix for local deployment
 ports:
-  - "8000:8000"
+  - "127.0.0.1:8000:8000"

Docker publishes a port without a host IP on all interfaces. (docs.docker.com)

📝 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
HOST: "0.0.0.0"
PORT: "8000"
WATCHTOWER_DB_PATH: "/app/data/watchtower.db"
ports:
- "8000:8000"
HOST: "0.0.0.0"
PORT: "8000"
WATCHTOWER_DB_PATH: "/app/data/watchtower.db"
ports:
- "127.0.0.1:8000:8000"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker-compose.yml` around lines 36 - 40, Update the ports mapping in the
Docker Compose service to bind port 8000 only to the host loopback interface
instead of all interfaces, preserving the existing container port and local
development access.

Comment thread Dockerfile
Comment on lines +7 to +16
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY vulnerable-server/ ./vulnerable-server/
COPY lab-server-b/ ./lab-server-b/
COPY proxy/ ./proxy/ No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Run the services as a non-root user.

The image has no USER instruction. All three service processes run as root. A compromise in any service then has root permissions inside its container. Create an unprivileged user, make /app/data writable by that user, and switch users before the runtime command.

Proposed fix
 COPY vulnerable-server/ ./vulnerable-server/
 COPY lab-server-b/ ./lab-server-b/
 COPY proxy/ ./proxy/
+
+RUN adduser --system watchtower \
+    && mkdir -p /app/data \
+    && chown -R watchtower:watchtower /app
+
+USER watchtower
📝 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
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY vulnerable-server/ ./vulnerable-server/
COPY lab-server-b/ ./lab-server-b/
COPY proxy/ ./proxy/
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY vulnerable-server/ ./vulnerable-server/
COPY lab-server-b/ ./lab-server-b/
COPY proxy/ ./proxy/
RUN adduser --system watchtower \
&& mkdir -p /app/data \
&& chown -R watchtower:watchtower /app
USER watchtower
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile` around lines 7 - 16, Update the Dockerfile to create a dedicated
unprivileged user, ensure /app/data exists and is writable by that user, then
add a USER instruction before the runtime command so vulnerable-server,
lab-server-b, and proxy run without root privileges.

Source: Linters/SAST tools

Comment thread tests/test_proxy_e2e.py
Comment on lines +95 to +107
finally:
for proc in (proxy_proc, filesrv_proc):
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=5)
except TimeoutError:
proc.kill()

print("=== lookup_user x5 (expect alerts to start at call #4) ===")
for i in range(5):
r = await session.call_tool("filesrv__lookup_user", {"username": "jdoe"})
print(f" call #{i+1}: {r.content[0].text}")
proxy_output = (await proxy_proc.stdout.read()).decode(errors="replace") if proxy_proc.stdout else ""
print("\n=== proxy output ===")
print(proxy_output)

Path(servers_config_path).unlink(missing_ok=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unread subprocess pipes can block the servers in all three migrated tests. Each test starts servers with stdout=asyncio.subprocess.PIPE and stderr=STDOUT, and reads the pipe only after termination. If a server writes more than the pipe buffer holds, it blocks and the test hangs.

  • tests/test_proxy_e2e.py#L95-L107: start background drain tasks for filesrv_proc and proxy_proc right after create_subprocess_exec, then print the collected output in the finally block.
  • tests/smoke_test.py#L75-L80: drain the server pipe during the run and print the collected output in the finally block.
  • tests/test_rugpull_schema.py#L136-L144: drain both the filesrv and proxy pipes during the run, including across the filesrv restart at line 110.
📍 Affects 3 files
  • tests/test_proxy_e2e.py#L95-L107 (this comment)
  • tests/smoke_test.py#L75-L80
  • tests/test_rugpull_schema.py#L136-L144
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_proxy_e2e.py` around lines 95 - 107, Prevent subprocess pipe
backpressure in all three tests by starting background drain tasks immediately
after each relevant create_subprocess_exec call: tests/test_proxy_e2e.py lines
95-107 for filesrv_proc and proxy_proc, tests/smoke_test.py lines 75-80 for its
server process, and tests/test_rugpull_schema.py lines 136-144 for both
processes, including the filesrv restart at line 110. Collect drained output and
print it during each test’s finally cleanup instead of reading pipes only after
termination.

Comment on lines +26 to +43
async def wait_for_url(url: str, timeout: float = 15.0) -> None:
from urllib.parse import urlparse

parsed = urlparse(url)
host, port = parsed.hostname, parsed.port

deadline = time.time() + timeout
last_error = None
while time.time() < deadline:
try:
_reader, writer = await asyncio.open_connection(host, port)
writer.close()
await writer.wait_closed()
return
except OSError as e:
last_error = e
await asyncio.sleep(0.3)
raise RuntimeError(f"{url} never came up: {last_error}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

The HTTP test harness is duplicated across three test files. wait_for_url, stop_proc, the subprocess launch blocks, the LOCAL_SERVERS_YAML template, and the REPO_ROOT/URL constants are copied. The readiness logic has already diverged: tests/smoke_test.py polls with an MCP initialize call, while the other two tests only complete a TCP connect. Extract one shared helper module, for example tests/harness.py.

  • tests/test_rugpull_schema.py#L26-L43: import wait_for_url, stop_proc, and start_filesrv from the shared helper and delete the local copies.
  • tests/test_proxy_e2e.py#L21-L38: import the same wait_for_url and delete the local copy.
  • tests/smoke_test.py#L14-L29: move the MCP-handshake readiness poll into the shared helper and use it as the single readiness implementation.
📍 Affects 3 files
  • tests/test_rugpull_schema.py#L26-L43 (this comment)
  • tests/test_proxy_e2e.py#L21-L38
  • tests/smoke_test.py#L14-L29
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_rugpull_schema.py` around lines 26 - 43, Create a shared
tests/harness.py containing wait_for_url with the MCP-handshake readiness
behavior from tests/smoke_test.py, plus stop_proc, start_filesrv, the
LOCAL_SERVERS_YAML template, and shared REPO_ROOT/URL constants; update
tests/test_rugpull_schema.py#L26-L43 and tests/test_proxy_e2e.py#L21-L38 to
import and use the shared helpers, removing their local duplicates, and update
tests/smoke_test.py#L14-L29 to use the shared readiness helper.

Comment on lines +79 to +83
proxy_env = os.environ.copy()
proxy_env["HOST"] = "127.0.0.1"
proxy_env["PORT"] = "8000"
proxy_env["WATCHTOWER_SERVERS_CONFIG"] = servers_config_path
proxy_env["WATCHTOWER_DB_PATH"] = str(REPO_ROOT / "proxy" / "watchtower.db")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find where the proxy consults WATCHTOWER_CI_AUTO_APPROVE and which operations gate on approval.
rg -n -C 6 'WATCHTOWER_CI_AUTO_APPROVE|auto_approve|approval' --glob '*.py'

Repository: Jeanm2005/MCP-security-proxy

Length of output: 166


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Tracked Python files (first 200):"
git ls-files '*.py' | sed -n '1,200p'

echo
echo "Relevant test files:"
fd -a 'test_.*\.py$' . | sed -n '1,100p'

echo
echo "Search approval/auto words in tracked files:"
rg -n -i -C 4 'approv|auto|interactive|requires|requires approval|list_tools|tool description|description' . --glob '!*.md' --glob '!*.txt' --glob '!package-lock.json' --glob '!pnpm-lock.yaml' --glob '!yarn.lock' | sed -n '1,240p'

echo
echo "Files test_rugpull_schema.py and test_proxy_e2e.py exist:"
for f in tests/test_rugpull_schema.py tests/test_proxy_e2e.py; do
  echo "--- $f"
  if [ -f "$f" ]; then
    wc -l "$f"
    sed -n '1,140p' "$f" | cat -n
  else
    echo "MISSING"
  fi
done

Repository: Jeanm2005/MCP-security-proxy

Length of output: 25024


Add auto-approval for the rug-pull proxy test.

This test does not run a tool requiring approval, but session.list_tools() can trigger the approved auto-path if the proxy reports a suspicious tool description. Since tests/test_proxy_e2e.py uses WATCHTOWER_CI_AUTO_APPROVE=true for its proxy, set the same env var in tests/test_rugpull_schema.py so this e2e test does not wait for interactive approval.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_rugpull_schema.py` around lines 79 - 83, Set
WATCHTOWER_CI_AUTO_APPROVE to true in the proxy_env setup for the rug-pull proxy
test, alongside the existing HOST, PORT, and Watchtower configuration variables,
so session.list_tools() cannot block waiting for interactive approval.

Comment on lines +59 to +76
print(" waiting for proxy to be reachable...")
up = False
for _ in range(30):
result = run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:8000/mcp"],
capture_output=True,
text=True,
)
if result.stdout.strip():
up = True
break
time.sleep(1)

if not up:
print(" FAILED: proxy never became reachable")
run(["docker", "compose", "logs", "proxy"])
run(["docker", "compose", "down", "-v"])
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The readiness poll always succeeds on the first attempt.

curl -w "%{http_code}" writes a status code to stdout even when the connection fails; it writes 000. The condition at line 67 tests only that stdout is non-empty, so up becomes True immediately and the loop never waits. The Docker test then runs against a proxy that is not ready.

Check the curl exit status, or compare the printed code against a success value.

🐛 Proposed fix
     for _ in range(30):
         result = run(
             ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:8000/mcp"],
             capture_output=True,
             text=True,
         )
-        if result.stdout.strip():
+        if result.returncode == 0 and result.stdout.strip() not in ("", "000"):
             up = True
             break
         time.sleep(1)
📝 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
print(" waiting for proxy to be reachable...")
up = False
for _ in range(30):
result = run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:8000/mcp"],
capture_output=True,
text=True,
)
if result.stdout.strip():
up = True
break
time.sleep(1)
if not up:
print(" FAILED: proxy never became reachable")
run(["docker", "compose", "logs", "proxy"])
run(["docker", "compose", "down", "-v"])
return False
print(" waiting for proxy to be reachable...")
up = False
for _ in range(30):
result = run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:8000/mcp"],
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip() not in ("", "000"):
up = True
break
time.sleep(1)
if not up:
print(" FAILED: proxy never became reachable")
run(["docker", "compose", "logs", "proxy"])
run(["docker", "compose", "down", "-v"])
return False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/run_attack_simulation.py` around lines 59 - 76, Update the readiness
loop around the curl invocation in the simulation entry point so `up` is set
only when the proxy responds successfully, rather than merely when
`result.stdout` is non-empty. Check curl’s return status or require the HTTP
status output to represent a successful response, while preserving the existing
retry, logging, and cleanup behavior.

Comment on lines +78 to +93
test_result = run(
[sys.executable, "tests/test_docker_stack.py"],
capture_output=True,
text=True,
timeout=60,
)
ok = test_result.returncode == 0
print(f" {'OK' if ok else 'FAILED (nonzero exit)'}")
if not ok:
print(test_result.stdout[-2000:])
print(test_result.stderr[-2000:])

DOCKER_DB_COPY_PATH.unlink(missing_ok=True)
run(["docker", "compose", "cp", "proxy:/app/data/watchtower.db", str(DOCKER_DB_COPY_PATH)])
run(["docker", "compose", "logs", "proxy"])
run(["docker", "compose", "down"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Two teardown defects: an uncaught timeout and a retained volume.

First, subprocess.run with timeout=60 raises TimeoutExpired. The failure path at lines 72-76 tears the stack down, but this call site has no try/finally. If tests/test_docker_stack.py hangs, the exception propagates and the Compose stack stays running on the CI runner.

Second, line 93 runs docker compose down without -v, while line 75 runs down -v. The named volume watchtower-db therefore survives a successful run. On the next run, verify_counts can read cascade_findings rows that a previous run wrote and report a pass even if detection is broken.

🐛 Proposed fix
-    test_result = run(
-        [sys.executable, "tests/test_docker_stack.py"],
-        capture_output=True,
-        text=True,
-        timeout=60,
-    )
-    ok = test_result.returncode == 0
-    print(f" {'OK' if ok else 'FAILED (nonzero exit)'}")
-    if not ok:
-        print(test_result.stdout[-2000:])
-        print(test_result.stderr[-2000:])
-
-    DOCKER_DB_COPY_PATH.unlink(missing_ok=True)
-    run(["docker", "compose", "cp", "proxy:/app/data/watchtower.db", str(DOCKER_DB_COPY_PATH)])
-    run(["docker", "compose", "logs", "proxy"])
-    run(["docker", "compose", "down"])
-
-    print()
-    return ok
+    try:
+        test_result = run(
+            [sys.executable, "tests/test_docker_stack.py"],
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+        ok = test_result.returncode == 0
+        print(f" {'OK' if ok else 'FAILED (nonzero exit)'}")
+        if not ok:
+            print(test_result.stdout[-2000:])
+            print(test_result.stderr[-2000:])
+    except subprocess.TimeoutExpired:
+        print(" FAILED: docker stack test timed out")
+        ok = False
+
+    DOCKER_DB_COPY_PATH.unlink(missing_ok=True)
+    run(["docker", "compose", "cp", "proxy:/app/data/watchtower.db", str(DOCKER_DB_COPY_PATH)])
+    run(["docker", "compose", "logs", "proxy"])
+    run(["docker", "compose", "down", "-v"])
+
+    print()
+    return ok
📝 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
test_result = run(
[sys.executable, "tests/test_docker_stack.py"],
capture_output=True,
text=True,
timeout=60,
)
ok = test_result.returncode == 0
print(f" {'OK' if ok else 'FAILED (nonzero exit)'}")
if not ok:
print(test_result.stdout[-2000:])
print(test_result.stderr[-2000:])
DOCKER_DB_COPY_PATH.unlink(missing_ok=True)
run(["docker", "compose", "cp", "proxy:/app/data/watchtower.db", str(DOCKER_DB_COPY_PATH)])
run(["docker", "compose", "logs", "proxy"])
run(["docker", "compose", "down"])
try:
test_result = run(
[sys.executable, "tests/test_docker_stack.py"],
capture_output=True,
text=True,
timeout=60,
)
ok = test_result.returncode == 0
print(f" {'OK' if ok else 'FAILED (nonzero exit)'}")
if not ok:
print(test_result.stdout[-2000:])
print(test_result.stderr[-2000:])
except subprocess.TimeoutExpired:
print(" FAILED: docker stack test timed out")
ok = False
DOCKER_DB_COPY_PATH.unlink(missing_ok=True)
run(["docker", "compose", "cp", "proxy:/app/data/watchtower.db", str(DOCKER_DB_COPY_PATH)])
run(["docker", "compose", "logs", "proxy"])
run(["docker", "compose", "down", "-v"])
print()
return ok
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/run_attack_simulation.py` around lines 78 - 93, Ensure the test_result
subprocess call in the main simulation flow is covered by the existing teardown
finally path so TimeoutExpired also stops the Compose stack. Update the final
docker compose down command to include volume removal, matching the earlier
teardown behavior and ensuring watchtower-db is deleted after successful runs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant