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
5 changes: 1 addition & 4 deletions .cursor/rules/110-new-mcp-tool.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,8 @@ If your new tool needs to access an API endpoint that is different from the exis
bens_timeout: float = 30.0
chainscout_url: str = "https://chains.blockscout.com"
chainscout_timeout: float = 15.0
metadata_url: str = "https://metadata.services.blockscout.com"
metadata_timeout: float = 30.0
chain_cache_ttl_seconds: int = 1800


# Add your new endpoint
new_api_url: str = "https://api.example.com"
new_api_timeout: float = 60.0
Expand Down Expand Up @@ -79,7 +77,6 @@ If your new tool needs to access an API endpoint that is different from the exis
# Existing environment variables
ENV BLOCKSCOUT_BS_TIMEOUT="120.0"
ENV BLOCKSCOUT_BENS_URL="https://bens.services.blockscout.com"
ENV BLOCKSCOUT_METADATA_URL="https://metadata.services.blockscout.com"
ENV BLOCKSCOUT_METADATA_TIMEOUT="30.0"

# New environment variables
Expand Down
2 changes: 1 addition & 1 deletion .cursor/rules/220-integration-testing-guidelines.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This document provides detailed guidelines for writing effective integration tes

### Category 1: Helper-Level Integration Tests (Connectivity & Basic Contract)

These tests target the low-level helper functions in `tools/common.py` (e.g., `make_blockscout_request`, `get_blockscout_base_url`).
These tests target the low-level helper functions in `tools/common.py` (e.g., `make_blockscout_request`, `ensure_chain_supported`).

- **Purpose:** To verify basic network connectivity and ensure the fundamental HTTP request/response cycle with each external service is working.
- **Location:** `tests/integration/test_common_helpers.py`.
Expand Down
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ BLOCKSCOUT_PRO_API_CONFIG_TTL_SECONDS=300
# * read_contract — eth_call is routed through the PRO API JSON-RPC gateway, which requires the key; when unset, read_contract fails fast and makes no network call.
# Generate one at https://dev.blockscout.com.
BLOCKSCOUT_PRO_API_KEY=""
BLOCKSCOUT_CHAIN_CACHE_TTL_SECONDS=1800
BLOCKSCOUT_CHAINS_LIST_TTL_SECONDS=300
BLOCKSCOUT_PROGRESS_INTERVAL_SECONDS="15.0"

Expand Down
1 change: 0 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,6 @@ mcp-server/
* `BLOCKSCOUT_PRO_API_KEY`: Blockscout PRO API key used to authenticate all Blockscout data requests (every data tool routes through the PRO API gateway). It is required; without it, data requests fail fast with a clear error.
* `BLOCKSCOUT_CHAINSCOUT_URL`: URL for the Chainscout API (for chain resolution).
* `BLOCKSCOUT_CHAINSCOUT_TIMEOUT`: Timeout for Chainscout API requests.
* `BLOCKSCOUT_CHAIN_CACHE_TTL_SECONDS`: Time-to-live for chain resolution cache.
* `BLOCKSCOUT_CHAINS_LIST_TTL_SECONDS`: Time-to-live for the Chains List cache.
* `BLOCKSCOUT_PROGRESS_INTERVAL_SECONDS`: Interval for periodic progress updates in long-running operations.
* `BLOCKSCOUT_NFT_PAGE_SIZE`: Page size for NFT token queries (default: 10).
Expand Down
1 change: 0 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ ENV BLOCKSCOUT_PRO_API_BASE_URL="https://api.blockscout.com"
ENV BLOCKSCOUT_METADATA_TIMEOUT="30.0"
ENV BLOCKSCOUT_CHAINSCOUT_URL="https://chains.blockscout.com"
ENV BLOCKSCOUT_CHAINSCOUT_TIMEOUT="15.0"
ENV BLOCKSCOUT_CHAIN_CACHE_TTL_SECONDS="1800"
ENV BLOCKSCOUT_CHAINS_LIST_TTL_SECONDS="300"
ENV BLOCKSCOUT_PROGRESS_INTERVAL_SECONDS="15.0"
ENV BLOCKSCOUT_CONTRACTS_CACHE_MAX_NUMBER="10"
Expand Down
3 changes: 1 addition & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,6 @@ This architecture provides the flexibility of a multi-protocol server without th
- The snapshot is cached in-process with a TTL (configurable via `BLOCKSCOUT_CHAINS_LIST_TTL_SECONDS`).
- This chains-list cache is derived from PRO API config + Chainscout metadata and is invalidated after successful PRO API config refreshes.
- The PRO API config mapping is cached separately with its own TTL (configurable via `BLOCKSCOUT_PRO_API_CONFIG_TTL_SECONDS`).
- The per-chain `ChainCache` is an optimization layer authoritatively synchronized/replaced from the latest PRO API snapshot on each successful refresh. Positive entries are trusted only while the corresponding PRO API snapshot is fresh.
- Concurrent refreshes are deduplicated with an async lock.
- MCP Host selects appropriate chain based on user needs

Expand Down Expand Up @@ -373,7 +372,7 @@ Credit-exhaustion and rate-limit responses from the PRO API are currently not sp
4. **Async Web3 Connection Pool**:
- The server uses a custom `AsyncHTTPProviderBlockscout` and `Web3Pool` to perform `eth_call` requests through the Blockscout PRO API JSON-RPC gateway (`https://api.blockscout.com/{chain_id}/json-rpc`) rather than per-chain public RPC endpoints.
- Requests authenticate against the gateway as described in the "Blockscout PRO API Authentication" section (the `Authorization` header is resolved at request time and excluded from pool cache keys); when no key is configured, contract reads fail fast before any network call is made.
- Chain support is validated independently of instance URL resolution, against the authoritative PRO API chain configuration.
- Chain support is validated against the authoritative PRO API chain configuration.
- The provider ensures request IDs never start at zero and normalizes parameters to lists for Blockscout compatibility.
- Because all chains target a single gateway host, the pool maintains one shared `aiohttp` session whose connector enforces a global per-host connection limit across every chain.
- Credit-exhaustion and rate-limit responses are currently treated the same as general service unavailability.
Expand Down
93 changes: 0 additions & 93 deletions blockscout_mcp_server/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,99 +11,6 @@
from blockscout_mcp_server.models import ChainInfo


class ChainCache:
def __init__(self) -> None:
self._cache: dict[str, tuple[str | None, float]] = {}
self._locks_lock = anyio.Lock()
self._locks: dict[str, anyio.Lock] = {}

async def _get_or_create_lock(self, chain_id: str) -> anyio.Lock:
"""Get or create a lock for a specific chain."""
if lock := self._locks.get(chain_id):
return lock
async with self._locks_lock:
if lock := self._locks.get(chain_id):
return lock
new_lock = anyio.Lock()
self._locks[chain_id] = new_lock
return new_lock

@property
def _lock_keys(self) -> set[str]:
"""Return a snapshot of chain IDs with initialized locks."""
return set(self._locks.keys())

def get(self, chain_id: str) -> tuple[str | None, float] | None:
"""Retrieve an entry without validating expiry (no locking).

Returns ``(url_or_none, expiry_monotonic)`` or ``None``.
"""
return self._cache.get(chain_id)

async def set(self, chain_id: str, blockscout_url: str | None) -> None:
"""Cache the URL (or lack thereof) for a single chain."""
expiry = time.monotonic() + config.chain_cache_ttl_seconds
chain_lock = await self._get_or_create_lock(chain_id)
async with chain_lock:
self._cache[chain_id] = (blockscout_url, expiry)

async def set_failure(self, chain_id: str) -> None:
"""Cache a negative lookup with a shorter TTL for faster rediscovery."""
expiry = time.monotonic() + config.pro_api_config_ttl_seconds
chain_lock = await self._get_or_create_lock(chain_id)
async with chain_lock:
self._cache[chain_id] = (None, expiry)

async def bulk_set(self, chain_urls: dict[str, str | None]) -> None:
"""Cache URLs from a bulk /api/chains response concurrently."""
expiry = time.monotonic() + config.chain_cache_ttl_seconds

async def _set_with_expiry(chain_id: str, url: str | None) -> None:
chain_lock = await self._get_or_create_lock(chain_id)
async with chain_lock:
self._cache[chain_id] = (url, expiry)

async with anyio.create_task_group() as tg:
for chain_id, url in chain_urls.items():
tg.start_soon(_set_with_expiry, chain_id, url)

async def replace_success_entries(self, chain_urls: dict[str, str]) -> None:
"""Authoritatively replace positive entries from latest PRO API snapshot.

The initial cache scan (list comprehension) is not guarded by a lock and
relies on the caller serialising access — currently ``pro_api_config_cache.lock``
in ``ensure_pro_api_config()``.
"""
expiry = time.monotonic() + config.chain_cache_ttl_seconds
stale_success_ids = [cid for cid, (url, _) in self._cache.items() if url is not None and cid not in chain_urls]

async def _remove(chain_id: str) -> None:
chain_lock = await self._get_or_create_lock(chain_id)
async with chain_lock:
entry = self._cache.get(chain_id)
if entry and entry[0] is not None:
self._cache.pop(chain_id, None)

async def _upsert(chain_id: str, url: str) -> None:
chain_lock = await self._get_or_create_lock(chain_id)
async with chain_lock:
self._cache[chain_id] = (url, expiry)

async with anyio.create_task_group() as tg:
for chain_id in stale_success_ids:
tg.start_soon(_remove, chain_id)
for chain_id, url in chain_urls.items():
tg.start_soon(_upsert, chain_id, url)

async def invalidate(self, chain_id: str) -> None:
"""Remove an entry from the cache if present."""
if chain_id not in self._cache:
return
chain_lock = await self._get_or_create_lock(chain_id)
async with chain_lock:
self._cache.pop(chain_id, None)


class ChainsListCache:
"""In-process TTL cache for the chains list."""

Expand Down
1 change: 0 additions & 1 deletion blockscout_mcp_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ def normalize_pro_api_key(cls, value: str) -> str:
# Metadata configuration (PRO API metadata endpoint)
metadata_timeout: float = 30.0

chain_cache_ttl_seconds: int = 1800 # Default 30 minutes
chains_list_ttl_seconds: int = 300 # Default 5 minutes
progress_interval_seconds: float = 15.0 # Default interval for periodic progress updates

Expand Down
41 changes: 1 addition & 40 deletions blockscout_mcp_server/tools/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import httpx
from mcp.server.fastmcp import Context

from blockscout_mcp_server.cache import ChainCache, ChainsListCache, ProApiConfigCache
from blockscout_mcp_server.cache import ChainsListCache, ProApiConfigCache
from blockscout_mcp_server.config import config
from blockscout_mcp_server.constants import (
INPUT_DATA_TRUNCATION_LIMIT,
Expand Down Expand Up @@ -52,8 +52,6 @@ class ResponseTooLargeError(Exception):
pass


# Shared cache instance for chain data
chain_cache = ChainCache()
chains_list_cache = ChainsListCache()
pro_api_config_cache = ProApiConfigCache()

Expand Down Expand Up @@ -86,7 +84,6 @@ async def ensure_pro_api_config() -> dict[str, str]:
try:
chain_urls = await _fetch_pro_api_config()
pro_api_config_cache.store_snapshot(chain_urls)
await chain_cache.replace_success_entries(chain_urls)
chains_list_cache.invalidate()
return chain_urls
except (httpx.HTTPStatusError, httpx.RequestError, ValueError, OSError):
Expand Down Expand Up @@ -117,42 +114,6 @@ async def ensure_chain_supported(chain_id: str) -> None:
raise ChainNotFoundError(f"Chain ID '{chain_id}' is not supported by the Blockscout API.")


async def get_blockscout_base_url(chain_id: str) -> str:
current_time = time.monotonic()
cached_entry = chain_cache.get(chain_id)

if cached_entry:
cached_url, expiry_timestamp = cached_entry
if current_time < expiry_timestamp:
if cached_url is None:
raise ChainNotFoundError(f"Chain ID '{chain_id}' is not supported by the Blockscout API.")
fresh_snapshot = pro_api_config_cache.get_if_fresh()
if fresh_snapshot is not None:
fresh_url = fresh_snapshot.get(chain_id)
if fresh_url:
if fresh_url != cached_url:
await chain_cache.set(chain_id, fresh_url)
return fresh_url
await chain_cache.set_failure(chain_id)
raise ChainNotFoundError(f"Chain ID '{chain_id}' is not supported by the Blockscout API.")
chain_urls = await ensure_pro_api_config()
if chain_id in chain_urls:
await chain_cache.set(chain_id, chain_urls[chain_id])
return chain_urls[chain_id]
await chain_cache.set_failure(chain_id)
raise ChainNotFoundError(f"Chain ID '{chain_id}' is not supported by the Blockscout API.")
await chain_cache.invalidate(chain_id)

chain_urls = await ensure_pro_api_config()
blockscout_url = chain_urls.get(chain_id)
if blockscout_url:
await chain_cache.set(chain_id, blockscout_url)
return blockscout_url

await chain_cache.set_failure(chain_id)
raise ChainNotFoundError(f"Chain ID '{chain_id}' is not supported by the Blockscout API.")


def _extract_http_error_details(response: httpx.Response) -> str:
details = ""
raw_text = response.text or ""
Expand Down
20 changes: 1 addition & 19 deletions tests/integration/chains/test_get_chains_list_real.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
# SPDX-License-Identifier: LicenseRef-Blockscout
import asyncio
import time
from unittest.mock import AsyncMock, patch

import pytest

from blockscout_mcp_server.config import config
from blockscout_mcp_server.models import ToolResponse
from blockscout_mcp_server.tools.chains.get_chains_list import get_chains_list
from blockscout_mcp_server.tools.common import chain_cache, chains_list_cache, get_blockscout_base_url
from blockscout_mcp_server.tools.common import chains_list_cache
from tests.integration.helpers import retry_on_network_error


Expand Down Expand Up @@ -52,23 +51,6 @@ async def test_get_chains_list_integration(mock_ctx):
assert any(c.chain_id == "480" for c in result.data)


@pytest.mark.integration
@pytest.mark.asyncio
async def test_get_chains_list_warms_cache(mock_ctx):
"""Ensure calling get_chains_list populates the chain cache."""
await retry_on_network_error(
lambda: get_chains_list(ctx=mock_ctx),
action_description="get_chains_list request",
)

cached_entry = chain_cache.get("1")
assert cached_entry is not None
cached_url, expiry = cached_entry
expected_url = await get_blockscout_base_url("1")
assert cached_url == expected_url
assert expiry > time.monotonic()


@pytest.mark.integration
@pytest.mark.asyncio
async def test_get_chains_list_cache_hit_skips_network(mock_ctx, monkeypatch):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

from blockscout_mcp_server.config import config
from blockscout_mcp_server.models import AddressLogItem, ToolResponse
from blockscout_mcp_server.tools.common import get_blockscout_base_url
from blockscout_mcp_server.tools.direct_api.direct_api_call import direct_api_call
from tests.integration.helpers import is_log_a_truncated_call_executed, retry_on_network_error

Expand Down Expand Up @@ -89,13 +88,11 @@ async def test_direct_api_call_paginated_search_for_truncation(mock_ctx):
if any(is_log_a_truncated_call_executed(item) for item in result.data):
found_truncated_log = True
assert result.notes is not None
base_url = await get_blockscout_base_url(chain_id)
assert any(
f"{config.pro_api_base_url}/1/api/v2/transactions/{{THE_TRANSACTION_HASH}}/logs" in note
for note in result.notes
)
assert all("curl" not in note for note in result.notes)
assert all(base_url.rstrip("/") not in note for note in result.notes)
assert any("`web3-dev` skill" in note for note in result.notes)
break

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from blockscout_mcp_server.config import config
from blockscout_mcp_server.constants import LOG_DATA_TRUNCATION_LIMIT
from blockscout_mcp_server.models import ToolResponse, TransactionLogItem
from blockscout_mcp_server.tools.common import get_blockscout_base_url
from blockscout_mcp_server.tools.direct_api.direct_api_call import direct_api_call
from tests.integration.helpers import is_log_a_truncated_call_executed, retry_on_network_error

Expand Down Expand Up @@ -86,10 +85,8 @@ async def test_direct_api_call_transaction_logs_with_truncation(mock_ctx):

assert result.notes is not None
assert "One or more log items" in result.notes[0]
base_url = await get_blockscout_base_url("1")
assert any(f"{config.pro_api_base_url}/1/api/v2/transactions/{tx_hash}/logs" in note for note in result.notes)
assert all("curl" not in note for note in result.notes)
assert all(base_url.rstrip("/") not in note for note in result.notes)
assert any("`web3-dev` skill" in note for note in result.notes)

assert isinstance(result.data, list) and result.data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

from blockscout_mcp_server.config import config
from blockscout_mcp_server.models import ToolResponse, UserOperationData
from blockscout_mcp_server.tools.common import get_blockscout_base_url
from blockscout_mcp_server.tools.direct_api.direct_api_call import direct_api_call
from tests.integration.helpers import retry_on_network_error

Expand All @@ -17,13 +16,11 @@

async def _assert_user_operation_pro_endpoint_notes(notes: list[str] | None, operation_hash: str) -> None:
assert notes is not None
base_url = await get_blockscout_base_url("1")
assert any(
f"{config.pro_api_base_url}/1/api/v2/proxy/account-abstraction/operations/{operation_hash}" in note
for note in notes
)
assert all("curl" not in note for note in notes)
assert all(base_url.rstrip("/") not in note for note in notes)
assert any("`web3-dev` skill" in note for note in notes)


Expand Down
Loading
Loading