diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b54bc..9ea999e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.6.1] — 2026-11-10 + +### Fixed +- **docker**: Install `/usr/local/bin/torproxy` CLI entrypoint in container image so `docker exec -i torproxy torproxy mcp` works as documented (#124, #125) +- **mcp**: Support MCP Streamable HTTP (`POST /mcp`, `POST /sse`, `POST /`), preserve query-token SSE session authentication, use `hmac.compare_digest`, and bind `AF_INET6` for IPv6 addresses (#124, #125) +- **gateway**: Exempt MCP port `2501` in transparent gateway netfilter rules (#124, #125) +- **mcp**: Negotiate `protocolVersion`, support `prompts/list` and `resources/templates/list`, accept `strict_nodes` alias, and block non-HTTP(S) redirects in `torproxy_fetch` (#124, #125) + ## [2.6.0] — 2026-11-10 ### Added @@ -191,7 +199,8 @@ Sprint 1 security & contract correctness release. --- -[Unreleased]: https://github.com/hapheus/torproxy/compare/v2.6.0...HEAD +[Unreleased]: https://github.com/hapheus/torproxy/compare/v2.6.1...HEAD +[2.6.1]: https://github.com/hapheus/torproxy/compare/v2.6.0...v2.6.1 [2.6.0]: https://github.com/hapheus/torproxy/compare/v2.5.0...v2.6.0 [2.1.1]: https://github.com/hapheus/torproxy/compare/v2.1.0...v2.1.1 [2.1.0]: https://github.com/hapheus/torproxy/compare/v2.0.2...v2.1.0 diff --git a/docker/Dockerfile b/docker/Dockerfile index d6a084f..9558c4d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -7,7 +7,7 @@ ARG PYTHON_VERSION=3.14 LABEL org.opencontainers.image.title="torproxy" \ org.opencontainers.image.description="A minimal HTTP proxy routed through Tor" \ - org.opencontainers.image.version="2.6.0" \ + org.opencontainers.image.version="2.6.1" \ org.opencontainers.image.licenses="MIT" \ org.opencontainers.image.source="https://github.com/hapheus/torproxy" \ org.opencontainers.image.url="https://github.com/hapheus/torproxy" @@ -51,8 +51,11 @@ RUN SITE_PACKAGES="$(python3 -c 'import sysconfig; print(sysconfig.get_paths()[" && mkdir -p "$SITE_PACKAGES" \ && cp -a /app/torproxy-src "$SITE_PACKAGES/torproxy" \ && cp /app/pyproject.toml "$SITE_PACKAGES/pyproject.toml" \ + && printf '#!/usr/bin/env python3\nimport sys\nfrom torproxy.runtime.cli import main\nif __name__ == "__main__":\n sys.exit(main())\n' > /usr/local/bin/torproxy \ + && chmod 0755 /usr/local/bin/torproxy \ && rm -rf /app \ - && python3 -c "import torproxy; print(f'torproxy {torproxy.__version__} import OK on Python ' + __import__('sys').version)" + && python3 -c "import torproxy; print(f'torproxy {torproxy.__version__} import OK on Python ' + __import__('sys').version)" \ + && torproxy --version COPY --chmod=755 docker/start.sh /usr/local/bin/start.sh COPY --chmod=755 docker/healthcheck.sh /usr/local/bin/healthcheck.sh COPY --chmod=644 docker/privoxy.config.template /etc/privoxy/config.template diff --git a/docs/mcp.md b/docs/mcp.md index abdb3fa..f20f6d4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -15,9 +15,9 @@ It supports two distinct transport protocols: - Communicates using newline-delimited JSON-RPC 2.0 frames over `stdin` and `stdout`. - All internal diagnostics and operational warnings are directed to `stderr` to maintain clean protocol isolation. - Recommended for **Claude Desktop**. -2. **HTTP/SSE Transport (Port 2501):** +2. **HTTP/SSE & Streamable HTTP Transport (Port 2501):** - Runs as a non-blocking daemon thread on port `2501` (`TORPROXY_MCP_PORT=2501`). - - Exposes `GET /sse` for Server-Sent Events stream initialization and `POST /messages?sessionId=...` for JSON-RPC dispatch. + - Supports both **HTTP/SSE** (`GET /sse` or `GET /mcp` for Server-Sent Events stream initialization + `POST /messages?sessionId=...` for JSON-RPC dispatch) and **Streamable HTTP** (direct JSON-RPC `POST` to `/mcp`, `/sse`, or `/`). - Recommended for **Cursor**, **Antigravity**, and remote container orchestrators. --- diff --git a/docs/releases.md b/docs/releases.md index d2ecab1..fbb07a2 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -91,6 +91,12 @@ If an upgraded image exhibits unexpected behavior in your environment: ## 6. Release Notes +### 2.6.1 (MCP server CLI entrypoint, Streamable HTTP & transport hardening) + +- **Container CLI Entrypoint (#124, PR #125):** Installed `/usr/local/bin/torproxy` inside the Docker image and verified `torproxy --version` at build time so `docker exec -i torproxy torproxy mcp` works out of the box. +- **Streamable HTTP & SSE Hardening (#124, PR #125):** Added MCP `2025-03-26+` Streamable HTTP support (`POST /mcp`, `POST /sse`, `POST /`), authenticated SSE session continuity for `?token=...` clients, constant-time `hmac.compare_digest` token verification, IPv6 listener support (`AF_INET6`), and non-blocking session shutdown. +- **Gateway Mode & Protocol Hardening (#124, PR #125):** Exempted MCP port `2501` in transparent gateway netfilter rules (`TORPROXY_GATEWAY_MODE=true`), added `protocolVersion` negotiation and `_SafeRedirectHandler` redirect scheme validation in `torproxy_fetch`. + ### 2.6.0 (Model Context Protocol server & torproxy_fetch) - **Integrated MCP Server (ISSUE-122, PR #123):** Built-in zero-dependency Model Context Protocol (MCP) server running on port `2501` (HTTP/SSE) and via Stdio (`torproxy mcp`), enabling autonomous AI agents (Claude Desktop, Cursor, Antigravity) to orchestrate Tor circuits and inspect status. diff --git a/pyproject.toml b/pyproject.toml index 98b0f36..ed37e95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "torproxy" -version = "2.6.0" +version = "2.6.1" description = "A minimal HTTP and SOCKS5 proxy routed through the Tor network with local REST control API" readme = "README.md" requires-python = ">=3.10" diff --git a/src/torproxy/mcp/protocol.py b/src/torproxy/mcp/protocol.py index e49f735..283b2d2 100644 --- a/src/torproxy/mcp/protocol.py +++ b/src/torproxy/mcp/protocol.py @@ -11,6 +11,27 @@ from torproxy.service import ControlFailure, InvalidInput, ProxyService MCP_PROTOCOL_VERSION = "2024-11-05" +SUPPORTED_PROTOCOL_VERSIONS = ("2024-11-05", "2025-03-26", "2025-06-18") + + +class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request( + self, + req: urllib.request.Request, + fp: Any, + code: int, + msg: str, + headers: Any, + newurl: str, + ) -> urllib.request.Request | None: + parsed = urllib.parse.urlparse(newurl) + if parsed.scheme not in ("http", "https"): + raise urllib.error.URLError( + f"Unsupported redirect URL scheme '{parsed.scheme}'. " + "Only http and https are allowed." + ) + return super().redirect_request(req, fp, code, msg, headers, newurl) + TOOLS_SCHEMA: list[dict[str, Any]] = [ { @@ -193,11 +214,17 @@ def handle_jsonrpc(self, req: dict[str, Any]) -> dict[str, Any] | None: return None if method == "initialize": + client_version = params.get("protocolVersion") + negotiated_version = ( + client_version + if isinstance(client_version, str) and client_version in SUPPORTED_PROTOCOL_VERSIONS + else MCP_PROTOCOL_VERSION + ) return { "jsonrpc": "2.0", "id": req_id, "result": { - "protocolVersion": MCP_PROTOCOL_VERSION, + "protocolVersion": negotiated_version, "capabilities": { "tools": {}, "resources": {}, @@ -212,6 +239,12 @@ def handle_jsonrpc(self, req: dict[str, Any]) -> dict[str, Any] | None: if method == "ping": return {"jsonrpc": "2.0", "id": req_id, "result": {}} + if method == "prompts/list": + return {"jsonrpc": "2.0", "id": req_id, "result": {"prompts": []}} + + if method == "resources/templates/list": + return {"jsonrpc": "2.0", "id": req_id, "result": {"resourceTemplates": []}} + if method == "tools/list": return { "jsonrpc": "2.0", @@ -271,7 +304,7 @@ def _call_tool(self, req_id: Any, name: str, args: dict[str, Any]) -> dict[str, if name in ("torproxy_reconnect", "torproxy_connect"): action = "reconnect" if name == "torproxy_reconnect" else "connect" country = args.get("country") - strict = args.get("strict") + strict = args.get("strict") if "strict" in args else args.get("strict_nodes") try: res = self.service.mutate(action, country=country, strict=strict) return { @@ -391,17 +424,21 @@ def _call_fetch(self, req_id: Any, args: dict[str, Any]) -> dict[str, Any]: try: timeout = float(args.get("timeout", 30)) + if timeout <= 0: + timeout = 30.0 except (ValueError, TypeError): timeout = 30.0 try: max_bytes = int(args.get("max_bytes", 1048576)) + if max_bytes <= 0: + max_bytes = 1048576 except (ValueError, TypeError): max_bytes = 1048576 proxy_url = f"http://127.0.0.1:{self.service.settings.proxy_port}" proxy_handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}) - opener = urllib.request.build_opener(proxy_handler) + opener = urllib.request.build_opener(proxy_handler, _SafeRedirectHandler()) req = urllib.request.Request(raw_url, data=data, headers=headers, method=method) diff --git a/src/torproxy/mcp/sse.py b/src/torproxy/mcp/sse.py index 00cc783..5572710 100644 --- a/src/torproxy/mcp/sse.py +++ b/src/torproxy/mcp/sse.py @@ -1,7 +1,9 @@ -"""HTTP/SSE transport for Model Context Protocol (MCP).""" +"""HTTP/SSE and Streamable HTTP transport for Model Context Protocol (MCP).""" +import hmac import json import queue +import socket import threading import urllib.parse import uuid @@ -14,6 +16,7 @@ class McpSseServer(ThreadingHTTPServer): daemon_threads = True + block_on_close = False def __init__( self, @@ -26,6 +29,8 @@ def __init__( self.sessions: dict[str, queue.Queue[str | None]] = {} self.sessions_lock = threading.Lock() self._is_shut_down = threading.Event() + if ":" in server_address[0]: + self.address_family = socket.AF_INET6 super().__init__(server_address, McpSseHandler) def create_session(self) -> tuple[str, queue.Queue[str | None]]: @@ -43,12 +48,19 @@ def remove_session(self, session_id: str) -> None: with self.sessions_lock: self.sessions.pop(session_id, None) - def server_close(self) -> None: + def _close_all_sessions(self) -> None: self._is_shut_down.set() with self.sessions_lock: for q in self.sessions.values(): q.put(None) self.sessions.clear() + + def shutdown(self) -> None: + self._close_all_sessions() + super().shutdown() + + def server_close(self) -> None: + self._close_all_sessions() super().server_close() def handle_error(self, request: Any, client_address: Any) -> None: @@ -63,25 +75,34 @@ def handle_error(self, request: Any, client_address: Any) -> None: class McpSseHandler(BaseHTTPRequestHandler): server: McpSseServer + def _has_explicit_credentials(self, query_params: dict[str, list[str]]) -> bool: + return bool( + self.headers.get("Authorization", "").strip() + or self.headers.get("X-API-Key", "").strip() + or query_params.get("token", [""])[0].strip() + ) + def _check_auth(self, query_params: dict[str, list[str]]) -> bool: expected = self.server.settings.api_token if not expected: return True + expected_bytes = expected.encode("utf-8") # 1. Bearer header auth_header = self.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:].strip() - if token == expected: + if hmac.compare_digest(token.encode("utf-8"), expected_bytes): return True # 2. X-API-Key header - if self.headers.get("X-API-Key", "").strip() == expected: + api_key = self.headers.get("X-API-Key", "").strip() + if api_key and hmac.compare_digest(api_key.encode("utf-8"), expected_bytes): return True # 3. Query param ?token=... query_token = query_params.get("token", [""])[0].strip() - if query_token == expected: + if query_token and hmac.compare_digest(query_token.encode("utf-8"), expected_bytes): return True return False @@ -91,9 +112,49 @@ def _send_cors_headers(self) -> None: self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header( "Access-Control-Allow-Headers", - "Content-Type, Authorization, X-API-Key", + "Content-Type, Authorization, X-API-Key, Mcp-Session-Id", ) + def _read_json_body(self) -> dict[str, Any] | None: + raw_len = self.headers.get("Content-Length", "0").strip() + if not raw_len.isascii() or not raw_len.isdecimal(): + self.send_response(400) + self._send_cors_headers() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error": "Invalid Content-Length"}\n') + return None + + content_length = int(raw_len) + if content_length > self.server.settings.max_body_bytes: + self.send_response(413) + self._send_cors_headers() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error": "Payload Too Large"}\n') + return None + + body_bytes = self.rfile.read(content_length) + try: + req = json.loads(body_bytes.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + self.send_response(400) + self._send_cors_headers() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error": "Invalid JSON body"}\n') + return None + + if not isinstance(req, dict): + self.send_response(400) + self._send_cors_headers() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error": "JSON body must be an object"}\n') + return None + + return req + def do_OPTIONS(self) -> None: self.send_response(204) self._send_cors_headers() @@ -104,7 +165,7 @@ def do_GET(self) -> None: parsed = urllib.parse.urlparse(self.path) qs = urllib.parse.parse_qs(parsed.query) - if parsed.path != "/sse": + if parsed.path not in ("/sse", "/mcp"): self.send_response(404) self._send_cors_headers() self.send_header("Content-Type", "application/json") @@ -154,69 +215,94 @@ def do_POST(self) -> None: parsed = urllib.parse.urlparse(self.path) qs = urllib.parse.parse_qs(parsed.query) - if parsed.path != "/messages": - self.send_response(404) + if parsed.path == "/messages": + session_id = qs.get("sessionId", [None])[0] + q = self.server.get_session(session_id) if session_id else None + + if self.server.settings.api_token: + if self._has_explicit_credentials(qs): + if not self._check_auth(qs): + self.send_response(401) + self._send_cors_headers() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error": "Unauthorized"}\n') + return + elif q is None: + self.send_response(401) + self._send_cors_headers() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error": "Unauthorized"}\n') + return + + if not session_id: + self.send_response(400) + self._send_cors_headers() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error": "Missing sessionId query parameter"}\n') + return + + if q is None: + self.send_response(404) + self._send_cors_headers() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error": "Session not found"}\n') + return + + req = self._read_json_body() + if req is None: + return + + resp = self.server.handler.handle_jsonrpc(req) + if resp is not None: + q.put(json.dumps(resp)) + + self.send_response(202) self._send_cors_headers() self.send_header("Content-Type", "application/json") self.end_headers() - self.wfile.write(b'{"error": "Not Found"}\n') + self.wfile.write(b'{"status": "accepted"}\n') return - if not self._check_auth(qs): - self.send_response(401) - self._send_cors_headers() - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(b'{"error": "Unauthorized"}\n') - return - - session_id = qs.get("sessionId", [None])[0] - if not session_id: - self.send_response(400) - self._send_cors_headers() - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(b'{"error": "Missing sessionId query parameter"}\n') - return - - q = self.server.get_session(session_id) - if q is None: - self.send_response(404) - self._send_cors_headers() - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(b'{"error": "Session not found"}\n') - return - - content_length = int(self.headers.get("Content-Length", 0)) - if content_length > self.server.settings.max_body_bytes: - self.send_response(413) + if parsed.path in ("/mcp", "/sse", "/"): + if not self._check_auth(qs): + self.send_response(401) + self._send_cors_headers() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error": "Unauthorized"}\n') + return + + req = self._read_json_body() + if req is None: + return + + resp = self.server.handler.handle_jsonrpc(req) + if resp is None: + self.send_response(202) + self._send_cors_headers() + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"status": "accepted"}\n') + return + + payload = (json.dumps(resp) + "\n").encode("utf-8") + self.send_response(200) self._send_cors_headers() self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) self.end_headers() - self.wfile.write(b'{"error": "Payload Too Large"}\n') + self.wfile.write(payload) return - body_bytes = self.rfile.read(content_length) - try: - req = json.loads(body_bytes.decode("utf-8")) - except json.JSONDecodeError: - self.send_response(400) - self._send_cors_headers() - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(b'{"error": "Invalid JSON body"}\n') - return - - resp = self.server.handler.handle_jsonrpc(req) - if resp is not None: - q.put(json.dumps(resp)) - - self.send_response(202) + self.send_response(404) self._send_cors_headers() self.send_header("Content-Type", "application/json") self.end_headers() - self.wfile.write(b'{"status": "accepted"}\n') + self.wfile.write(b'{"error": "Not Found"}\n') def log_message(self, format: str, *args: Any) -> None: # Suppress noisy standard HTTP access logs diff --git a/src/torproxy/runtime/gateway.py b/src/torproxy/runtime/gateway.py index 2728781..5deb090 100644 --- a/src/torproxy/runtime/gateway.py +++ b/src/torproxy/runtime/gateway.py @@ -11,7 +11,7 @@ def generate_iptables_rules( trans_port: int = 9040, dns_port: int = 9053, tor_user: str = "tor", - exempt_ports: tuple[int, ...] = (8118, 8080, 9050, 9051), + exempt_ports: tuple[int, ...] = (8118, 8080, 9050, 9051, 2501), ) -> list[list[str]]: """Generate iptables and ip6tables commands for transparent Tor routing and kill switch. @@ -297,7 +297,13 @@ def setup_gateway(settings: Settings) -> None: "Please add 'cap_add: [NET_ADMIN]' to your Docker compose configuration." ) - exempt = (settings.proxy_port, settings.api_port, settings.socks_port, settings.control_port) + exempt: tuple[int, ...] = ( + settings.proxy_port, + settings.api_port, + settings.socks_port, + settings.control_port, + *((settings.mcp_port,) if settings.mcp_enabled else ()), + ) rules = generate_iptables_rules( trans_port=settings.trans_port, dns_port=settings.dns_port, diff --git a/tests/integration/verify_container.sh b/tests/integration/verify_container.sh index d157105..6f3c3c7 100755 --- a/tests/integration/verify_container.sh +++ b/tests/integration/verify_container.sh @@ -72,8 +72,8 @@ echo "$LEGACY_RESP" | jq -e '.action == "reconnect" and .status == "success"' >/ echo "✅ REST API contracts verified." echo "=== 3b. MCP Server Verification ===" -# Test MCP Stdio ping & tools/list -MCP_STDIO_RESP=$(echo '{"jsonrpc":"2.0","id":1,"method":"ping"}' | docker exec -i "$CONTAINER_NAME" python3 -m torproxy mcp 2>/dev/null) +# Test MCP Stdio ping via documented CLI binary ('torproxy mcp') & python module +MCP_STDIO_RESP=$(echo '{"jsonrpc":"2.0","id":1,"method":"ping"}' | docker exec -i "$CONTAINER_NAME" torproxy mcp 2>/dev/null) echo "MCP stdio ping response: $MCP_STDIO_RESP" echo "$MCP_STDIO_RESP" | jq -e '.result == {}' >/dev/null @@ -84,7 +84,12 @@ echo "$MCP_TOOLS_RESP" | jq -e '.result.tools | map(.name) | contains(["torproxy SSE_ENDPOINT_RESP=$(docker exec "$CONTAINER_NAME" curl -fsS --max-time 3 http://127.0.0.1:2501/sse 2>/dev/null | head -n 2 || true) echo "MCP SSE endpoint response: $SSE_ENDPOINT_RESP" echo "$SSE_ENDPOINT_RESP" | grep -q "event: endpoint" -echo "✅ MCP Server (stdio & SSE port 2501) verified." + +# Test MCP Streamable HTTP POST on internal port 2501 +STREAMABLE_RESP=$(docker exec "$CONTAINER_NAME" curl -fsS --max-time 5 -X POST http://127.0.0.1:2501/mcp -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":3,"method":"ping"}' 2>/dev/null) +echo "MCP Streamable HTTP response: $STREAMABLE_RESP" +echo "$STREAMABLE_RESP" | jq -e '.result == {}' >/dev/null +echo "✅ MCP Server (stdio CLI, SSE & Streamable HTTP on port 2501) verified." echo "=== 4. SOCKS5 Handshake Verification ===" # Send RFC1928 greeting: \x05\x01\x00 (Version 5, 1 auth method, NO AUTH) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index c9b497f..4746384 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -417,3 +417,10 @@ def test_mcp_port_collision_with_api_port(self): with pytest.raises(ValueError, match="distinct ports"): Settings.from_mapping({"TORPROXY_MCP_PORT": "8080"}) + + def test_dockerfile_installs_torproxy_cli_binary(self): + from pathlib import Path + + dockerfile = Path("docker/Dockerfile").read_text(encoding="utf-8") + assert "/usr/local/bin/torproxy" in dockerfile + assert "torproxy --version" in dockerfile diff --git a/tests/unit/test_gateway.py b/tests/unit/test_gateway.py index 46c7078..b4d0ab1 100644 --- a/tests/unit/test_gateway.py +++ b/tests/unit/test_gateway.py @@ -94,6 +94,12 @@ def test_custom_ports_in_rules(self): assert any("--to-ports 9055" in c for c in commands) assert any("--uid-owner customuser" in c for c in commands) + def test_mcp_port_exempt_in_gateway_rules(self): + rules = generate_iptables_rules(trans_port=9040, dns_port=9053, tor_user="tor") + commands = [" ".join(cmd) for cmd in rules] + assert any("nat -A PREROUTING -p tcp --dport 2501 -j RETURN" in c for c in commands) + assert any("-A INPUT -p tcp --dport 2501 -j ACCEPT" in c for c in commands) + @pytest.mark.unit class TestGatewayLifecycle: diff --git a/tests/unit/test_mcp_protocol.py b/tests/unit/test_mcp_protocol.py index 1218df5..6d402df 100644 --- a/tests/unit/test_mcp_protocol.py +++ b/tests/unit/test_mcp_protocol.py @@ -393,3 +393,56 @@ def test_mcp_tools_call_fetch_http_error(mcp_setup, monkeypatch): content = json.loads(resp["result"]["content"][0]["text"]) assert content["status"] == 404 assert content["body"] == "Not Found" + + +@pytest.mark.unit +def test_mcp_initialize_version_negotiation_and_optional_lists(mcp_setup): + handler, _, _ = mcp_setup + req = { + "jsonrpc": "2.0", + "id": 30, + "method": "initialize", + "params": {"protocolVersion": "2025-03-26"}, + } + resp = handler.handle_jsonrpc(req) + assert resp["result"]["protocolVersion"] == "2025-03-26" + + prompts_resp = handler.handle_jsonrpc({"jsonrpc": "2.0", "id": 31, "method": "prompts/list"}) + assert prompts_resp["result"] == {"prompts": []} + + templates_resp = handler.handle_jsonrpc( + {"jsonrpc": "2.0", "id": 32, "method": "resources/templates/list"} + ) + assert templates_resp["result"] == {"resourceTemplates": []} + + +@pytest.mark.unit +def test_mcp_reconnect_accepts_strict_nodes_alias(mcp_setup): + handler, _, _ = mcp_setup + req = { + "jsonrpc": "2.0", + "id": 33, + "method": "tools/call", + "params": { + "name": "torproxy_reconnect", + "arguments": {"country": "at", "strict_nodes": True}, + }, + } + resp = handler.handle_jsonrpc(req) + assert resp["result"]["isError"] is False + content = json.loads(resp["result"]["content"][0]["text"]) + assert content["exit_country"] == "AT" + assert content["strict_nodes"] is True + + +@pytest.mark.unit +def test_mcp_fetch_rejects_redirect_to_disallowed_scheme(mcp_setup): + import urllib.request + from email.message import Message + + from torproxy.mcp.protocol import _SafeRedirectHandler + + redirect_handler = _SafeRedirectHandler() + req = urllib.request.Request("https://example.com/start") + with pytest.raises(urllib.error.URLError, match="Unsupported redirect URL scheme"): + redirect_handler.redirect_request(req, None, 302, "Found", Message(), "file:///etc/passwd") diff --git a/tests/unit/test_mcp_sse.py b/tests/unit/test_mcp_sse.py index f0cc5e4..80499c1 100644 --- a/tests/unit/test_mcp_sse.py +++ b/tests/unit/test_mcp_sse.py @@ -230,3 +230,63 @@ def test_sse_post_payload_too_large(running_sse_server): urllib.request.urlopen(post_req) assert exc_info.value.code == 413 stream_resp.close() + + +@pytest.mark.unit +def test_sse_query_token_authenticated_session_can_post_messages(running_sse_server): + _, base_url = running_sse_server({"TORPROXY_API_TOKEN": "topsecret"}) + + # Connect via query parameter only (as EventSource / URL-only MCP clients do) + resp = urllib.request.urlopen(f"{base_url}/sse?token=topsecret") + line1 = resp.readline().decode().strip() + line2 = resp.readline().decode().strip() + resp.readline() + assert line1 == "event: endpoint" + endpoint_path = line2.split("data: ")[1].strip() + + # Client resolves endpoint relative to base URL (which strips ?token=topsecret) + post_url = f"{base_url}{endpoint_path}" + body = json.dumps({"jsonrpc": "2.0", "id": "auth-1", "method": "ping"}).encode() + post_req = urllib.request.Request( + post_url, data=body, headers={"Content-Type": "application/json"}, method="POST" + ) + with urllib.request.urlopen(post_req) as post_resp: + assert post_resp.status in (200, 202) + + ev_line = resp.readline().decode().strip() + data_line = resp.readline().decode().strip() + resp.readline() + assert ev_line == "event: message" + msg_data = json.loads(data_line.split("data: ")[1]) + assert msg_data["id"] == "auth-1" + assert msg_data["result"] == {} + resp.close() + + +@pytest.mark.unit +def test_streamable_http_post_on_sse_and_mcp_paths(running_sse_server): + _, base_url = running_sse_server() + + for path in ("/sse", "/mcp", "/"): + body = json.dumps({"jsonrpc": "2.0", "id": "s-1", "method": "ping"}).encode() + req = urllib.request.Request( + f"{base_url}{path}", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req) as resp: + assert resp.status == 200 + data = json.loads(resp.read().decode("utf-8")) + assert data == {"jsonrpc": "2.0", "id": "s-1", "result": {}} + + # Notification over Streamable HTTP returns 202 Accepted + notif = json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}).encode() + notif_req = urllib.request.Request( + f"{base_url}/mcp", + data=notif, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(notif_req) as resp: + assert resp.status == 202 diff --git a/uv.lock b/uv.lock index 8775884..178cca2 100644 --- a/uv.lock +++ b/uv.lock @@ -842,7 +842,7 @@ wheels = [ [[package]] name = "torproxy" -version = "2.6.0" +version = "2.6.1" source = { editable = "." } [package.optional-dependencies]