diff --git a/.env.example b/.env.example index 5c64b9db..19d2f5b7 100644 --- a/.env.example +++ b/.env.example @@ -76,9 +76,12 @@ BLOCKSCOUT_RPC_POOL_PER_HOST=50 BLOCKSCOUT_MCP_USER_AGENT="Blockscout MCP" # Optional Mixpanel analytics (HTTP mode only). Set token to enable; leave empty to disable. -# Use API host for regional endpoints (e.g., EU). No tracking occurs in stdio mode. +# No tracking occurs in stdio mode. BLOCKSCOUT_MIXPANEL_TOKEN="" -BLOCKSCOUT_MIXPANEL_API_HOST="" +# The ingestion region default (api-eu.mixpanel.com) lives in the server config. +# Leave this commented out to use that EU default. Set it to an empty value to fall +# back to Mixpanel's US host, or to an explicit regional host for other regions. +# BLOCKSCOUT_MIXPANEL_API_HOST="" # Disable community telemetry reporting. BLOCKSCOUT_DISABLE_COMMUNITY_TELEMETRY=false diff --git a/Dockerfile b/Dockerfile index 6812190f..0c0eaf59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,7 +45,7 @@ ENV BLOCKSCOUT_RPC_REQUEST_TIMEOUT="60.0" ENV BLOCKSCOUT_RPC_POOL_PER_HOST="50" ENV BLOCKSCOUT_MCP_USER_AGENT="Blockscout MCP" # ENV BLOCKSCOUT_MIXPANEL_TOKEN="" # Intentionally commented out: pass at runtime to avoid embedding secrets in image -ENV BLOCKSCOUT_MIXPANEL_API_HOST="" +# ENV BLOCKSCOUT_MIXPANEL_API_HOST="" # Intentionally commented out: the ingestion region default (api-eu.mixpanel.com) lives in config.py. Setting a value here — including an empty string — would override that default. Pass at runtime (e.g. -e BLOCKSCOUT_MIXPANEL_API_HOST=api.mixpanel.com) for a US or other-region project. ENV BLOCKSCOUT_DISABLE_COMMUNITY_TELEMETRY="false" ENV BLOCKSCOUT_INTERMEDIARY_HEADER="Blockscout-MCP-Intermediary" ENV BLOCKSCOUT_INTERMEDIARY_ALLOWLIST="ClaudeDesktop,HigressPlugin,EvaluationSuite" diff --git a/blockscout_mcp_server/__init__.py b/blockscout_mcp_server/__init__.py index 6e671f85..fc4f066d 100644 --- a/blockscout_mcp_server/__init__.py +++ b/blockscout_mcp_server/__init__.py @@ -1,4 +1,4 @@ # SPDX-License-Identifier: LicenseRef-Blockscout """Blockscout MCP Server package.""" -__version__ = "0.17.0.dev2" +__version__ = "0.17.0.dev3" diff --git a/blockscout_mcp_server/config.py b/blockscout_mcp_server/config.py index 77f57e41..926500a5 100644 --- a/blockscout_mcp_server/config.py +++ b/blockscout_mcp_server/config.py @@ -76,7 +76,10 @@ def normalize_pro_api_key_header(cls, value: str) -> str: # Analytics configuration mixpanel_token: str = "" - mixpanel_api_host: str = "" # Optional custom API host (e.g., EU region) + # Defaults to EU because the central analytics project uses EU data residency. + # Deployments whose Mixpanel project is US-resident (or in another region) should + # override this via BLOCKSCOUT_MIXPANEL_API_HOST (e.g. "api.mixpanel.com" for US). + mixpanel_api_host: str = "api-eu.mixpanel.com" disable_community_telemetry: bool = False # Transport mode for the server ("stdio" or "http"). diff --git a/pyproject.toml b/pyproject.toml index ba93c7de..b19c5dee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "blockscout-mcp-server" -version = "0.17.0.dev2" +version = "0.17.0.dev3" description = "MCP server for Blockscout" requires-python = ">=3.11" dependencies = [ diff --git a/server.json b/server.json index a6f87b9f..f3683d01 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "com.blockscout/mcp-server", "description": "MCP server for Blockscout", - "version": "0.17.0.dev2", + "version": "0.17.0.dev3", "websiteUrl": "https://blockscout.com", "repository": { "url": "https://github.com/blockscout/mcp-server", diff --git a/tests/test_analytics.py b/tests/test_analytics.py index f243804a..1be81d0d 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -34,9 +34,47 @@ def reset_mode_and_client(monkeypatch): monkeypatch.setattr(analytics, "_mp_client", None, raising=False) # type: ignore[attr-defined] +def test_get_mixpanel_client_non_empty_host_uses_custom_consumer(monkeypatch): + """A non-empty mixpanel_api_host routes construction through a custom Consumer.""" + monkeypatch.setattr(server_config, "mixpanel_token", "test-token", raising=False) + monkeypatch.setattr(server_config, "mixpanel_api_host", "api-eu.mixpanel.com", raising=False) + with ( + patch("blockscout_mcp_server.analytics.Consumer") as consumer_cls, + patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls, + ): + consumer_instance = MagicMock() + consumer_cls.return_value = consumer_instance + mp_instance = MagicMock() + mp_cls.return_value = mp_instance + + client = analytics._get_mixpanel_client() + + consumer_cls.assert_called_once_with(api_host="api-eu.mixpanel.com") + mp_cls.assert_called_once_with("test-token", consumer=consumer_instance) + assert client is mp_instance + + +def test_get_mixpanel_client_empty_host_uses_sdk_default(monkeypatch): + """An empty mixpanel_api_host skips the custom Consumer and uses the SDK's built-in host.""" + monkeypatch.setattr(server_config, "mixpanel_token", "test-token", raising=False) + monkeypatch.setattr(server_config, "mixpanel_api_host", "", raising=False) + with ( + patch("blockscout_mcp_server.analytics.Consumer") as consumer_cls, + patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls, + ): + mp_instance = MagicMock() + mp_cls.return_value = mp_instance + + client = analytics._get_mixpanel_client() + + consumer_cls.assert_not_called() + mp_cls.assert_called_once_with("test-token") + assert client is mp_instance + + def test_noop_when_not_http_mode(monkeypatch): monkeypatch.setattr(server_config, "mixpanel_token", "test-token", raising=False) - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: analytics.track_tool_invocation(DummyCtx(), "some_tool", {"a": 1}) mp_cls.assert_not_called() @@ -44,7 +82,7 @@ def test_noop_when_not_http_mode(monkeypatch): def test_noop_when_no_token(monkeypatch): monkeypatch.setattr(server_config, "mixpanel_token", "", raising=False) analytics.set_http_mode(True) - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: analytics.track_tool_invocation(DummyCtx(), "some_tool", {"a": 1}) mp_cls.assert_not_called() @@ -54,7 +92,7 @@ def test_tracks_with_headers(monkeypatch): headers = {"x-forwarded-for": "203.0.113.5, 70.41.3.18", "user-agent": "pytest-UA"} req = DummyRequest(headers=headers) ctx = DummyCtx(request=req, client_name="clientA", client_version="1.0.0") - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: mp_instance = MagicMock() mp_cls.return_value = mp_instance analytics.set_http_mode(True) @@ -86,7 +124,7 @@ def test_tracks_with_intermediary_header(monkeypatch): } req = DummyRequest(headers=headers) ctx = DummyCtx(request=req, client_name="node", client_version="1.0.0") - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: mp_instance = MagicMock() mp_cls.return_value = mp_instance analytics.set_http_mode(True) @@ -104,7 +142,7 @@ def test_tracks_with_invalid_intermediary(monkeypatch): } req = DummyRequest(headers=headers) ctx = DummyCtx(request=req, client_name="node", client_version="1.0.0") - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: mp_instance = MagicMock() mp_cls.return_value = mp_instance analytics.set_http_mode(True) @@ -122,7 +160,7 @@ def test_tracks_with_intermediary_and_user_agent_fallback(monkeypatch): } req = DummyRequest(headers=headers) ctx = DummyCtx(request=req, client_name="", client_version="") - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: mp_instance = MagicMock() mp_cls.return_value = mp_instance analytics.set_http_mode(True) @@ -136,7 +174,7 @@ def test_tracks_with_intermediary_no_client_or_user_agent(monkeypatch): headers = {"Blockscout-MCP-Intermediary": "ClaudeDesktop"} req = DummyRequest(headers=headers) ctx = DummyCtx(request=req, client_name="", client_version="") - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: mp_instance = MagicMock() mp_cls.return_value = mp_instance analytics.set_http_mode(True) @@ -184,7 +222,7 @@ def test_tracks_auth_origin_unknown_when_not_threaded(monkeypatch): def test_track_event_tracks_when_enabled(monkeypatch): monkeypatch.setattr(server_config, "mixpanel_token", "test-token", raising=False) req = DummyRequest(headers={"user-agent": "pytest-UA"}, host="203.0.113.5") - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: mp_instance = MagicMock() mp_cls.return_value = mp_instance analytics.set_http_mode(True) @@ -202,7 +240,7 @@ def test_track_event_noop_when_disabled(monkeypatch): monkeypatch.setattr(server_config, "mixpanel_token", "", raising=False) analytics.set_http_mode(True) req = DummyRequest() - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: analytics.track_event(req, "PageView", {"path": "/"}) mp_cls.assert_not_called() @@ -225,7 +263,7 @@ def test_pro_api_key_not_in_analytics_payload(monkeypatch): req = DummyRequest(headers=headers) ctx = DummyCtx(request=req, client_name="test-client", client_version="1.0.0") - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: mp_instance = MagicMock() mp_cls.return_value = mp_instance analytics.set_http_mode(True) @@ -273,7 +311,7 @@ def test_pro_api_key_not_in_analytics_payload_rest_source(monkeypatch): # Explicitly mark this context as coming from the REST path ctx.call_source = "rest" - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: mp_instance = MagicMock() mp_cls.return_value = mp_instance analytics.set_http_mode(True) @@ -304,7 +342,7 @@ def test_pro_api_key_not_in_analytics_payload_rest_source(monkeypatch): def test_track_community_usage(monkeypatch): monkeypatch.setattr(server_config, "mixpanel_token", "test-token", raising=False) - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: mp_instance = MagicMock() mp_cls.return_value = mp_instance analytics.set_http_mode(True) @@ -412,7 +450,7 @@ def test_track_community_usage_fingerprint_never_reaches_mixpanel(monkeypatch): def test_track_resource_read_noop_when_not_http_mode(monkeypatch): """No Mixpanel call when HTTP mode is disabled.""" monkeypatch.setattr(server_config, "mixpanel_token", "test-token", raising=False) - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: analytics.track_resource_read(DummyCtx(), "blockscout-mcp://skill/SKILL.md") mp_cls.assert_not_called() @@ -421,7 +459,7 @@ def test_track_resource_read_noop_when_no_token(monkeypatch): """No Mixpanel call when HTTP mode is on but no token is configured.""" monkeypatch.setattr(server_config, "mixpanel_token", "", raising=False) analytics.set_http_mode(True) - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: analytics.track_resource_read(DummyCtx(), "blockscout-mcp://skill/SKILL.md") mp_cls.assert_not_called() @@ -437,7 +475,7 @@ def test_track_resource_read_emits_correct_event(monkeypatch): } req = DummyRequest(headers=headers) ctx = DummyCtx(request=req, client_name="clientA", client_version="1.0.0") - with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + with patch("blockscout_mcp_server.analytics.Consumer"), patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: mp_instance = MagicMock() mp_cls.return_value = mp_instance analytics.set_http_mode(True) diff --git a/tests/test_config.py b/tests/test_config.py index a498fcc9..e31f9773 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -151,3 +151,21 @@ def test_pro_api_low_credits_threshold_negative_rejected(monkeypatch): monkeypatch.setenv("BLOCKSCOUT_PRO_API_LOW_CREDITS_THRESHOLD", "-1") with pytest.raises(ValidationError): ServerConfig(_env_file=None) + + +def test_mixpanel_api_host_default(monkeypatch): + monkeypatch.delenv("BLOCKSCOUT_MIXPANEL_API_HOST", raising=False) + cfg = ServerConfig(_env_file=None) + assert cfg.mixpanel_api_host == "api-eu.mixpanel.com" + + +def test_mixpanel_api_host_env_override(monkeypatch): + monkeypatch.setenv("BLOCKSCOUT_MIXPANEL_API_HOST", "api.mixpanel.com") + cfg = ServerConfig(_env_file=None) + assert cfg.mixpanel_api_host == "api.mixpanel.com" + + +def test_mixpanel_api_host_empty_string_preserved(monkeypatch): + monkeypatch.setenv("BLOCKSCOUT_MIXPANEL_API_HOST", "") + cfg = ServerConfig(_env_file=None) + assert cfg.mixpanel_api_host == ""