From 75d3d5e47e74d1aa731fd6c9723b4cd67c4983a8 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Thu, 4 Jun 2026 21:55:47 -0600 Subject: [PATCH 1/7] Phase 1: Define CreditsExhaustedError and map 402 in the shared HTTP core Co-Authored-By: Claude Opus 4.8 --- blockscout_mcp_server/tools/common.py | 19 ++++- tests/tools/address/test_get_address_info.py | 76 +++++++++++++++++++ .../address/test_get_address_info_metadata.py | 54 ++++++++++++- tests/tools/block/test_get_block_info.py | 39 ++++++++++ tests/tools/test_common_blockscout_request.py | 58 +++++++++++++- tests/tools/test_common_metadata.py | 38 ++++++++++ tests/tools/test_common_post_request.py | 36 ++++++++- .../transaction/test_get_transaction_info.py | 31 +++++++- 8 files changed, 344 insertions(+), 7 deletions(-) diff --git a/blockscout_mcp_server/tools/common.py b/blockscout_mcp_server/tools/common.py index 3ec0479..b5c7230 100644 --- a/blockscout_mcp_server/tools/common.py +++ b/blockscout_mcp_server/tools/common.py @@ -52,6 +52,12 @@ class ResponseTooLargeError(Exception): pass +class CreditsExhaustedError(Exception): + """Exception raised when the Blockscout PRO API rejects a request due to credit exhaustion (HTTP 402, body {"error": "Out of credits"}).""" # noqa: E501 + + pass + + chains_list_cache = ChainsListCache() pro_api_config_cache = ProApiConfigCache() @@ -178,7 +184,8 @@ async def make_blockscout_request( Raises: ValueError: If BLOCKSCOUT_PRO_API_KEY is not configured ChainNotFoundError: If the chain_id is not supported - httpx.HTTPStatusError: If the HTTP request returns an error status code + CreditsExhaustedError: If the PRO API returns HTTP 402 (credit allowance depleted) + httpx.HTTPStatusError: If the HTTP request returns a non-402 error status code httpx.TimeoutException: If the request times out httpx.RequestError: For transport-level errors after final retry @@ -233,6 +240,7 @@ async def make_blockscout_post_request( Raises: ValueError: If BLOCKSCOUT_PRO_API_KEY is not configured ChainNotFoundError: If the chain_id is not supported + CreditsExhaustedError: If the PRO API returns HTTP 402 (credit allowance depleted) Retry behavior is intentionally strict because POST requests are not idempotent: retries occur only for connection-establishment failures (ConnectError, @@ -284,6 +292,12 @@ async def _make_blockscout_http_request( try: response.raise_for_status() except httpx.HTTPStatusError as e: + if e.response.status_code == 402: + raise CreditsExhaustedError( + "Blockscout PRO API credits exhausted (HTTP 402): the API key's credit allowance is " + "depleted. Top up credits or wait for the daily reset; retrying will not succeed until " + "credits are replenished." + ) from e details = _extract_http_error_details(e.response) reason = e.response.reason_phrase or "Error" message = f"{e.response.status_code} {reason}" @@ -396,7 +410,8 @@ async def make_metadata_request(api_path: str, params: dict | None = None) -> di Raises: ValueError: If ``BLOCKSCOUT_PRO_API_KEY`` is not configured - httpx.HTTPStatusError: If the HTTP request returns an error status code + CreditsExhaustedError: If the PRO API returns HTTP 402 (credit allowance depleted) + httpx.HTTPStatusError: If the HTTP request returns a non-402 error status code httpx.TimeoutException: If the request times out (retried as a subclass of ``httpx.RequestError`` before being surfaced) httpx.RequestError: For transport-level errors surfaced after the final diff --git a/tests/tools/address/test_get_address_info.py b/tests/tools/address/test_get_address_info.py index 481f50c..3b285ac 100644 --- a/tests/tools/address/test_get_address_info.py +++ b/tests/tools/address/test_get_address_info.py @@ -7,6 +7,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.models import AddressInfoData, ToolResponse from blockscout_mcp_server.tools.address.get_address_info import get_address_info +from blockscout_mcp_server.tools.common import CreditsExhaustedError @pytest.mark.asyncio @@ -301,3 +302,78 @@ async def test_get_address_info_blockscout_failure(mock_ctx): assert mock_ctx.report_progress.await_count == 1 assert mock_ctx.info.await_count == 1 + + +# --------------------------------------------------------------------------- +# CreditsExhaustedError composite-tool tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_address_info_first_transaction_credits_exhausted_degrades_gracefully(mock_ctx): + """CreditsExhaustedError on the first-transaction side request degrades softly. + + Mirrors test_get_address_info_first_transaction_failure but with the new + exception type to prove the composite-tool soft-fail path handles it. + """ + chain_id = "1" + address = "0x123abc" + + mock_blockscout_response = {"hash": address, "is_contract": False} + first_tx_error = CreditsExhaustedError( + "Blockscout PRO API credits exhausted (HTTP 402): the API key's credit allowance is depleted." + ) + mock_metadata_response = {"addresses": {}} + + with ( + patch( + "blockscout_mcp_server.tools.address.get_address_info.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_bs_request, + patch( + "blockscout_mcp_server.tools.address.get_address_info.make_metadata_request", + new_callable=AsyncMock, + ) as mock_meta_request, + ): + mock_bs_request.side_effect = [mock_blockscout_response, first_tx_error] + mock_meta_request.return_value = mock_metadata_response + + result = await get_address_info(chain_id=chain_id, address=address, ctx=mock_ctx) + + assert isinstance(result, ToolResponse) + assert isinstance(result.data, AddressInfoData) + assert result.data.basic_info == mock_blockscout_response + assert result.data.first_transaction_details is None + assert result.notes is not None and len(result.notes) >= 1 + assert any("Could not retrieve first transaction details" in note for note in result.notes) + + +@pytest.mark.asyncio +async def test_get_address_info_primary_credits_exhausted_raises(mock_ctx): + """CreditsExhaustedError on the primary address-info request is re-raised unchanged. + + Mirrors test_get_address_info_blockscout_failure but with CreditsExhaustedError + to prove the tool surfaces the distinct error rather than swallowing it. + """ + chain_id = "1" + address = "0x123abc" + + primary_error = CreditsExhaustedError( + "Blockscout PRO API credits exhausted (HTTP 402): the API key's credit allowance is depleted." + ) + + with ( + patch( + "blockscout_mcp_server.tools.address.get_address_info.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_bs_request, + patch( + "blockscout_mcp_server.tools.address.get_address_info.make_metadata_request", + new_callable=AsyncMock, + ) as mock_meta_request, + ): + mock_bs_request.side_effect = [primary_error, {"items": []}] + mock_meta_request.return_value = {} + + with pytest.raises(CreditsExhaustedError): + await get_address_info(chain_id=chain_id, address=address, ctx=mock_ctx) diff --git a/tests/tools/address/test_get_address_info_metadata.py b/tests/tools/address/test_get_address_info_metadata.py index 87f7382..5b3cb54 100644 --- a/tests/tools/address/test_get_address_info_metadata.py +++ b/tests/tools/address/test_get_address_info_metadata.py @@ -14,6 +14,7 @@ from blockscout_mcp_server.constants import INPUT_DATA_TRUNCATION_LIMIT from blockscout_mcp_server.models import AddressInfoData, ToolResponse from blockscout_mcp_server.tools.address.get_address_info import _process_metadata_tags, get_address_info +from blockscout_mcp_server.tools.common import CreditsExhaustedError def _long_string() -> str: @@ -87,9 +88,14 @@ async def test_get_address_info_metadata_failure(mock_ctx): @pytest.mark.asyncio -@pytest.mark.parametrize("status_code", [401, 402, 429]) +@pytest.mark.parametrize("status_code", [401, 429]) async def test_get_address_info_metadata_http_status_error_degrades_gracefully(status_code, mock_ctx): - """A rejected PRO API call (401/402/429) degrades softly — primary data is still returned.""" + """A rejected PRO API call (401/429) degrades softly — primary data is still returned. + + Note: 402 is excluded because after Phase 1, a real metadata 402 arrives as + CreditsExhaustedError (not httpx.HTTPStatusError). See + test_get_address_info_metadata_credits_exhausted_degrades_gracefully below. + """ chain_id = "1" address = "0x123abc" @@ -158,6 +164,50 @@ async def test_get_address_info_fails_fast_when_no_key(mock_ctx, monkeypatch): await get_address_info(chain_id=chain_id, address=address, ctx=mock_ctx) +# --------------------------------------------------------------------------- +# Metadata failure — CreditsExhaustedError (PRO API credit exhaustion) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_address_info_metadata_credits_exhausted_degrades_gracefully(mock_ctx): + """A CreditsExhaustedError from make_metadata_request degrades softly — primary data is still returned.""" + chain_id = "1" + address = "0x123abc" + + mock_blockscout_response = {"hash": address, "is_contract": False} + mock_first_tx_response = {"items": []} + metadata_error = CreditsExhaustedError( + "Blockscout PRO API credits exhausted (HTTP 402): the API key's credit allowance is depleted." + ) + + with ( + patch( + "blockscout_mcp_server.tools.address.get_address_info.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_bs_request, + patch( + "blockscout_mcp_server.tools.address.get_address_info.make_metadata_request", + new_callable=AsyncMock, + ) as mock_meta_request, + ): + mock_bs_request.side_effect = [mock_blockscout_response, mock_first_tx_response] + mock_meta_request.side_effect = metadata_error + + result = await get_address_info(chain_id=chain_id, address=address, ctx=mock_ctx) + + mock_meta_request.assert_called_once_with( + api_path="/services/metadata/api/v1/metadata", params={"addresses": address, "chainId": chain_id} + ) + + assert isinstance(result, ToolResponse) + assert isinstance(result.data, AddressInfoData) + assert result.data.basic_info == mock_blockscout_response + assert result.data.metadata is None + assert result.notes is not None and len(result.notes) >= 1 + assert any("Could not retrieve address metadata" in note for note in result.notes) + + # --------------------------------------------------------------------------- # _process_metadata_tags unit tests # --------------------------------------------------------------------------- diff --git a/tests/tools/block/test_get_block_info.py b/tests/tools/block/test_get_block_info.py index 7c4e03d..79db8fa 100644 --- a/tests/tools/block/test_get_block_info.py +++ b/tests/tools/block/test_get_block_info.py @@ -7,6 +7,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.models import BlockInfoData, ToolResponse from blockscout_mcp_server.tools.block.get_block_info import get_block_info +from blockscout_mcp_server.tools.common import CreditsExhaustedError @pytest.mark.asyncio @@ -184,3 +185,41 @@ async def test_get_block_info_no_txs_upstream_failure(mock_ctx): assert mock_ctx.report_progress.await_count == 1 assert mock_ctx.info.await_count == 1 + + +@pytest.mark.asyncio +async def test_get_block_info_with_txs_credits_exhausted_degrades_gracefully(mock_ctx): + """CreditsExhaustedError on the transactions side request degrades softly. + + Mirrors test_get_block_info_with_txs_partial_failure but with the new + exception type to prove the composite-tool soft-fail path handles it. + """ + chain_id = "1" + number_or_hash = "19000000" + mock_block_response = {"height": 19000000} + tx_error = CreditsExhaustedError( + "Blockscout PRO API credits exhausted (HTTP 402): the API key's credit allowance is depleted." + ) + + async def mock_request_side_effect(chain_id, api_path, params=None, **kwargs): + if "transactions" in api_path: + raise tx_error + return mock_block_response + + with ( + patch( + "blockscout_mcp_server.tools.block.get_block_info.make_blockscout_request", new_callable=AsyncMock + ) as mock_request, + ): + mock_request.side_effect = mock_request_side_effect + + result = await get_block_info( + chain_id=chain_id, number_or_hash=number_or_hash, include_transactions=True, ctx=mock_ctx + ) + + assert isinstance(result, ToolResponse) + assert isinstance(result.data, BlockInfoData) + assert result.data.block_details == mock_block_response + assert result.data.transaction_hashes is None + assert result.notes is not None + assert "Could not retrieve the list of transactions" in result.notes[0] diff --git a/tests/tools/test_common_blockscout_request.py b/tests/tools/test_common_blockscout_request.py index 28ce4a0..1d1519c 100644 --- a/tests/tools/test_common_blockscout_request.py +++ b/tests/tools/test_common_blockscout_request.py @@ -12,7 +12,7 @@ import pytest from blockscout_mcp_server.config import config -from blockscout_mcp_server.tools.common import ChainNotFoundError, make_blockscout_request +from blockscout_mcp_server.tools.common import ChainNotFoundError, CreditsExhaustedError, make_blockscout_request # --------------------------------------------------------------------------- # Fake httpx.AsyncClient for transport tests @@ -413,3 +413,59 @@ async def get(self, url, params=None, headers=None, **kwargs): assert captured["headers"].get("Authorization") == "Bearer get_test_key" assert "User-Agent" in captured["headers"] assert captured["headers"].get("Accept") == "application/json" + + +# --------------------------------------------------------------------------- +# CreditsExhaustedError: 402 → distinct error, no retry +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_make_blockscout_request_402_raises_credits_exhausted_error(): + """A 402 response raises CreditsExhaustedError (not HTTPStatusError) exactly once — no retry.""" + response = _make_response(402, json_data={"error": "Out of credits"}) + + call_count = {"n": 0} + + class CountingClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def get(self, url, params=None, **kwargs): + call_count["n"] += 1 + return response + + patch_key, stub_chain = _patch_guards() + with patch_key, stub_chain: + with patch( + "blockscout_mcp_server.tools.common._create_httpx_client", + return_value=CountingClient(), + ): + with patch("blockscout_mcp_server.tools.common.anyio.sleep") as mock_sleep: + with pytest.raises(CreditsExhaustedError) as excinfo: + await make_blockscout_request(chain_id="1", api_path="/api/v2/test") + + # Must NOT be an HTTPStatusError (wrong base class would silently mis-route in Phase 2) + assert not isinstance(excinfo.value, httpx.HTTPStatusError) + # Message must carry the actionable signals the MCP agent needs + message = str(excinfo.value).lower() + assert "credits" in message + assert "402" in message + assert "retry" in message + # Must not retry: exactly one HTTP call, sleep never called + assert call_count["n"] == 1 + mock_sleep.assert_not_called() + + +def test_credits_exhausted_error_is_exception_not_value_error(): + """CreditsExhaustedError must subclass Exception but NOT ValueError. + + This guards the Phase 2 design: handle_rest_errors has an `except ValueError → 400` + branch; if CreditsExhaustedError were a ValueError subclass it would be mis-routed + to 400 instead of 402. + """ + assert issubclass(CreditsExhaustedError, Exception) + assert not issubclass(CreditsExhaustedError, ValueError) diff --git a/tests/tools/test_common_metadata.py b/tests/tools/test_common_metadata.py index 8c04bfd..718ce54 100644 --- a/tests/tools/test_common_metadata.py +++ b/tests/tools/test_common_metadata.py @@ -13,6 +13,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.constants import SERVER_VERSION from blockscout_mcp_server.tools.common import ( + CreditsExhaustedError, _pro_api_headers, make_blockscout_request, make_metadata_request, @@ -367,3 +368,40 @@ async def test_make_blockscout_request_sends_pro_api_key_to_pro_api_host(monkeyp assert sent_headers.get("Authorization") == "Bearer proapi_test" assert "User-Agent" in sent_headers assert sent_headers.get("Accept") == "application/json" + + +# --------------------------------------------------------------------------- +# CreditsExhaustedError: metadata 402 → distinct error, no retry +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_make_metadata_request_402_raises_credits_exhausted_error(monkeypatch): + """A 402 response from make_metadata_request raises CreditsExhaustedError and is not retried.""" + monkeypatch.setattr(config, "pro_api_key", "bad_key") + monkeypatch.setattr(config, "bs_request_max_retries", 3) + api_path = "/api/v1/metadata/address" + + attempt_count = {"n": 0} + + class _PaymentRequiredClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def get(self, url, **kwargs): + attempt_count["n"] += 1 + request = httpx.Request("GET", url) + return httpx.Response(402, content=b'{"error": "Out of credits"}', request=request) + + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=_PaymentRequiredClient()), + patch("blockscout_mcp_server.tools.common.anyio.sleep") as mock_sleep, + ): + with pytest.raises(CreditsExhaustedError): + await make_metadata_request(api_path) + + assert attempt_count["n"] == 1 + mock_sleep.assert_not_called() diff --git a/tests/tools/test_common_post_request.py b/tests/tools/test_common_post_request.py index 5b5c7ec..2a2ad7e 100644 --- a/tests/tools/test_common_post_request.py +++ b/tests/tools/test_common_post_request.py @@ -5,7 +5,7 @@ import pytest from blockscout_mcp_server.config import config -from blockscout_mcp_server.tools.common import make_blockscout_post_request +from blockscout_mcp_server.tools.common import CreditsExhaustedError, make_blockscout_post_request class MockResponse: @@ -239,3 +239,37 @@ async def post(self, url, json, params, headers=None, **kwargs): assert captured["headers"].get("Authorization") == "Bearer post_test_key" assert "User-Agent" in captured["headers"] assert captured["headers"].get("Accept") == "application/json" + + +# --------------------------------------------------------------------------- +# CreditsExhaustedError: POST 402 → distinct error, no retry +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_make_blockscout_post_request_402_raises_credits_exhausted_error(): + """A 402 response from POST raises CreditsExhaustedError and is not retried.""" + attempts = {"count": 0} + + class Client: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def post(self, *_args, **_kwargs): + attempts["count"] += 1 + return MockResponse({"error": "Out of credits"}, status_code=402) + + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=Client()), + patch("blockscout_mcp_server.tools.common.anyio.sleep") as mock_sleep, + patch.object(config, "pro_api_key", "test_key"), + patch("blockscout_mcp_server.tools.common.ensure_chain_supported", AsyncMock()), + ): + with pytest.raises(CreditsExhaustedError): + await make_blockscout_post_request("1", "/b", {"x": 1}) + + assert attempts["count"] == 1 + mock_sleep.assert_not_called() diff --git a/tests/tools/transaction/test_get_transaction_info.py b/tests/tools/transaction/test_get_transaction_info.py index 66d8c81..6571d7d 100644 --- a/tests/tools/transaction/test_get_transaction_info.py +++ b/tests/tools/transaction/test_get_transaction_info.py @@ -7,7 +7,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.constants import INPUT_DATA_TRUNCATION_LIMIT from blockscout_mcp_server.models import TokenTransfer, ToolResponse, TransactionInfoData -from blockscout_mcp_server.tools.common import ChainNotFoundError +from blockscout_mcp_server.tools.common import ChainNotFoundError, CreditsExhaustedError from blockscout_mcp_server.tools.transaction.get_transaction_info import get_transaction_info @@ -645,3 +645,32 @@ async def test_get_transaction_info_handles_null_token_transfer_metadata(mock_ct assert result.data.token_transfers[0].token is None assert mock_ctx.report_progress.await_count == 3 assert mock_ctx.info.await_count == 3 + + +@pytest.mark.asyncio +async def test_get_transaction_info_ops_credits_exhausted_degrades_gracefully(mock_ctx): + """CreditsExhaustedError on the user-operations side request degrades softly. + + Mirrors test_get_transaction_info_ops_api_failure but with the new + exception type to prove the composite-tool soft-fail path handles it. + """ + chain_id = "1" + tx_hash = "0xabc123" + + mock_api_response = {"hash": tx_hash, "status": "ok"} + ops_error = CreditsExhaustedError( + "Blockscout PRO API credits exhausted (HTTP 402): the API key's credit allowance is depleted." + ) + + with patch( + "blockscout_mcp_server.tools.transaction.get_transaction_info.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_request: + mock_request.side_effect = [mock_api_response, ops_error] + + result = await get_transaction_info(chain_id=chain_id, transaction_hash=tx_hash, ctx=mock_ctx) + + assert result.data.user_operations is None + assert result.notes is not None + assert any("Could not retrieve user operations" in note for note in result.notes) + assert all("USER OPERATIONS REQUIRE EXPANSION" not in instr for instr in result.instructions) From 4460cf4181a2bdf036e69671cfab7f0081684b60 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Thu, 4 Jun 2026 21:58:21 -0600 Subject: [PATCH 2/7] Phase 2: Surface CreditsExhaustedError as HTTP 402 in REST mode Co-Authored-By: Claude Opus 4.8 --- blockscout_mcp_server/api/helpers.py | 4 ++- tests/api/test_helpers.py | 47 ++++++++++++++++++++++++++++ tests/api/test_routes.py | 2 ++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/api/test_helpers.py diff --git a/blockscout_mcp_server/api/helpers.py b/blockscout_mcp_server/api/helpers.py index 447022b..7f400df 100644 --- a/blockscout_mcp_server/api/helpers.py +++ b/blockscout_mcp_server/api/helpers.py @@ -10,7 +10,7 @@ from starlette.responses import JSONResponse, Response from blockscout_mcp_server.models import ToolResponse -from blockscout_mcp_server.tools.common import ResponseTooLargeError +from blockscout_mcp_server.tools.common import CreditsExhaustedError, ResponseTooLargeError def str_to_bool(val: str) -> bool: @@ -68,6 +68,8 @@ async def wrapper(request: Request) -> Response: return await func(request) except ResponseTooLargeError as e: return JSONResponse({"error": str(e)}, status_code=413) + except CreditsExhaustedError as e: + return JSONResponse({"error": str(e)}, status_code=402) except ValueError as e: return JSONResponse({"error": str(e)}, status_code=400) except httpx.HTTPStatusError as e: diff --git a/tests/api/test_helpers.py b/tests/api/test_helpers.py new file mode 100644 index 0000000..de3fee7 --- /dev/null +++ b/tests/api/test_helpers.py @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +"""Unit tests for the handle_rest_errors decorator in blockscout_mcp_server.api.helpers.""" + +import json + +import pytest +from starlette.requests import Request + +from blockscout_mcp_server.api.helpers import handle_rest_errors +from blockscout_mcp_server.tools.common import CreditsExhaustedError + + +def _make_request() -> Request: + """Create a minimal Starlette Request for use in decorator tests.""" + scope = {"type": "http", "method": "GET", "path": "/", "query_string": b"", "headers": []} + return Request(scope) + + +@pytest.mark.asyncio +async def test_handle_rest_errors_credits_exhausted_returns_402(): + """handle_rest_errors converts CreditsExhaustedError into an HTTP 402 response.""" + message = "Out of credits" + + @handle_rest_errors + async def handler(request: Request): + raise CreditsExhaustedError(message) + + response = await handler(_make_request()) + + assert response.status_code == 402 + body = json.loads(response.body) + assert body["error"] == message + + +@pytest.mark.asyncio +async def test_handle_rest_errors_value_error_still_returns_400(): + """CreditsExhaustedError branch does not intercept ValueError; it still maps to 400.""" + + @handle_rest_errors + async def handler(request: Request): + raise ValueError("bad input") + + response = await handler(_make_request()) + + assert response.status_code == 400 + body = json.loads(response.body) + assert body["error"] == "bad input" diff --git a/tests/api/test_routes.py b/tests/api/test_routes.py index d402b56..a24a23c 100644 --- a/tests/api/test_routes.py +++ b/tests/api/test_routes.py @@ -9,6 +9,7 @@ from mcp.server.fastmcp import FastMCP from blockscout_mcp_server.models import AdvancedFilterItem, TokenTransfer, ToolResponse, TransactionInfoData +from blockscout_mcp_server.tools.common import CreditsExhaustedError @pytest.mark.asyncio @@ -710,6 +711,7 @@ async def test_direct_api_call_missing_chain_id(client: AsyncClient): ), (httpx.TimeoutException("timeout"), 504), (ValueError("bad input"), 400), + (CreditsExhaustedError("Out of credits"), 402), ], ) @patch("blockscout_mcp_server.api.routes.get_block_number", new_callable=AsyncMock) From 9d84fe9917f69819cb48fb6ed22056f780f9f7ab Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Thu, 4 Jun 2026 22:04:58 -0600 Subject: [PATCH 3/7] Phase 3: Documentation Updates Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 2 ++ API.md | 3 ++- SPEC.md | 16 ++++++++++++++-- tests/api/__init__.py | 0 tests/tools/__init__.py | 0 tests/tools/transaction/__init__.py | 0 6 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 tests/api/__init__.py create mode 100644 tests/tools/__init__.py create mode 100644 tests/tools/transaction/__init__.py diff --git a/AGENTS.md b/AGENTS.md index 18e2081..d644194 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,8 @@ mcp-server/ │ │ ├── test_get_transaction_info_real.py # Integration tests for get_transaction_info │ │ └── test_get_transactions_by_address_real.py # Integration tests for get_transactions_by_address │ ├── api/ # Unit tests for the REST API +│ │ ├── test_helpers.py # Unit tests for REST error-handling helpers (e.g. handle_rest_errors) +│ │ ├── test_resource_routes.py # Unit tests for resource discovery routes (/v1/resources) │ │ ├── test_routes.py # Unit tests for API route definitions │ │ └── test_skill_resource_routes.py # Unit tests for the bundled skill HTTP mirror │ ├── conftest.py diff --git a/API.md b/API.md index 8905ed9..36a660e 100644 --- a/API.md +++ b/API.md @@ -83,9 +83,10 @@ All error responses, regardless of the HTTP status code, return a JSON object wi #### Error Categories -- **Client-Side Errors (`4xx` status codes)**: These errors indicate a problem with the request itself. Common examples include: +- **Client-Side Errors (`4xx` status codes)**: These errors usually indicate a problem with the request itself, though some (such as `402 Payment Required`) instead reflect a server-side account/quota state rather than anything wrong with the request. Common examples include: - **Validation Errors (`400 Bad Request`)**: Occur when a required parameter is missing or a parameter value is invalid. - **Deprecated Endpoints (`410 Gone`)**: Occur when a requested endpoint is no longer supported. + - **Credits Exhausted (`402 Payment Required`)**: Occurs when the Blockscout PRO API daily credit allowance for the server's API key has been exhausted. This is a distinct, clearly-labeled signal — separate from generic transient upstream failures — and reflects the server's API-key quota state, not a problem with the client's request: the client should stop and top up credits or wait for the daily reset rather than retry. - **Server-Side Errors (`5xx` status codes)**: These errors indicate a problem on the server or with a downstream service. Common examples include: - **Internal Errors (`500 Internal Server Error`)**: Occur when the server encounters an unexpected condition. diff --git a/SPEC.md b/SPEC.md index a157265..eac805b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -229,7 +229,7 @@ The key requirement is enforced as a single chokepoint: each PRO API entry point **Error semantics** -Credit-exhaustion and rate-limit responses from the PRO API are currently not special-cased; they surface as general request / service-unavailability failures. +Credit-exhaustion responses on the PRO API *data path* are special-cased: the shared `_make_blockscout_http_request` core maps `HTTP 402` (body `{"error": "Out of credits"}`) to a dedicated `CreditsExhaustedError` (see §8, "Credit Exhaustion"). Rate-limit responses are not special-cased and still surface as general request / service-unavailability failures. The Web3/RPC transport used by `read_contract` is separate and is not covered by this mapping. ### Key Architectural Decisions @@ -581,7 +581,7 @@ Credit-exhaustion and rate-limit responses from the PRO API are currently not sp This keeps API semantics intact, avoids masking persistent upstream problems, and improves reliability for both MCP tools and the REST API endpoints that proxy through the same business logic. - Because all PRO API helpers share this core, their HTTP-status-error enrichment and JSON-`null`-body normalization are identical, and they share the same retry orchestration (attempt count and backoff schedule); only the set of exceptions treated as retryable differs by helper, as detailed in the bullets above. In particular, `make_metadata_request` — used by `get_address_info` — now inherits the shared GET retry policy (retrying `httpx.RequestError`, which includes `httpx.TimeoutException`), the same `" - Details: …"` error enrichment, and the same normalization of a JSON `null` body to an empty object that the primary data path already provides. + Because all PRO API helpers share this core, their HTTP-status-error enrichment (for non-`402` statuses — `HTTP 402` is intercepted in the same core and mapped to `CreditsExhaustedError` before enrichment; see §8, "Credit Exhaustion") and JSON-`null`-body normalization are identical, and they share the same retry orchestration (attempt count and backoff schedule); only the set of exceptions treated as retryable differs by helper, as detailed in the bullets above. In particular, `make_metadata_request` — used by `get_address_info` — now inherits the shared GET retry policy (retrying `httpx.RequestError`, which includes `httpx.TimeoutException`), the same `" - Details: …"` error enrichment, and the same normalization of a JSON `null` body to an empty object that the primary data path already provides. Exhausted internal retries surface differently per access mode: - **REST clients** see `500 Internal Server Error` for generic transport failures, or `504 Gateway Timeout` for `httpx.TimeoutException`. Because the server has already retried internally, downstream retry policies that also retry on `5xx` should stay conservative on `500`/`504` from this server to avoid multiplicative attempt cascades. @@ -602,6 +602,18 @@ Credit-exhaustion and rate-limit responses from the PRO API are currently not sp This ensures that the AI receives the specific feedback needed to adjust its tool usage without overwhelming it with raw HTML or stack traces. + **Credit Exhaustion (`402 Payment Required`)** + + The Blockscout PRO API meters access by credits and returns `HTTP 402` with body `{"error": "Out of credits"}` when the daily allowance is exhausted, halting the request in its rate-limit gate before it reaches the upstream Blockscout instance. The shared `_make_blockscout_http_request` core maps any `402` status to a dedicated `CreditsExhaustedError` (a direct `Exception` subclass, kept separate from the generic `HTTPStatusError` enrichment path above) so credit exhaustion is surfaced as a clearly-labeled error rather than a generic transient upstream failure. Because every PRO API helper — `make_blockscout_request` (GET), `make_blockscout_post_request` (POST), and `make_metadata_request` — funnels through this single core, the mapping applies uniformly to all of them. `CreditsExhaustedError` is not part of any helper's retry set, so it propagates immediately without retries. + + The mapping is **plan-agnostic**. The server always authenticates with an API key, so it never reaches the keyless x402 `402` payment paths, and it does not distinguish PRO plan types: the PRO API returns `402` only for free-plan or admin-managed keys at a non-positive balance, while paid-plan keys are allowed through at a negative balance (allow-then-bill) and never receive `402` from this gate. If the deployment key is on a paid plan, `402` simply never arrives — which is acceptable. + + The error surfaces differently per access mode: + - **REST clients** receive `402 Payment Required` with the standard `{"error": ...}` body, emitted by the `handle_rest_errors` decorator. + - **Native MCP clients** receive a `tools/call` result with `isError: true` whose text content carries the `CreditsExhaustedError` message; no special registration is required because FastMCP surfaces uncaught tool exceptions as error results. + + Note on composite tools: several tools issue multiple PRO API requests concurrently via `asyncio.gather(..., return_exceptions=True)`, with exactly one hard-fail *primary* request whose exception is re-raised and surfaces directly, plus one or more optional *side* requests whose isolated failures are each downgraded to a null field plus an explanatory note. This applies to `get_address_info` (primary: address-info; side: metadata and first-transaction), `get_block_info` with `include_transactions=True` (primary: block details; side: the transactions list), and `get_transaction_info` (primary: the transaction; side: account-abstraction user operations). Because the side requests share the same `_make_blockscout_http_request` core, a `402` raised by a side request *alone* is mapped to `CreditsExhaustedError` and then absorbed into a note (partial data is still returned) rather than surfaced as a hard error. Under genuine credit exhaustion, however, every request — including the primary one — is rejected, so the `CreditsExhaustedError` still reaches the client through the primary request's re-raise. + 9. **Tool Title and Annotations**: Each MCP tool is registered with two separate pieces of metadata that serve distinct purposes: diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tools/transaction/__init__.py b/tests/tools/transaction/__init__.py new file mode 100644 index 0000000..e69de29 From 33881cf8492a11037dbb5eb2828d2628aa65bde8 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Thu, 4 Jun 2026 22:06:27 -0600 Subject: [PATCH 4/7] Phase 4: 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 159d6cf..e70bb54 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.16.0.dev14" +__version__ = "0.16.0.dev15" diff --git a/pyproject.toml b/pyproject.toml index 6095306..2f4e6dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "blockscout-mcp-server" -version = "0.16.0.dev14" +version = "0.16.0.dev15" description = "MCP server for Blockscout" requires-python = ">=3.11" dependencies = [ diff --git a/server.json b/server.json index 51bc214..fddaf76 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.16.0.dev14", + "version": "0.16.0.dev15", "websiteUrl": "https://blockscout.com", "repository": { "url": "https://github.com/blockscout/mcp-server", From 073d65ae0eef1be168beb563fdb7942b4d26d73e Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Thu, 4 Jun 2026 22:40:41 -0600 Subject: [PATCH 5/7] test: resolve test_helpers basename collision via rename Revert the three __init__.py files added in Phase 3 and rename tests/api/test_helpers.py -> tests/api/test_api_helpers.py to disambiguate it from tests/tools/transaction/test_helpers.py under pytest's prepend import mode. This keeps the test tree flat and consistent (no partial-package asymmetry) instead of relying on a subset of __init__.py files. Update the AGENTS.md tree accordingly. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 2 +- tests/api/__init__.py | 0 tests/api/{test_helpers.py => test_api_helpers.py} | 0 tests/tools/__init__.py | 0 tests/tools/transaction/__init__.py | 0 5 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 tests/api/__init__.py rename tests/api/{test_helpers.py => test_api_helpers.py} (100%) delete mode 100644 tests/tools/__init__.py delete mode 100644 tests/tools/transaction/__init__.py diff --git a/AGENTS.md b/AGENTS.md index d644194..b9bc9d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ mcp-server/ │ │ ├── test_get_transaction_info_real.py # Integration tests for get_transaction_info │ │ └── test_get_transactions_by_address_real.py # Integration tests for get_transactions_by_address │ ├── api/ # Unit tests for the REST API -│ │ ├── test_helpers.py # Unit tests for REST error-handling helpers (e.g. handle_rest_errors) +│ │ ├── test_api_helpers.py # Unit tests for REST error-handling helpers (e.g. handle_rest_errors) │ │ ├── test_resource_routes.py # Unit tests for resource discovery routes (/v1/resources) │ │ ├── test_routes.py # Unit tests for API route definitions │ │ └── test_skill_resource_routes.py # Unit tests for the bundled skill HTTP mirror diff --git a/tests/api/__init__.py b/tests/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/api/test_helpers.py b/tests/api/test_api_helpers.py similarity index 100% rename from tests/api/test_helpers.py rename to tests/api/test_api_helpers.py diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/tools/transaction/__init__.py b/tests/tools/transaction/__init__.py deleted file mode 100644 index e69de29..0000000 From c4aa4e9f395ea7a8c95abdf0f5f6c1086215a9ec Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Fri, 5 Jun 2026 10:27:44 -0600 Subject: [PATCH 6/7] docs: trim Credit Exhaustion section in SPEC.md to high-level description Replace four verbose paragraphs (~350 words) with two concise sentences covering the shared-core mapping, no-retry behaviour, client surfaces, and composite-tool primary-vs-side semantics (~60 words). Co-Authored-By: Claude Sonnet 4.6 --- SPEC.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/SPEC.md b/SPEC.md index eac805b..7a940d4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -604,15 +604,9 @@ Credit-exhaustion responses on the PRO API *data path* are special-cased: the sh **Credit Exhaustion (`402 Payment Required`)** - The Blockscout PRO API meters access by credits and returns `HTTP 402` with body `{"error": "Out of credits"}` when the daily allowance is exhausted, halting the request in its rate-limit gate before it reaches the upstream Blockscout instance. The shared `_make_blockscout_http_request` core maps any `402` status to a dedicated `CreditsExhaustedError` (a direct `Exception` subclass, kept separate from the generic `HTTPStatusError` enrichment path above) so credit exhaustion is surfaced as a clearly-labeled error rather than a generic transient upstream failure. Because every PRO API helper — `make_blockscout_request` (GET), `make_blockscout_post_request` (POST), and `make_metadata_request` — funnels through this single core, the mapping applies uniformly to all of them. `CreditsExhaustedError` is not part of any helper's retry set, so it propagates immediately without retries. + The shared `_make_blockscout_http_request` core maps any `402` response to a dedicated `CreditsExhaustedError`, which propagates immediately without retries. REST clients receive `402 Payment Required`; native MCP clients receive an `isError: true` tool result. - The mapping is **plan-agnostic**. The server always authenticates with an API key, so it never reaches the keyless x402 `402` payment paths, and it does not distinguish PRO plan types: the PRO API returns `402` only for free-plan or admin-managed keys at a non-positive balance, while paid-plan keys are allowed through at a negative balance (allow-then-bill) and never receive `402` from this gate. If the deployment key is on a paid plan, `402` simply never arrives — which is acceptable. - - The error surfaces differently per access mode: - - **REST clients** receive `402 Payment Required` with the standard `{"error": ...}` body, emitted by the `handle_rest_errors` decorator. - - **Native MCP clients** receive a `tools/call` result with `isError: true` whose text content carries the `CreditsExhaustedError` message; no special registration is required because FastMCP surfaces uncaught tool exceptions as error results. - - Note on composite tools: several tools issue multiple PRO API requests concurrently via `asyncio.gather(..., return_exceptions=True)`, with exactly one hard-fail *primary* request whose exception is re-raised and surfaces directly, plus one or more optional *side* requests whose isolated failures are each downgraded to a null field plus an explanatory note. This applies to `get_address_info` (primary: address-info; side: metadata and first-transaction), `get_block_info` with `include_transactions=True` (primary: block details; side: the transactions list), and `get_transaction_info` (primary: the transaction; side: account-abstraction user operations). Because the side requests share the same `_make_blockscout_http_request` core, a `402` raised by a side request *alone* is mapped to `CreditsExhaustedError` and then absorbed into a note (partial data is still returned) rather than surfaced as a hard error. Under genuine credit exhaustion, however, every request — including the primary one — is rejected, so the `CreditsExhaustedError` still reaches the client through the primary request's re-raise. + In composite tools (`get_address_info`, `get_block_info`, `get_transaction_info`), side requests absorb `CreditsExhaustedError` into a note (returning partial data) while the primary request hard-fails normally. 9. **Tool Title and Annotations**: From ade9404d36abb2442e6cc4ee32ac104103e9b32d Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Fri, 5 Jun 2026 11:10:52 -0600 Subject: [PATCH 7/7] docs: narrow Credit Exhaustion mapping to 402 with "Out of credits" body Line 607 said "any 402 response" which contradicts the contract at line 232 (only HTTP 402 with body {"error":"Out of credits"} maps to CreditsExhaustedError). Align the description to match the authoritative contract. Co-Authored-By: Claude Sonnet 4.6 --- SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index 7a940d4..492c48b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -604,7 +604,7 @@ Credit-exhaustion responses on the PRO API *data path* are special-cased: the sh **Credit Exhaustion (`402 Payment Required`)** - The shared `_make_blockscout_http_request` core maps any `402` response to a dedicated `CreditsExhaustedError`, which propagates immediately without retries. REST clients receive `402 Payment Required`; native MCP clients receive an `isError: true` tool result. + The shared `_make_blockscout_http_request` core maps `HTTP 402` responses with body `{"error": "Out of credits"}` to a dedicated `CreditsExhaustedError`, which propagates immediately without retries. REST clients receive `402 Payment Required`; native MCP clients receive an `isError: true` tool result. In composite tools (`get_address_info`, `get_block_info`, `get_transaction_info`), side requests absorb `CreditsExhaustedError` into a note (returning partial data) while the primary request hard-fails normally.