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
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@
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"
ENV BLOCKSCOUT_PRO_API_KEY_HEADER="Blockscout-MCP-Pro-Api-Key"

Check warning on line 52 in Dockerfile

View workflow job for this annotation

GitHub Actions / Docker build and docker push

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "BLOCKSCOUT_PRO_API_KEY_HEADER") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

# Set the default transport mode. Can be overridden at runtime with -e.
# Options: "stdio" (default), "http"
Expand Down
2 changes: 1 addition & 1 deletion blockscout_mcp_server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-License-Identifier: LicenseRef-Blockscout
"""Blockscout MCP Server package."""

__version__ = "0.17.0.dev2"
__version__ = "0.17.0.dev3"
5 changes: 4 additions & 1 deletion blockscout_mcp_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 53 additions & 15 deletions tests/test_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,55 @@ 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()


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()

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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()

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand All @@ -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()

Expand All @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == ""
Loading