From ea972fd0541e49ac57b7ced053d3dfbe95f8a77c Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 1 Jul 2026 16:43:30 -0600 Subject: [PATCH 1/4] Phase 1: Change default Mixpanel host + unit tests Co-Authored-By: Claude Opus 4.8 --- blockscout_mcp_server/config.py | 5 ++- tests/test_analytics.py | 68 +++++++++++++++++++++++++-------- tests/test_config.py | 18 +++++++++ 3 files changed, 75 insertions(+), 16 deletions(-) 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/tests/test_analytics.py b/tests/test_analytics.py index ec3bffa3..45a7e35e 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) @@ -148,7 +186,7 @@ def test_tracks_with_intermediary_no_client_or_user_agent(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) @@ -166,7 +204,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() @@ -189,7 +227,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) @@ -233,7 +271,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) @@ -260,7 +298,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) @@ -291,7 +329,7 @@ def test_track_community_usage(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() @@ -300,7 +338,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() @@ -312,7 +350,7 @@ def test_track_resource_read_emits_correct_event(monkeypatch): headers = {"x-forwarded-for": "203.0.113.5", "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) 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 == "" From 345e7914de5fcc4f15a11129ea43117751a2feff Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 1 Jul 2026 16:46:54 -0600 Subject: [PATCH 2/4] Phase 2: Remove empty-value override from shipped artifacts Co-Authored-By: Claude Opus 4.8 --- .env.example | 8 ++++++-- Dockerfile | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 5c64b9db..6d835f7f 100644 --- a/.env.example +++ b/.env.example @@ -76,9 +76,13 @@ 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. Do NOT set it to an empty value: +# an empty value overrides the default back to Mixpanel's US host. Uncomment and set +# to api.mixpanel.com for a US-resident project, or to another regional host as needed. +# 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" From 26a92bc178e58731e1300d5a38d26f205d9744ec Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 1 Jul 2026 16:49:39 -0600 Subject: [PATCH 3/4] Phase 3: Version bump Co-Authored-By: Claude Opus 4.8 --- blockscout_mcp_server/__init__.py | 2 +- pyproject.toml | 2 +- server.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/blockscout_mcp_server/__init__.py b/blockscout_mcp_server/__init__.py index 0a765d1c..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.dev1" +__version__ = "0.17.0.dev3" diff --git a/pyproject.toml b/pyproject.toml index bcdb50c7..b19c5dee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "blockscout-mcp-server" -version = "0.17.0.dev1" +version = "0.17.0.dev3" description = "MCP server for Blockscout" requires-python = ">=3.11" dependencies = [ diff --git a/server.json b/server.json index d58dac80..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.dev1", + "version": "0.17.0.dev3", "websiteUrl": "https://blockscout.com", "repository": { "url": "https://github.com/blockscout/mcp-server", From 2ea103fb924a5430be4a2fb20c19044e2deed736 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 1 Jul 2026 18:54:54 -0600 Subject: [PATCH 4/4] docs: reframe empty MIXPANEL_API_HOST as US selector in .env.example The empty value is an intentional, tested path (empty -> SDK default US host), so "Do NOT set it to an empty value" mischaracterized a supported option as forbidden. Describe empty as the US selector instead. Co-Authored-By: Claude Opus 4.8 --- .env.example | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 6d835f7f..19d2f5b7 100644 --- a/.env.example +++ b/.env.example @@ -79,9 +79,8 @@ BLOCKSCOUT_MCP_USER_AGENT="Blockscout MCP" # No tracking occurs in stdio mode. BLOCKSCOUT_MIXPANEL_TOKEN="" # The ingestion region default (api-eu.mixpanel.com) lives in the server config. -# Leave this commented out to use that EU default. Do NOT set it to an empty value: -# an empty value overrides the default back to Mixpanel's US host. Uncomment and set -# to api.mixpanel.com for a US-resident project, or to another regional host as needed. +# 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.