Skip to content

Commit aecd3bb

Browse files
authored
Get rid of per-instance resolver and chain cache (#388)
1 parent aeb689b commit aecd3bb

17 files changed

Lines changed: 7 additions & 464 deletions

.cursor/rules/110-new-mcp-tool.mdc

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,8 @@ If your new tool needs to access an API endpoint that is different from the exis
1919
bens_timeout: float = 30.0
2020
chainscout_url: str = "https://chains.blockscout.com"
2121
chainscout_timeout: float = 15.0
22-
metadata_url: str = "https://metadata.services.blockscout.com"
2322
metadata_timeout: float = 30.0
24-
chain_cache_ttl_seconds: int = 1800
25-
23+
2624
# Add your new endpoint
2725
new_api_url: str = "https://api.example.com"
2826
new_api_timeout: float = 60.0
@@ -79,7 +77,6 @@ If your new tool needs to access an API endpoint that is different from the exis
7977
# Existing environment variables
8078
ENV BLOCKSCOUT_BS_TIMEOUT="120.0"
8179
ENV BLOCKSCOUT_BENS_URL="https://bens.services.blockscout.com"
82-
ENV BLOCKSCOUT_METADATA_URL="https://metadata.services.blockscout.com"
8380
ENV BLOCKSCOUT_METADATA_TIMEOUT="30.0"
8481

8582
# New environment variables

.cursor/rules/220-integration-testing-guidelines.mdc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ This document provides detailed guidelines for writing effective integration tes
1111

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

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

1616
- **Purpose:** To verify basic network connectivity and ensure the fundamental HTTP request/response cycle with each external service is working.
1717
- **Location:** `tests/integration/test_common_helpers.py`.

.env.example

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ BLOCKSCOUT_PRO_API_CONFIG_TTL_SECONDS=300
2828
# * 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.
2929
# Generate one at https://dev.blockscout.com.
3030
BLOCKSCOUT_PRO_API_KEY=""
31-
BLOCKSCOUT_CHAIN_CACHE_TTL_SECONDS=1800
3231
BLOCKSCOUT_CHAINS_LIST_TTL_SECONDS=300
3332
BLOCKSCOUT_PROGRESS_INTERVAL_SECONDS="15.0"
3433

AGENTS.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,6 @@ mcp-server/
261261
* `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.
262262
* `BLOCKSCOUT_CHAINSCOUT_URL`: URL for the Chainscout API (for chain resolution).
263263
* `BLOCKSCOUT_CHAINSCOUT_TIMEOUT`: Timeout for Chainscout API requests.
264-
* `BLOCKSCOUT_CHAIN_CACHE_TTL_SECONDS`: Time-to-live for chain resolution cache.
265264
* `BLOCKSCOUT_CHAINS_LIST_TTL_SECONDS`: Time-to-live for the Chains List cache.
266265
* `BLOCKSCOUT_PROGRESS_INTERVAL_SECONDS`: Interval for periodic progress updates in long-running operations.
267266
* `BLOCKSCOUT_NFT_PAGE_SIZE`: Page size for NFT token queries (default: 10).

Dockerfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ ENV BLOCKSCOUT_PRO_API_BASE_URL="https://api.blockscout.com"
3434
ENV BLOCKSCOUT_METADATA_TIMEOUT="30.0"
3535
ENV BLOCKSCOUT_CHAINSCOUT_URL="https://chains.blockscout.com"
3636
ENV BLOCKSCOUT_CHAINSCOUT_TIMEOUT="15.0"
37-
ENV BLOCKSCOUT_CHAIN_CACHE_TTL_SECONDS="1800"
3837
ENV BLOCKSCOUT_CHAINS_LIST_TTL_SECONDS="300"
3938
ENV BLOCKSCOUT_PROGRESS_INTERVAL_SECONDS="15.0"
4039
ENV BLOCKSCOUT_CONTRACTS_CACHE_MAX_NUMBER="10"

SPEC.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,6 @@ This architecture provides the flexibility of a multi-protocol server without th
173173
- The snapshot is cached in-process with a TTL (configurable via `BLOCKSCOUT_CHAINS_LIST_TTL_SECONDS`).
174174
- This chains-list cache is derived from PRO API config + Chainscout metadata and is invalidated after successful PRO API config refreshes.
175175
- The PRO API config mapping is cached separately with its own TTL (configurable via `BLOCKSCOUT_PRO_API_CONFIG_TTL_SECONDS`).
176-
- 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.
177176
- Concurrent refreshes are deduplicated with an async lock.
178177
- MCP Host selects appropriate chain based on user needs
179178

@@ -373,7 +372,7 @@ Credit-exhaustion and rate-limit responses from the PRO API are currently not sp
373372
4. **Async Web3 Connection Pool**:
374373
- 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.
375374
- 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.
376-
- Chain support is validated independently of instance URL resolution, against the authoritative PRO API chain configuration.
375+
- Chain support is validated against the authoritative PRO API chain configuration.
377376
- The provider ensures request IDs never start at zero and normalizes parameters to lists for Blockscout compatibility.
378377
- 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.
379378
- Credit-exhaustion and rate-limit responses are currently treated the same as general service unavailability.

blockscout_mcp_server/cache.py

Lines changed: 0 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -11,99 +11,6 @@
1111
from blockscout_mcp_server.models import ChainInfo
1212

1313

14-
class ChainCache:
15-
def __init__(self) -> None:
16-
self._cache: dict[str, tuple[str | None, float]] = {}
17-
self._locks_lock = anyio.Lock()
18-
self._locks: dict[str, anyio.Lock] = {}
19-
20-
async def _get_or_create_lock(self, chain_id: str) -> anyio.Lock:
21-
"""Get or create a lock for a specific chain."""
22-
if lock := self._locks.get(chain_id):
23-
return lock
24-
async with self._locks_lock:
25-
if lock := self._locks.get(chain_id):
26-
return lock
27-
new_lock = anyio.Lock()
28-
self._locks[chain_id] = new_lock
29-
return new_lock
30-
31-
@property
32-
def _lock_keys(self) -> set[str]:
33-
"""Return a snapshot of chain IDs with initialized locks."""
34-
return set(self._locks.keys())
35-
36-
def get(self, chain_id: str) -> tuple[str | None, float] | None:
37-
"""Retrieve an entry without validating expiry (no locking).
38-
39-
Returns ``(url_or_none, expiry_monotonic)`` or ``None``.
40-
"""
41-
return self._cache.get(chain_id)
42-
43-
async def set(self, chain_id: str, blockscout_url: str | None) -> None:
44-
"""Cache the URL (or lack thereof) for a single chain."""
45-
expiry = time.monotonic() + config.chain_cache_ttl_seconds
46-
chain_lock = await self._get_or_create_lock(chain_id)
47-
async with chain_lock:
48-
self._cache[chain_id] = (blockscout_url, expiry)
49-
50-
async def set_failure(self, chain_id: str) -> None:
51-
"""Cache a negative lookup with a shorter TTL for faster rediscovery."""
52-
expiry = time.monotonic() + config.pro_api_config_ttl_seconds
53-
chain_lock = await self._get_or_create_lock(chain_id)
54-
async with chain_lock:
55-
self._cache[chain_id] = (None, expiry)
56-
57-
async def bulk_set(self, chain_urls: dict[str, str | None]) -> None:
58-
"""Cache URLs from a bulk /api/chains response concurrently."""
59-
expiry = time.monotonic() + config.chain_cache_ttl_seconds
60-
61-
async def _set_with_expiry(chain_id: str, url: str | None) -> None:
62-
chain_lock = await self._get_or_create_lock(chain_id)
63-
async with chain_lock:
64-
self._cache[chain_id] = (url, expiry)
65-
66-
async with anyio.create_task_group() as tg:
67-
for chain_id, url in chain_urls.items():
68-
tg.start_soon(_set_with_expiry, chain_id, url)
69-
70-
async def replace_success_entries(self, chain_urls: dict[str, str]) -> None:
71-
"""Authoritatively replace positive entries from latest PRO API snapshot.
72-
73-
The initial cache scan (list comprehension) is not guarded by a lock and
74-
relies on the caller serialising access — currently ``pro_api_config_cache.lock``
75-
in ``ensure_pro_api_config()``.
76-
"""
77-
expiry = time.monotonic() + config.chain_cache_ttl_seconds
78-
stale_success_ids = [cid for cid, (url, _) in self._cache.items() if url is not None and cid not in chain_urls]
79-
80-
async def _remove(chain_id: str) -> None:
81-
chain_lock = await self._get_or_create_lock(chain_id)
82-
async with chain_lock:
83-
entry = self._cache.get(chain_id)
84-
if entry and entry[0] is not None:
85-
self._cache.pop(chain_id, None)
86-
87-
async def _upsert(chain_id: str, url: str) -> None:
88-
chain_lock = await self._get_or_create_lock(chain_id)
89-
async with chain_lock:
90-
self._cache[chain_id] = (url, expiry)
91-
92-
async with anyio.create_task_group() as tg:
93-
for chain_id in stale_success_ids:
94-
tg.start_soon(_remove, chain_id)
95-
for chain_id, url in chain_urls.items():
96-
tg.start_soon(_upsert, chain_id, url)
97-
98-
async def invalidate(self, chain_id: str) -> None:
99-
"""Remove an entry from the cache if present."""
100-
if chain_id not in self._cache:
101-
return
102-
chain_lock = await self._get_or_create_lock(chain_id)
103-
async with chain_lock:
104-
self._cache.pop(chain_id, None)
105-
106-
10714
class ChainsListCache:
10815
"""In-process TTL cache for the chains list."""
10916

blockscout_mcp_server/config.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ def normalize_pro_api_key(cls, value: str) -> str:
3636
# Metadata configuration (PRO API metadata endpoint)
3737
metadata_timeout: float = 30.0
3838

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

blockscout_mcp_server/tools/common.py

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import httpx
1111
from mcp.server.fastmcp import Context
1212

13-
from blockscout_mcp_server.cache import ChainCache, ChainsListCache, ProApiConfigCache
13+
from blockscout_mcp_server.cache import ChainsListCache, ProApiConfigCache
1414
from blockscout_mcp_server.config import config
1515
from blockscout_mcp_server.constants import (
1616
INPUT_DATA_TRUNCATION_LIMIT,
@@ -52,8 +52,6 @@ class ResponseTooLargeError(Exception):
5252
pass
5353

5454

55-
# Shared cache instance for chain data
56-
chain_cache = ChainCache()
5755
chains_list_cache = ChainsListCache()
5856
pro_api_config_cache = ProApiConfigCache()
5957

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

119116

120-
async def get_blockscout_base_url(chain_id: str) -> str:
121-
current_time = time.monotonic()
122-
cached_entry = chain_cache.get(chain_id)
123-
124-
if cached_entry:
125-
cached_url, expiry_timestamp = cached_entry
126-
if current_time < expiry_timestamp:
127-
if cached_url is None:
128-
raise ChainNotFoundError(f"Chain ID '{chain_id}' is not supported by the Blockscout API.")
129-
fresh_snapshot = pro_api_config_cache.get_if_fresh()
130-
if fresh_snapshot is not None:
131-
fresh_url = fresh_snapshot.get(chain_id)
132-
if fresh_url:
133-
if fresh_url != cached_url:
134-
await chain_cache.set(chain_id, fresh_url)
135-
return fresh_url
136-
await chain_cache.set_failure(chain_id)
137-
raise ChainNotFoundError(f"Chain ID '{chain_id}' is not supported by the Blockscout API.")
138-
chain_urls = await ensure_pro_api_config()
139-
if chain_id in chain_urls:
140-
await chain_cache.set(chain_id, chain_urls[chain_id])
141-
return chain_urls[chain_id]
142-
await chain_cache.set_failure(chain_id)
143-
raise ChainNotFoundError(f"Chain ID '{chain_id}' is not supported by the Blockscout API.")
144-
await chain_cache.invalidate(chain_id)
145-
146-
chain_urls = await ensure_pro_api_config()
147-
blockscout_url = chain_urls.get(chain_id)
148-
if blockscout_url:
149-
await chain_cache.set(chain_id, blockscout_url)
150-
return blockscout_url
151-
152-
await chain_cache.set_failure(chain_id)
153-
raise ChainNotFoundError(f"Chain ID '{chain_id}' is not supported by the Blockscout API.")
154-
155-
156117
def _extract_http_error_details(response: httpx.Response) -> str:
157118
details = ""
158119
raw_text = response.text or ""

tests/integration/chains/test_get_chains_list_real.py

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
# SPDX-License-Identifier: LicenseRef-Blockscout
22
import asyncio
3-
import time
43
from unittest.mock import AsyncMock, patch
54

65
import pytest
76

87
from blockscout_mcp_server.config import config
98
from blockscout_mcp_server.models import ToolResponse
109
from blockscout_mcp_server.tools.chains.get_chains_list import get_chains_list
11-
from blockscout_mcp_server.tools.common import chain_cache, chains_list_cache, get_blockscout_base_url
10+
from blockscout_mcp_server.tools.common import chains_list_cache
1211
from tests.integration.helpers import retry_on_network_error
1312

1413

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

5453

55-
@pytest.mark.integration
56-
@pytest.mark.asyncio
57-
async def test_get_chains_list_warms_cache(mock_ctx):
58-
"""Ensure calling get_chains_list populates the chain cache."""
59-
await retry_on_network_error(
60-
lambda: get_chains_list(ctx=mock_ctx),
61-
action_description="get_chains_list request",
62-
)
63-
64-
cached_entry = chain_cache.get("1")
65-
assert cached_entry is not None
66-
cached_url, expiry = cached_entry
67-
expected_url = await get_blockscout_base_url("1")
68-
assert cached_url == expected_url
69-
assert expiry > time.monotonic()
70-
71-
7254
@pytest.mark.integration
7355
@pytest.mark.asyncio
7456
async def test_get_chains_list_cache_hit_skips_network(mock_ctx, monkeypatch):

0 commit comments

Comments
 (0)