diff --git a/README.md b/README.md index bfcc67d..0be9b61 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ unchanged. | `PLANE_API_KEY` | stdio | API key | | `PLANE_WORKSPACE_SLUG` | stdio | Target workspace | | `PLANE_BASE_URL` | optional | Plane API URL (default `https://api.plane.so`) | +| `PLANE_SSL_VERIFY` | optional | TLS verification for `PLANE_BASE_URL` / `PLANE_INTERNAL_BASE_URL` (default: verify normally). `false`/`0`/`no` disables verification entirely — a warning is logged on every use, since this removes protection against a man-in-the-middle; only use it for a self-hosted instance you control. A path to an existing file is treated as a CA bundle. | The remote transports carry credentials in the connection — the OAuth flow or the PAT headers — and need none of these. diff --git a/plane_mcp/client.py b/plane_mcp/client.py index dd8c6b6..ea33db6 100644 --- a/plane_mcp/client.py +++ b/plane_mcp/client.py @@ -18,6 +18,44 @@ class PlaneClientContext(NamedTuple): workspace_slug: str +def _resolve_ssl_verify(base_url: str) -> bool | str: + """ + Resolve TLS verification mode from PLANE_SSL_VERIFY. + + - Unset (default): verify=True, normal certificate verification. + - "false"/"0"/"no" (case-insensitive): verify=False, disable verification + entirely. Logs a warning on every call since this drops protection + against a man-in-the-middle. + - Any other value that names an existing file path: treated as a CA + bundle and passed through verbatim. + - Anything else: verify=True (fail safe rather than silently misparse). + """ + raw = os.environ.get("PLANE_SSL_VERIFY") + if not raw: + return True + + if raw.strip().lower() in ("false", "0", "no"): + logger.warning( + "TLS verification is DISABLED for %s (PLANE_SSL_VERIFY=%s). " + "The connection has no protection against a man-in-the-middle; " + "requests, responses, and the API key are exposed to anyone " + "positioned on the network path.", + base_url, + raw, + ) + return False + + if os.path.exists(raw): + return raw + + logger.warning( + "PLANE_SSL_VERIFY=%s is neither false/0/no nor an existing file path; " + "falling back to verify=True.", + raw, + ) + return True + + def get_plane_client_context() -> PlaneClientContext: """ Initialize and return a PlaneClient instance with workspace context. @@ -30,6 +68,9 @@ def get_plane_client_context() -> PlaneClientContext: Environment variables: - PLANE_INTERNAL_BASE_URL: Internal URL for Plane API (preferred for server-to-server calls) - PLANE_BASE_URL: Base URL for Plane API (fallback, default: https://api.plane.so) + - PLANE_SSL_VERIFY: TLS verification mode. Unset/default = verify normally. + "false"/"0"/"no" = disable verification entirely (logs a warning on every + use). A path to an existing file = treated as a CA bundle. Returns: PlaneClientContext containing configured PlaneClient instance and workspace slug @@ -57,15 +98,19 @@ def get_plane_client_context() -> PlaneClientContext: else: access_token = token + verify = _resolve_ssl_verify(base_url) + if access_token: client = PlaneClient( base_url=base_url, access_token=access_token, + verify=verify, ) else: client = PlaneClient( base_url=base_url, api_key=api_key, + verify=verify, ) return PlaneClientContext( diff --git a/tests/test_client_ssl_verify.py b/tests/test_client_ssl_verify.py new file mode 100644 index 0000000..b7d58ef --- /dev/null +++ b/tests/test_client_ssl_verify.py @@ -0,0 +1,38 @@ +"""Unit tests for PLANE_SSL_VERIFY parsing in plane_mcp.client._resolve_ssl_verify. + +Pure function, no network and no live Plane instance required. +""" + +import pytest + +from plane_mcp.client import _resolve_ssl_verify + + +def test_default_is_true_when_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PLANE_SSL_VERIFY", raising=False) + assert _resolve_ssl_verify("https://plane.example.com") is True + + +@pytest.mark.parametrize("raw", ["false", "False", "FALSE", "0", "no", "No"]) +def test_falsy_values_disable_verification(monkeypatch: pytest.MonkeyPatch, raw: str) -> None: + monkeypatch.setenv("PLANE_SSL_VERIFY", raw) + assert _resolve_ssl_verify("https://plane.example.com") is False + + +def test_existing_file_path_is_passed_through_verbatim( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + ca_bundle = tmp_path / "internal-ca.pem" + ca_bundle.write_text("fake cert content") + monkeypatch.setenv("PLANE_SSL_VERIFY", str(ca_bundle)) + assert _resolve_ssl_verify("https://plane.example.com") == str(ca_bundle) + + +def test_nonexistent_path_falls_back_to_true(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PLANE_SSL_VERIFY", "/no/such/path/ca.pem") + assert _resolve_ssl_verify("https://plane.example.com") is True + + +def test_empty_string_is_treated_as_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PLANE_SSL_VERIFY", "") + assert _resolve_ssl_verify("https://plane.example.com") is True