Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
6 changes: 6 additions & 0 deletions docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 40 additions & 3 deletions src/torproxy/mcp/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = [
{
Expand Down Expand Up @@ -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": {},
Expand All @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading