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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_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
│ ├── conftest.py
Expand Down
3 changes: 2 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 `"<code> <reason> - 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 `"<code> <reason> - 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.
Expand All @@ -602,6 +602,12 @@ 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 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.

9. **Tool Title and Annotations**:

Each MCP tool is registered with two separate pieces of metadata that serve distinct purposes:
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.16.0.dev14"
__version__ = "0.16.0.dev15"
4 changes: 3 additions & 1 deletion blockscout_mcp_server/api/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
19 changes: 17 additions & 2 deletions blockscout_mcp_server/tools/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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
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.16.0.dev14"
version = "0.16.0.dev15"
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.16.0.dev14",
"version": "0.16.0.dev15",
"websiteUrl": "https://blockscout.com",
"repository": {
"url": "https://github.com/blockscout/mcp-server",
Expand Down
47 changes: 47 additions & 0 deletions tests/api/test_api_helpers.py
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 2 additions & 0 deletions tests/api/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
76 changes: 76 additions & 0 deletions tests/tools/address/test_get_address_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
54 changes: 52 additions & 2 deletions tests/tools/address/test_get_address_info_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading