From 9905e8951c644460584367268f11a591fd1a6642 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 11:24:22 -0600 Subject: [PATCH 01/11] Phase 1: Collapse address & search single-fetch tools Co-Authored-By: Claude Opus 4.8 --- .../tools/address/get_tokens_by_address.py | 7 ++--- .../tools/address/nft_tokens_by_address.py | 6 ++--- .../tools/search/lookup_token_by_symbol.py | 13 +++------- .../address/test_get_tokens_by_address.py | 26 +++++++++---------- .../address/test_nft_tokens_by_address.py | 22 ++++++++-------- .../search/test_lookup_token_by_symbol.py | 23 +++++++--------- 6 files changed, 41 insertions(+), 56 deletions(-) diff --git a/blockscout_mcp_server/tools/address/get_tokens_by_address.py b/blockscout_mcp_server/tools/address/get_tokens_by_address.py index 362ea5b..656a446 100644 --- a/blockscout_mcp_server/tools/address/get_tokens_by_address.py +++ b/blockscout_mcp_server/tools/address/get_tokens_by_address.py @@ -44,16 +44,13 @@ async def get_tokens_by_address( # Report start of operation await report_and_log_progress( - ctx, progress=0.0, total=2.0, message=f"Starting to fetch token holdings for {address} on chain {chain_id}..." + ctx, progress=0.0, total=1.0, message=f"Starting to fetch token holdings for {address} on chain {chain_id}..." ) - # Report progress before fetching data - await report_and_log_progress(ctx, progress=1.0, total=2.0, message="Fetching data...") - response_data = await make_blockscout_request(chain_id=chain_id, api_path=api_path, params=params) # Report completion - await report_and_log_progress(ctx, progress=2.0, total=2.0, message="Successfully fetched token data.") + await report_and_log_progress(ctx, progress=1.0, total=1.0, message="Successfully fetched token data.") items_data = response_data.get("items", []) token_holdings = [] diff --git a/blockscout_mcp_server/tools/address/nft_tokens_by_address.py b/blockscout_mcp_server/tools/address/nft_tokens_by_address.py index def61fe..17e1655 100644 --- a/blockscout_mcp_server/tools/address/nft_tokens_by_address.py +++ b/blockscout_mcp_server/tools/address/nft_tokens_by_address.py @@ -59,14 +59,12 @@ async def nft_tokens_by_address( apply_cursor_to_params(cursor, params) await report_and_log_progress( - ctx, progress=0.0, total=2.0, message=f"Starting to fetch NFT tokens for {address} on chain {chain_id}..." + ctx, progress=0.0, total=1.0, message=f"Starting to fetch NFT tokens for {address} on chain {chain_id}..." ) - await report_and_log_progress(ctx, progress=1.0, total=2.0, message="Fetching data...") - response_data = await make_blockscout_request(chain_id=chain_id, api_path=api_path, params=params) - await report_and_log_progress(ctx, progress=2.0, total=2.0, message="Successfully fetched NFT data.") + await report_and_log_progress(ctx, progress=1.0, total=1.0, message="Successfully fetched NFT data.") # Process all items first to prepare for pagination original_items = response_data.get("items", []) diff --git a/blockscout_mcp_server/tools/search/lookup_token_by_symbol.py b/blockscout_mcp_server/tools/search/lookup_token_by_symbol.py index 43979fd..6fa6084 100644 --- a/blockscout_mcp_server/tools/search/lookup_token_by_symbol.py +++ b/blockscout_mcp_server/tools/search/lookup_token_by_symbol.py @@ -34,17 +34,10 @@ async def lookup_token_by_symbol( await report_and_log_progress( ctx, progress=0.0, - total=2.0, + total=1.0, message=f"Starting token search for '{symbol}' on chain {chain_id}...", ) - await report_and_log_progress( - ctx, - progress=1.0, - total=2.0, - message="Fetching data...", - ) - response_data = await make_blockscout_request( chain_id=chain_id, api_path=api_path, @@ -54,8 +47,8 @@ async def lookup_token_by_symbol( await report_and_log_progress( ctx, - progress=2.0, - total=2.0, + progress=1.0, + total=1.0, message="Successfully completed token search.", ) diff --git a/tests/tools/address/test_get_tokens_by_address.py b/tests/tools/address/test_get_tokens_by_address.py index e9c1413..c1a3142 100644 --- a/tests/tools/address/test_get_tokens_by_address.py +++ b/tests/tools/address/test_get_tokens_by_address.py @@ -90,8 +90,8 @@ async def test_get_tokens_by_address_with_pagination(mock_ctx): assert result.pagination.next_call.params["address"] == address # Check progress reporting and logging - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 @pytest.mark.asyncio @@ -175,8 +175,8 @@ async def test_get_tokens_by_address_without_pagination(mock_ctx): assert result.pagination is None # Check progress reporting and logging - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 @pytest.mark.asyncio @@ -236,8 +236,8 @@ async def test_get_tokens_by_address_with_pagination_params(mock_ctx): assert result.pagination.next_call.params["address"] == address # Check progress reporting and logging - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 @pytest.mark.asyncio @@ -288,8 +288,8 @@ async def test_get_tokens_by_address_empty_response(mock_ctx): assert result.pagination is None # Check progress reporting and logging - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 @pytest.mark.asyncio @@ -353,8 +353,8 @@ async def test_get_tokens_by_address_missing_token_fields(mock_ctx): assert result.pagination is None # Check progress reporting and logging - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 @pytest.mark.asyncio @@ -384,6 +384,6 @@ async def test_get_tokens_by_address_api_error(mock_ctx): mock_request.assert_called_once_with( chain_id=chain_id, api_path=f"/api/v2/addresses/{address}/tokens", params={"type": "ERC-20"} ) - # Progress should have been reported twice (start + before fetch) before the error - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.info.await_count == 2 + # Progress should have been reported once (start only) before the error + assert mock_ctx.report_progress.await_count == 1 + assert mock_ctx.info.await_count == 1 diff --git a/tests/tools/address/test_nft_tokens_by_address.py b/tests/tools/address/test_nft_tokens_by_address.py index 6fa1e49..a22c5c7 100644 --- a/tests/tools/address/test_nft_tokens_by_address.py +++ b/tests/tools/address/test_nft_tokens_by_address.py @@ -87,8 +87,8 @@ async def test_nft_tokens_by_address_success(mock_ctx): assert isinstance(holding.token_instances[1].metadata_attributes, dict) assert holding.token_instances[1].metadata_attributes["trait_type"] == "Common" assert holding.token_instances[1].metadata_attributes["value"] == "Gray" - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 @pytest.mark.asyncio @@ -120,8 +120,8 @@ async def test_nft_tokens_by_address_empty_response(mock_ctx): ) assert isinstance(result, ToolResponse) assert result.data == [] - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 @pytest.mark.asyncio @@ -183,8 +183,8 @@ async def test_nft_tokens_by_address_missing_fields(mock_ctx): assert result.data[0].token_instances[0].id == "999" assert result.data[1].collection.address == "0xempty456" assert result.data[1].token_instances == [] - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 @pytest.mark.asyncio @@ -217,9 +217,9 @@ async def test_nft_tokens_by_address_api_error(mock_ctx): api_path=f"/api/v2/addresses/{address}/nft/collections", params={"type": "ERC-721,ERC-404,ERC-1155"}, ) - # Ensure the tool does not report a success step after the failure - assert mock_ctx.report_progress.await_count <= 2 - assert mock_ctx.info.await_count <= 2 + # Ensure the tool reports only the start beat before the failure + assert mock_ctx.report_progress.await_count == 1 + assert mock_ctx.info.await_count == 1 @pytest.mark.asyncio @@ -281,5 +281,5 @@ async def test_nft_tokens_by_address_erc1155(mock_ctx): assert holding.collection.address == "0xmulti123" assert holding.token_instances[0].description == "First token" assert holding.token_instances[0].external_app_url == "https://example.com/1" - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 diff --git a/tests/tools/search/test_lookup_token_by_symbol.py b/tests/tools/search/test_lookup_token_by_symbol.py index 3b2dfcc..a0cc984 100644 --- a/tests/tools/search/test_lookup_token_by_symbol.py +++ b/tests/tools/search/test_lookup_token_by_symbol.py @@ -6,7 +6,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.models import TokenSearchResult -from blockscout_mcp_server.tools.common import build_tool_response +from blockscout_mcp_server.tools.common import ChainNotFoundError, build_tool_response from blockscout_mcp_server.tools.search.lookup_token_by_symbol import ( TOKEN_RESULTS_LIMIT, lookup_token_by_symbol, @@ -81,8 +81,8 @@ async def test_lookup_token_by_symbol_success(mock_ctx): ) assert result.model_dump() == expected_result.model_dump() assert result.content_text is not None - assert mock_ctx.report_progress.call_count == 3 - assert mock_ctx.info.call_count == 3 + assert mock_ctx.report_progress.call_count == 2 + assert mock_ctx.info.call_count == 2 @pytest.mark.asyncio @@ -144,9 +144,8 @@ async def test_lookup_token_by_symbol_limit_more_than_seven(mock_ctx): assert result.model_dump() == expected_result.model_dump() assert result.content_text is not None assert len(result.data) == 7 - assert mock_ctx.report_progress.call_count == 3 - assert mock_ctx.info.call_count == 3 - assert mock_ctx.info.call_count == 3 + assert mock_ctx.report_progress.call_count == 2 + assert mock_ctx.info.call_count == 2 @pytest.mark.asyncio @@ -201,7 +200,7 @@ async def test_lookup_token_by_symbol_limit_exactly_seven(mock_ctx): assert result.model_dump() == expected_result.model_dump() assert result.content_text is not None assert len(result.data) == 7 - assert mock_ctx.report_progress.call_count == 3 + assert mock_ctx.report_progress.call_count == 2 @pytest.mark.asyncio @@ -233,7 +232,7 @@ async def test_lookup_token_by_symbol_empty_results(mock_ctx): ) assert result.model_dump() == expected_result.model_dump() assert result.content_text is not None - assert mock_ctx.report_progress.call_count == 3 + assert mock_ctx.report_progress.call_count == 2 @pytest.mark.asyncio @@ -296,7 +295,7 @@ async def test_lookup_token_by_symbol_missing_fields(mock_ctx): ) assert result.model_dump() == expected_result.model_dump() assert result.content_text is not None - assert mock_ctx.report_progress.call_count == 3 + assert mock_ctx.report_progress.call_count == 2 @pytest.mark.asyncio @@ -354,7 +353,7 @@ async def test_lookup_token_by_symbol_total_supply_none(mock_ctx): assert result.model_dump() == expected_result.model_dump() assert result.content_text is not None assert result.data[0].total_supply is None - assert mock_ctx.report_progress.call_count == 3 + assert mock_ctx.report_progress.call_count == 2 @pytest.mark.asyncio @@ -394,8 +393,6 @@ async def test_lookup_token_by_symbol_chain_not_found(mock_ctx): chain_id = "999999" symbol = "TEST" - from blockscout_mcp_server.tools.common import ChainNotFoundError - chain_error = ChainNotFoundError(f"Chain with ID '{chain_id}' not found on Chainscout.") with patch( @@ -444,4 +441,4 @@ async def test_lookup_token_by_symbol_no_items_field(mock_ctx): ) assert result.model_dump() == expected_result.model_dump() assert result.content_text is not None - assert mock_ctx.report_progress.call_count == 3 + assert mock_ctx.report_progress.call_count == 2 From ff6f3fc6330fc1017b94f8ecc86a114690e39c47 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 11:29:10 -0600 Subject: [PATCH 02/11] Phase 2: Collapse transaction & contract-ABI single-fetch tools Co-Authored-By: Claude Opus 4.8 --- .../tools/contract/get_contract_abi.py | 14 ++--- .../tools/transaction/get_transaction_info.py | 6 +-- tests/tools/contract/test_get_contract_abi.py | 51 +++++++++---------- .../transaction/test_get_transaction_info.py | 40 +++++++-------- 4 files changed, 48 insertions(+), 63 deletions(-) diff --git a/blockscout_mcp_server/tools/contract/get_contract_abi.py b/blockscout_mcp_server/tools/contract/get_contract_abi.py index 6d71b31..a8b650d 100644 --- a/blockscout_mcp_server/tools/contract/get_contract_abi.py +++ b/blockscout_mcp_server/tools/contract/get_contract_abi.py @@ -30,18 +30,10 @@ async def get_contract_abi( await report_and_log_progress( ctx, progress=0.0, - total=2.0, + total=1.0, message=f"Starting to fetch contract ABI for {address} on chain {chain_id}...", ) - # Report progress before fetching - await report_and_log_progress( - ctx, - progress=1.0, - total=2.0, - message="Fetching data...", - ) - # 20s light timeout validated empirically: payloads range from ~10 KB # (simple proxies) to ~350 KB (large multi-file projects like Uniswap V3 # Universal Router); worst-case server response is ~10-15s on loaded @@ -55,8 +47,8 @@ async def get_contract_abi( # Report completion await report_and_log_progress( ctx, - progress=2.0, - total=2.0, + progress=1.0, + total=1.0, message="Successfully fetched contract ABI.", ) diff --git a/blockscout_mcp_server/tools/transaction/get_transaction_info.py b/blockscout_mcp_server/tools/transaction/get_transaction_info.py index 50d9d96..9c787ed 100644 --- a/blockscout_mcp_server/tools/transaction/get_transaction_info.py +++ b/blockscout_mcp_server/tools/transaction/get_transaction_info.py @@ -40,12 +40,10 @@ async def get_transaction_info( await report_and_log_progress( ctx, progress=0.0, - total=2.0, + total=1.0, message=f"Starting to fetch transaction info for {transaction_hash} on chain {chain_id}...", ) - await report_and_log_progress(ctx, progress=1.0, total=2.0, message="Fetching transaction data...") - operations_path = "/api/v2/proxy/account-abstraction/operations" transaction_result, ops_result = await asyncio.gather( @@ -67,7 +65,7 @@ async def get_transaction_info( if isinstance(ops_result, Exception): ops_error_note = f"Could not retrieve user operations. The 'user_operations' field is null. Error: {ops_result}" - await report_and_log_progress(ctx, progress=2.0, total=2.0, message="Successfully fetched transaction data.") + await report_and_log_progress(ctx, progress=1.0, total=1.0, message="Successfully fetched transaction data.") processed_data, was_truncated = _process_and_truncate_tx_info_data(response_data, include_raw_input) diff --git a/tests/tools/contract/test_get_contract_abi.py b/tests/tools/contract/test_get_contract_abi.py index 0b027a0..4eb2d71 100644 --- a/tests/tools/contract/test_get_contract_abi.py +++ b/tests/tools/contract/test_get_contract_abi.py @@ -6,6 +6,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.models import ContractAbiData, ToolResponse +from blockscout_mcp_server.tools.common import ChainNotFoundError from blockscout_mcp_server.tools.contract.get_contract_abi import get_contract_abi @@ -59,15 +60,14 @@ async def test_get_contract_abi_success(mock_ctx): timeout=config.bs_light_timeout, ) assert_contract_abi_response(result, mock_abi_list) - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 progress_calls = mock_ctx.report_progress.await_args_list - assert [call.kwargs["progress"] for call in progress_calls] == [0.0, 1.0, 2.0] - assert [call.kwargs["total"] for call in progress_calls] == [2.0, 2.0, 2.0] + assert [call.kwargs["progress"] for call in progress_calls] == [0.0, 1.0] + assert [call.kwargs["total"] for call in progress_calls] == [1.0, 1.0] info_messages = [call.args[0] for call in mock_ctx.info.await_args_list] assert "Starting to fetch contract ABI for 0xa0b86a33e6dd0ba3c70de3b8e2b9e48cd6efb7b0" in info_messages[0] - assert "Fetching data" in info_messages[1] - assert "Successfully fetched contract ABI." in info_messages[2] + assert "Successfully fetched contract ABI." in info_messages[1] @pytest.mark.asyncio @@ -96,15 +96,14 @@ async def test_get_contract_abi_missing_abi_field(mock_ctx): timeout=config.bs_light_timeout, ) assert_contract_abi_response(result, None) - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 progress_calls = mock_ctx.report_progress.await_args_list - assert [call.kwargs["progress"] for call in progress_calls] == [0.0, 1.0, 2.0] - assert [call.kwargs["total"] for call in progress_calls] == [2.0, 2.0, 2.0] + assert [call.kwargs["progress"] for call in progress_calls] == [0.0, 1.0] + assert [call.kwargs["total"] for call in progress_calls] == [1.0, 1.0] info_messages = [call.args[0] for call in mock_ctx.info.await_args_list] assert "Starting to fetch contract ABI for 0xa0b86a33e6dd0ba3c70de3b8e2b9e48cd6efb7b0" in info_messages[0] - assert "Fetching data" in info_messages[1] - assert "Successfully fetched contract ABI." in info_messages[2] + assert "Successfully fetched contract ABI." in info_messages[1] @pytest.mark.asyncio @@ -133,15 +132,14 @@ async def test_get_contract_abi_empty_abi(mock_ctx): timeout=config.bs_light_timeout, ) assert_contract_abi_response(result, []) - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 progress_calls = mock_ctx.report_progress.await_args_list - assert [call.kwargs["progress"] for call in progress_calls] == [0.0, 1.0, 2.0] - assert [call.kwargs["total"] for call in progress_calls] == [2.0, 2.0, 2.0] + assert [call.kwargs["progress"] for call in progress_calls] == [0.0, 1.0] + assert [call.kwargs["total"] for call in progress_calls] == [1.0, 1.0] info_messages = [call.args[0] for call in mock_ctx.info.await_args_list] assert "Starting to fetch contract ABI for 0xa0b86a33e6dd0ba3c70de3b8e2b9e48cd6efb7b0" in info_messages[0] - assert "Fetching data" in info_messages[1] - assert "Successfully fetched contract ABI." in info_messages[2] + assert "Successfully fetched contract ABI." in info_messages[1] @pytest.mark.asyncio @@ -169,7 +167,7 @@ async def test_get_contract_abi_api_error(mock_ctx): api_path=f"/api/v2/smart-contracts/{address}", timeout=config.bs_light_timeout, ) - assert mock_ctx.report_progress.await_count == 2 # 0.0 and 1.0 only + assert mock_ctx.report_progress.await_count == 1 # 0.0 only infos = [c.args[0] for c in mock_ctx.info.await_args_list] assert any("Starting to fetch contract ABI" in message for message in infos) assert not any("Successfully fetched contract ABI." in message for message in infos) @@ -184,8 +182,6 @@ async def test_get_contract_abi_chain_not_found(mock_ctx): chain_id = "999999" address = "0xa0b86a33e6dd0ba3c70de3b8e2b9e48cd6efb7b0" - from blockscout_mcp_server.tools.common import ChainNotFoundError - chain_error = ChainNotFoundError(f"Chain with ID '{chain_id}' not found on Chainscout.") with patch( @@ -202,7 +198,7 @@ async def test_get_contract_abi_chain_not_found(mock_ctx): api_path=f"/api/v2/smart-contracts/{address}", timeout=config.bs_light_timeout, ) - assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.report_progress.await_count == 1 assert mock_ctx.report_progress.await_args_list[0].kwargs["progress"] == 0.0 @@ -232,7 +228,7 @@ async def test_get_contract_abi_invalid_address_format(mock_ctx): api_path=f"/api/v2/smart-contracts/{address}", timeout=config.bs_light_timeout, ) - assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.report_progress.await_count == 1 @pytest.mark.asyncio @@ -291,11 +287,10 @@ async def test_get_contract_abi_complex_abi(mock_ctx): timeout=config.bs_light_timeout, ) assert_contract_abi_response(result, mock_abi_list) - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 progress_calls = mock_ctx.report_progress.await_args_list - assert [call.kwargs["progress"] for call in progress_calls] == [0.0, 1.0, 2.0] + assert [call.kwargs["progress"] for call in progress_calls] == [0.0, 1.0] info_messages = [call.args[0] for call in mock_ctx.info.await_args_list] assert "Starting to fetch contract ABI for 0xa0b86a33e6dd0ba3c70de3b8e2b9e48cd6efb7b0" in info_messages[0] - assert "Fetching data" in info_messages[1] - assert "Successfully fetched contract ABI." in info_messages[2] + assert "Successfully fetched contract ABI." in info_messages[1] diff --git a/tests/tools/transaction/test_get_transaction_info.py b/tests/tools/transaction/test_get_transaction_info.py index 79db72c..f8ac0d6 100644 --- a/tests/tools/transaction/test_get_transaction_info.py +++ b/tests/tools/transaction/test_get_transaction_info.py @@ -82,8 +82,8 @@ async def test_get_transaction_info_success(mock_ctx): data = result.data.model_dump(by_alias=True) for key, value in expected_transformed_result.items(): assert data[key] == value - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 assert result.instructions is not None assert all("/api/v2/proxy/account-abstraction/operations" not in instr for instr in result.instructions) @@ -124,8 +124,8 @@ async def test_get_transaction_info_with_user_ops(mock_ctx): ), ] ) - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 assert any( "Starting to fetch transaction info" in call.kwargs.get("message", "") for call in mock_ctx.report_progress.await_args_list @@ -200,8 +200,8 @@ async def test_get_transaction_info_no_user_ops(mock_ctx): ), ] ) - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 assert any( "Starting to fetch transaction info" in call.kwargs.get("message", "") for call in mock_ctx.report_progress.await_args_list @@ -241,8 +241,8 @@ async def test_get_transaction_info_ops_api_failure(mock_ctx): ), ] ) - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 assert any( "Starting to fetch transaction info" in call.kwargs.get("message", "") for call in mock_ctx.report_progress.await_args_list @@ -288,8 +288,8 @@ async def test_get_transaction_info_pagination_note(mock_ctx): ), ] ) - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 assert any( "Starting to fetch transaction info" in call.kwargs.get("message", "") for call in mock_ctx.report_progress.await_args_list @@ -462,8 +462,8 @@ async def test_get_transaction_info_not_found(mock_ctx): ), ] ) - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.info.await_count == 2 + assert mock_ctx.report_progress.await_count == 1 + assert mock_ctx.info.await_count == 1 @pytest.mark.asyncio @@ -487,8 +487,8 @@ async def test_get_transaction_info_chain_not_found(mock_ctx): with pytest.raises(ChainNotFoundError): await get_transaction_info(chain_id=chain_id, transaction_hash=tx_hash, ctx=mock_ctx) - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.info.await_count == 2 + assert mock_ctx.report_progress.await_count == 1 + assert mock_ctx.info.await_count == 1 @pytest.mark.asyncio @@ -537,8 +537,8 @@ async def test_get_transaction_info_minimal_response(mock_ctx): data = result.data.model_dump(by_alias=True) for key, value in expected_result.items(): assert data[key] == value - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 @pytest.mark.asyncio @@ -587,8 +587,8 @@ async def test_get_transaction_info_with_token_transfers_transformation(mock_ctx assert result.data.to_address == "0x3328..." assert isinstance(result.data.token_transfers[0], TokenTransfer) assert result.data.token_transfers[0].transfer_type == "token_minting" - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 @pytest.mark.asyncio @@ -633,5 +633,5 @@ async def test_get_transaction_info_handles_null_token_transfer_metadata(mock_ct assert isinstance(result.data, TransactionInfoData) assert isinstance(result.data.token_transfers[0], TokenTransfer) assert result.data.token_transfers[0].token is None - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 From e3aea880f16a83d051a5b757d5bca079e48c6212 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 11:35:27 -0600 Subject: [PATCH 03/11] Phase 3: Honest progress for inspect_contract_code (DD-02) Co-Authored-By: Claude Opus 4.8 --- .../tools/contract/_shared.py | 20 ++----------- .../tools/contract/inspect_contract_code.py | 10 +++++-- .../test_fetch_and_process_contract.py | 26 ++++++++--------- .../contract/test_inspect_contract_code.py | 28 ++++++++++++++----- 4 files changed, 43 insertions(+), 41 deletions(-) diff --git a/blockscout_mcp_server/tools/contract/_shared.py b/blockscout_mcp_server/tools/contract/_shared.py index 4979095..4928637 100644 --- a/blockscout_mcp_server/tools/contract/_shared.py +++ b/blockscout_mcp_server/tools/contract/_shared.py @@ -1,13 +1,11 @@ # SPDX-License-Identifier: LicenseRef-Blockscout from typing import Any -from mcp.server.fastmcp import Context - from blockscout_mcp_server.cache import CachedContract, contract_cache from blockscout_mcp_server.config import config from blockscout_mcp_server.tools.common import ( + _truncate_constructor_args, make_blockscout_request, - report_and_log_progress, ) @@ -23,7 +21,7 @@ def _determine_file_path(raw_data: dict[str, Any]) -> str: return file_path -async def _fetch_and_process_contract(chain_id: str, address: str, ctx: Context) -> CachedContract: +async def _fetch_and_process_contract(chain_id: str, address: str) -> CachedContract: """Fetch contract data from cache or Blockscout API.""" normalized_address = address.lower() @@ -31,12 +29,6 @@ async def _fetch_and_process_contract(chain_id: str, address: str, ctx: Context) if cached := await contract_cache.get(cache_key): return cached - await report_and_log_progress( - ctx, - progress=1.0, - total=2.0, - message="Fetching data...", - ) api_path = f"/api/v2/smart-contracts/{normalized_address}" # 20s light timeout validated empirically: payloads range from ~10 KB # (simple proxies) to ~350 KB (large multi-file projects like Uniswap V3 @@ -47,12 +39,6 @@ async def _fetch_and_process_contract(chain_id: str, address: str, ctx: Context) api_path=api_path, timeout=config.bs_light_timeout, ) - await report_and_log_progress( - ctx, - progress=2.0, - total=2.0, - message="Successfully fetched contract data.", - ) raw_data.setdefault("name", normalized_address) for key in [ "language", @@ -85,8 +71,6 @@ async def _fetch_and_process_contract(chain_id: str, address: str, ctx: Context) metadata_copy = raw_data.copy() # Process constructor args on the copy instead of the original - from blockscout_mcp_server.tools.common import _truncate_constructor_args # Local import to avoid cycles - processed_args, truncated_flag = _truncate_constructor_args(metadata_copy.get("constructor_args")) metadata_copy["constructor_args"] = processed_args metadata_copy["constructor_args_truncated"] = truncated_flag diff --git a/blockscout_mcp_server/tools/contract/inspect_contract_code.py b/blockscout_mcp_server/tools/contract/inspect_contract_code.py index 7becb2f..4c6231d 100644 --- a/blockscout_mcp_server/tools/contract/inspect_contract_code.py +++ b/blockscout_mcp_server/tools/contract/inspect_contract_code.py @@ -38,11 +38,17 @@ async def inspect_contract_code( await report_and_log_progress( ctx, progress=0.0, - total=2.0, + total=1.0, message=start_msg, ) - processed = await _fetch_and_process_contract(chain_id, address, ctx) + processed = await _fetch_and_process_contract(chain_id, address) + await report_and_log_progress( + ctx, + progress=1.0, + total=1.0, + message="Successfully fetched contract data.", + ) if file_name is None: metadata = ContractMetadata.model_validate(processed.metadata) instructions = None diff --git a/tests/tools/contract/test_fetch_and_process_contract.py b/tests/tools/contract/test_fetch_and_process_contract.py index 3e4b838..4693258 100644 --- a/tests/tools/contract/test_fetch_and_process_contract.py +++ b/tests/tools/contract/test_fetch_and_process_contract.py @@ -33,7 +33,7 @@ async def test_fetch_and_process_cache_miss(mock_ctx): new_callable=AsyncMock, ) as mock_set, ): - await _fetch_and_process_contract("1", "0xAbC", mock_ctx) + await _fetch_and_process_contract("1", "0xAbC") mock_get.assert_awaited_once_with("1:0xabc") mock_request.assert_awaited_once_with( chain_id="1", @@ -44,9 +44,7 @@ async def test_fetch_and_process_cache_miss(mock_ctx): key_arg, value_arg = mock_set.await_args.args assert key_arg == "1:0xabc" assert isinstance(value_arg, CachedContract) - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.report_progress.await_args_list[0].kwargs["message"] == "Fetching data..." - assert mock_ctx.report_progress.await_args_list[1].kwargs["message"] == "Successfully fetched contract data." + assert mock_ctx.report_progress.await_count == 0 @pytest.mark.asyncio @@ -63,7 +61,7 @@ async def test_fetch_and_process_cache_hit(mock_ctx): new_callable=AsyncMock, ) as mock_request, ): - result = await _fetch_and_process_contract("1", "0xAbC", mock_ctx) + result = await _fetch_and_process_contract("1", "0xAbC") assert result is cached mock_get.assert_awaited_once_with("1:0xabc") mock_request.assert_not_called() @@ -95,11 +93,11 @@ async def test_process_logic_single_solidity_file(mock_ctx): new_callable=AsyncMock, ) as mock_set, ): - result = await _fetch_and_process_contract("1", "0xabc", mock_ctx) + result = await _fetch_and_process_contract("1", "0xabc") assert result.metadata["source_code_tree_structure"] == ["MyContract.sol"] assert set(result.source_files.keys()) == {"MyContract.sol"} mock_set.assert_awaited_once() - assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.report_progress.await_count == 0 @pytest.mark.asyncio @@ -128,12 +126,12 @@ async def test_process_logic_multi_file_missing_main_path(mock_ctx): new_callable=AsyncMock, ), ): - result = await _fetch_and_process_contract("1", "0xabc", mock_ctx) + result = await _fetch_and_process_contract("1", "0xabc") assert set(result.metadata["source_code_tree_structure"]) == {"Main.sol", "B.sol"} assert set(result.source_files.keys()) == {"Main.sol", "B.sol"} assert result.source_files["Main.sol"] == "a" assert result.source_files["B.sol"] == "b" - assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.report_progress.await_count == 0 @pytest.mark.asyncio @@ -169,16 +167,16 @@ async def test_process_logic_multi_file_and_vyper(mock_ctx): new_callable=AsyncMock, return_value=multi_resp, ): - multi = await _fetch_and_process_contract("1", "0x1", mock_ctx) + multi = await _fetch_and_process_contract("1", "0x1") with patch( "blockscout_mcp_server.tools.contract._shared.make_blockscout_request", new_callable=AsyncMock, return_value=vyper_resp, ): - vyper = await _fetch_and_process_contract("1", "0x2", mock_ctx) + vyper = await _fetch_and_process_contract("1", "0x2") assert set(multi.metadata["source_code_tree_structure"]) == {"A.sol", "B.sol"} assert vyper.metadata["source_code_tree_structure"] == ["VyperC.vy"] - assert mock_ctx.report_progress.await_count == 4 + assert mock_ctx.report_progress.await_count == 0 assert mock_set.await_count == 2 @@ -207,7 +205,7 @@ async def test_process_logic_unverified_contract(mock_ctx): new_callable=AsyncMock, ), ): - result = await _fetch_and_process_contract("1", "0xAbC", mock_ctx) + result = await _fetch_and_process_contract("1", "0xAbC") assert result.source_files == {} assert result.metadata["source_code_tree_structure"] == [] assert result.metadata["name"] == "0xabc" @@ -216,4 +214,4 @@ async def test_process_logic_unverified_contract(mock_ctx): assert "deployed_bytecode" not in result.metadata assert "source_code" not in result.metadata assert "additional_sources" not in result.metadata - assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.report_progress.await_count == 0 diff --git a/tests/tools/contract/test_inspect_contract_code.py b/tests/tools/contract/test_inspect_contract_code.py index 38cab72..03e93e7 100644 --- a/tests/tools/contract/test_inspect_contract_code.py +++ b/tests/tools/contract/test_inspect_contract_code.py @@ -36,12 +36,15 @@ async def test_inspect_contract_metadata_mode_success(mock_ctx): return_value=contract, ) as mock_fetch: result = await inspect_contract_code(chain_id="1", address="0xabc", file_name=None, ctx=mock_ctx) - mock_fetch.assert_awaited_once_with("1", "0xabc", mock_ctx) - mock_ctx.report_progress.assert_awaited_once() + mock_fetch.assert_awaited_once_with("1", "0xabc") + assert mock_ctx.report_progress.await_count == 2 assert ( mock_ctx.report_progress.await_args_list[0].kwargs["message"] == "Starting to fetch contract metadata for 0xabc on chain 1..." ) + assert mock_ctx.report_progress.await_args_list[0].kwargs["total"] == 1.0 + assert mock_ctx.report_progress.await_args_list[1].kwargs["message"] == "Successfully fetched contract data." + assert mock_ctx.report_progress.await_args_list[1].kwargs["total"] == 1.0 assert isinstance(result, ToolResponse) assert isinstance(result.data, ContractMetadata) assert result.data.source_code_tree_structure == ["A.sol"] @@ -62,13 +65,17 @@ async def test_inspect_contract_file_content_mode_success(mock_ctx): "blockscout_mcp_server.tools.contract.inspect_contract_code._fetch_and_process_contract", new_callable=AsyncMock, return_value=contract, - ): + ) as mock_fetch: result = await inspect_contract_code(chain_id="1", address="0xabc", file_name="A.sol", ctx=mock_ctx) - mock_ctx.report_progress.assert_awaited_once() + mock_fetch.assert_awaited_once_with("1", "0xabc") + assert mock_ctx.report_progress.await_count == 2 assert ( mock_ctx.report_progress.await_args_list[0].kwargs["message"] == "Starting to fetch source code for 'A.sol' of contract 0xabc on chain 1..." ) + assert mock_ctx.report_progress.await_args_list[0].kwargs["total"] == 1.0 + assert mock_ctx.report_progress.await_args_list[1].kwargs["message"] == "Successfully fetched contract data." + assert mock_ctx.report_progress.await_args_list[1].kwargs["total"] == 1.0 assert isinstance(result.data, ContractSourceFile) assert result.data.file_content == "pragma" @@ -80,14 +87,18 @@ async def test_inspect_contract_file_not_found_raises_error(mock_ctx): "blockscout_mcp_server.tools.contract.inspect_contract_code._fetch_and_process_contract", new_callable=AsyncMock, return_value=contract, - ): + ) as mock_fetch: with pytest.raises(ValueError) as exc: await inspect_contract_code(chain_id="1", address="0xabc", file_name="B.sol", ctx=mock_ctx) - mock_ctx.report_progress.assert_awaited_once() + mock_fetch.assert_awaited_once_with("1", "0xabc") + assert mock_ctx.report_progress.await_count == 2 assert ( mock_ctx.report_progress.await_args_list[0].kwargs["message"] == "Starting to fetch source code for 'B.sol' of contract 0xabc on chain 1..." ) + assert mock_ctx.report_progress.await_args_list[0].kwargs["total"] == 1.0 + assert mock_ctx.report_progress.await_args_list[1].kwargs["message"] == "Successfully fetched contract data." + assert mock_ctx.report_progress.await_args_list[1].kwargs["total"] == 1.0 assert "Available files: A.sol" in str(exc.value) @@ -101,11 +112,12 @@ async def test_inspect_contract_propagates_api_error(mock_ctx): ): with pytest.raises(httpx.HTTPStatusError): await inspect_contract_code(chain_id="1", address="0xabc", file_name=None, ctx=mock_ctx) - mock_ctx.report_progress.assert_awaited_once() + assert mock_ctx.report_progress.await_count == 1 assert ( mock_ctx.report_progress.await_args_list[0].kwargs["message"] == "Starting to fetch contract metadata for 0xabc on chain 1..." ) + assert mock_ctx.report_progress.await_args_list[0].kwargs["total"] == 1.0 @pytest.mark.asyncio @@ -137,3 +149,5 @@ async def test_inspect_contract_metadata_mode_truncated_sets_notes(mock_ctx): result = await inspect_contract_code(chain_id="1", address="0xabc", file_name=None, ctx=mock_ctx) assert result.notes == ["Constructor arguments were truncated to limit context size."] assert result.instructions is None + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.report_progress.await_args_list[1].kwargs["message"] == "Successfully fetched contract data." From f1bea4cbe78ed5593e2b26d8b3e2314a50699cfa Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 11:40:52 -0600 Subject: [PATCH 04/11] Phase 4: Collapse direct_api_call with enriched start message (DD-01) Co-Authored-By: Claude Opus 4.8 --- .../tools/direct_api/direct_api_call.py | 30 +-- .../tools/direct_api/test_direct_api_call.py | 19 +- .../test_direct_api_call_progress.py | 239 ++++++++++++++++++ 3 files changed, 263 insertions(+), 25 deletions(-) create mode 100644 tests/tools/direct_api/test_direct_api_call_progress.py diff --git a/blockscout_mcp_server/tools/direct_api/direct_api_call.py b/blockscout_mcp_server/tools/direct_api/direct_api_call.py index 76404f2..6b0efb9 100644 --- a/blockscout_mcp_server/tools/direct_api/direct_api_call.py +++ b/blockscout_mcp_server/tools/direct_api/direct_api_call.py @@ -74,28 +74,26 @@ async def direct_api_call( raise ValueError("json_body must be a JSON object (dict).") if method == "POST" and cursor is not None: raise ValueError("Pagination (cursor) is not supported for POST requests.") - await report_and_log_progress( - ctx, - progress=0.0, - total=2.0, - message=f"Preparing request for chain {chain_id}...", - ) if endpoint_path != "/" and endpoint_path.endswith("/"): endpoint_path = endpoint_path.rstrip("/") if "?" in endpoint_path: raise ValueError("Do not include query parameters in endpoint_path. Use query_params instead.") + start_message = f"Starting {method} request to {endpoint_path} on chain {chain_id}..." + if cursor is not None: + start_message += " (next page)" + await report_and_log_progress( + ctx, + progress=0.0, + total=1.0, + message=start_message, + ) + params = dict(query_params) if query_params else {} if method == "GET": apply_cursor_to_params(cursor, params) - await report_and_log_progress( - ctx, - progress=1.0, - total=2.0, - message="Fetching data from Blockscout API...", - ) if method == "GET": response_json = await make_blockscout_request(chain_id=chain_id, api_path=endpoint_path, params=params) else: @@ -118,8 +116,8 @@ async def direct_api_call( if handler_response is not None: await report_and_log_progress( ctx, - progress=2.0, - total=2.0, + progress=1.0, + total=1.0, message="Successfully fetched data.", ) return handler_response @@ -140,8 +138,8 @@ async def direct_api_call( await report_and_log_progress( ctx, - progress=2.0, - total=2.0, + progress=1.0, + total=1.0, message="Successfully fetched data.", ) diff --git a/tests/tools/direct_api/test_direct_api_call.py b/tests/tools/direct_api/test_direct_api_call.py index 1cc12c2..f37dbfe 100644 --- a/tests/tools/direct_api/test_direct_api_call.py +++ b/tests/tools/direct_api/test_direct_api_call.py @@ -35,7 +35,7 @@ async def test_direct_api_call_no_params(mock_ctx): assert isinstance(result.data, DirectApiData) assert result.data.model_dump() == mock_response assert result.pagination is None - assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 @pytest.mark.asyncio @@ -82,7 +82,7 @@ def fake_apply(cursor, params): assert isinstance(result.data, DirectApiData) assert result.data.model_dump() == mock_response assert result.pagination is None - assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 @pytest.mark.asyncio @@ -114,7 +114,7 @@ async def test_direct_api_call_with_pagination(mock_ctx): assert "cursor" in nc assert "query_params" not in nc mock_request.assert_called_once_with(chain_id=chain_id, api_path=endpoint_path, params={}) - assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 @pytest.mark.asyncio @@ -147,7 +147,7 @@ async def test_direct_api_call_with_query_params_pagination(mock_ctx): assert nc["query_params"] == query_params assert "cursor" in nc mock_request.assert_called_once_with(chain_id=chain_id, api_path=endpoint_path, params=query_params) - assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 @pytest.mark.asyncio @@ -168,7 +168,7 @@ async def test_direct_api_call_raises_on_request_error(mock_ctx): ctx=mock_ctx, ) mock_request.assert_awaited_once() - assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.report_progress.await_count == 1 @pytest.mark.asyncio @@ -181,7 +181,8 @@ async def test_direct_api_call_rejects_query_in_path(mock_ctx): endpoint_path=endpoint_path, ctx=mock_ctx, ) - assert mock_ctx.report_progress.await_count == 1 + assert mock_ctx.report_progress.await_count == 0 + assert mock_ctx.info.await_count == 0 @pytest.mark.asyncio @@ -389,7 +390,7 @@ async def test_direct_api_call_post_basic(mock_ctx): json_body={"id": 1}, params={}, ) - assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 @pytest.mark.asyncio @@ -420,7 +421,7 @@ async def test_direct_api_call_post_with_query_params(mock_ctx): json_body={"id": 1}, params={"foo": "bar"}, ) - assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 @pytest.mark.asyncio @@ -496,4 +497,4 @@ async def test_direct_api_call_post_ignores_next_page_params_for_pagination(mock json_body={"id": 1}, params={}, ) - assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 diff --git a/tests/tools/direct_api/test_direct_api_call_progress.py b/tests/tools/direct_api/test_direct_api_call_progress.py new file mode 100644 index 0000000..dfdec3b --- /dev/null +++ b/tests/tools/direct_api/test_direct_api_call_progress.py @@ -0,0 +1,239 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +"""Progress-focused tests for direct_api_call (companion to test_direct_api_call.py). + +Covers: start-message naming, " (next page)" suffix, large-response +completion-ordering, and query-string leak-guard. Kept in a separate module +to stay within the 500-LOC cap on the main test file (rule 210 / rule 010 §6). +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import blockscout_mcp_server.tools.direct_api.direct_api_call as direct_api_call_module +from blockscout_mcp_server.tools.common import ResponseTooLargeError + +# --------------------------------------------------------------------------- +# Start-message naming +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_start_message_names_method_and_endpoint_get(mock_ctx): + """Start beat carries the HTTP method and endpoint path for a GET request.""" + chain_id = "1" + endpoint_path = "/api/v2/stats" + mock_response = {"result": 1} + + with patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_request: + mock_request.return_value = mock_response + + await direct_api_call_module.direct_api_call( + chain_id=chain_id, + endpoint_path=endpoint_path, + ctx=mock_ctx, + ) + + # First report_progress call is the start beat + first_call_kwargs = mock_ctx.report_progress.await_args_list[0].kwargs + assert first_call_kwargs["progress"] == 0.0 + assert first_call_kwargs["total"] == 1.0 + assert "GET" in first_call_kwargs["message"] + assert endpoint_path in first_call_kwargs["message"] + assert chain_id in first_call_kwargs["message"] + + +@pytest.mark.asyncio +async def test_start_message_names_method_and_endpoint_post(mock_ctx): + """Start beat carries the HTTP method and endpoint path for a POST request.""" + chain_id = "42" + endpoint_path = "/json-rpc" + + with patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_post_request", + new_callable=AsyncMock, + ) as mock_post: + mock_post.return_value = {"jsonrpc": "2.0", "result": "0x1"} + + await direct_api_call_module.direct_api_call( + chain_id=chain_id, + endpoint_path=endpoint_path, + method="POST", + json_body={"id": 1}, + ctx=mock_ctx, + ) + + first_call_kwargs = mock_ctx.report_progress.await_args_list[0].kwargs + assert first_call_kwargs["progress"] == 0.0 + assert first_call_kwargs["total"] == 1.0 + assert "POST" in first_call_kwargs["message"] + assert endpoint_path in first_call_kwargs["message"] + assert chain_id in first_call_kwargs["message"] + + +@pytest.mark.asyncio +async def test_start_message_names_method_and_endpoint_handler_path(mock_ctx): + """Start beat carries method and endpoint when a specialized handler returns a response.""" + chain_id = "1" + endpoint_path = "/api/v2/addresses" + mock_response = {"items": []} + handler_result = MagicMock() + + with ( + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_request, + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.dispatcher.dispatch", + new_callable=AsyncMock, + ) as mock_dispatch, + ): + mock_request.return_value = mock_response + mock_dispatch.return_value = handler_result + + await direct_api_call_module.direct_api_call( + chain_id=chain_id, + endpoint_path=endpoint_path, + ctx=mock_ctx, + ) + + first_call_kwargs = mock_ctx.report_progress.await_args_list[0].kwargs + assert "GET" in first_call_kwargs["message"] + assert endpoint_path in first_call_kwargs["message"] + assert chain_id in first_call_kwargs["message"] + # Completion beat fires too (handler path) + assert mock_ctx.report_progress.await_count == 2 + + +# --------------------------------------------------------------------------- +# " (next page)" suffix +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_start_message_has_next_page_suffix_when_cursor_provided(mock_ctx): + """Start beat appends ' (next page)' when a pagination cursor is supplied.""" + chain_id = "1" + endpoint_path = "/api/v2/data" + mock_response = {"items": []} + + with ( + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_request, + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.apply_cursor_to_params", + new_callable=MagicMock, + ), + ): + mock_request.return_value = mock_response + + await direct_api_call_module.direct_api_call( + chain_id=chain_id, + endpoint_path=endpoint_path, + cursor="some-cursor", + ctx=mock_ctx, + ) + + first_call_kwargs = mock_ctx.report_progress.await_args_list[0].kwargs + assert " (next page)" in first_call_kwargs["message"] + + +@pytest.mark.asyncio +async def test_start_message_has_no_next_page_suffix_without_cursor(mock_ctx): + """Start beat does NOT contain ' (next page)' when no cursor is supplied.""" + chain_id = "1" + endpoint_path = "/api/v2/data" + mock_response = {"items": []} + + with patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_request: + mock_request.return_value = mock_response + + await direct_api_call_module.direct_api_call( + chain_id=chain_id, + endpoint_path=endpoint_path, + ctx=mock_ctx, + ) + + first_call_kwargs = mock_ctx.report_progress.await_args_list[0].kwargs + assert " (next page)" not in first_call_kwargs["message"] + + +# --------------------------------------------------------------------------- +# Large-response completion-ordering +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_large_response_mcp_both_beats_fire_before_error(mock_ctx): + """Both start and completion beats fire before ResponseTooLargeError is raised (non-REST).""" + chain_id = "1" + endpoint_path = "/api/v2/stats" + mock_response = {"data": "x" * 150} + + with ( + patch.object(direct_api_call_module.config, "direct_api_response_size_limit", 100), + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_request, + ): + mock_request.return_value = mock_response + + with pytest.raises(ResponseTooLargeError): + await direct_api_call_module.direct_api_call( + chain_id=chain_id, + endpoint_path=endpoint_path, + ctx=mock_ctx, + ) + + # Both start (progress=0.0) and completion (progress=1.0) beats must have fired + assert mock_ctx.report_progress.await_count == 2 + progress_values = [call.kwargs["progress"] for call in mock_ctx.report_progress.await_args_list] + assert progress_values == [0.0, 1.0] + + +# --------------------------------------------------------------------------- +# Query-string leak-guard (DD-01 security intent) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_query_string_in_path_emits_zero_beats_and_logs_nothing(mock_ctx): + """A query string in endpoint_path emits zero progress beats and zero info logs. + + Primary guard: if a regression re-orders the start beat ahead of the '?' check, + the start message would echo the path — including any embedded secrets — through + both report_progress and ctx.info before the ValueError fires. + """ + with pytest.raises(ValueError): + await direct_api_call_module.direct_api_call( + chain_id="1", + endpoint_path="/api/v2/foo?apikey=SECRET", + ctx=mock_ctx, + ) + + # Primary guard: no beats, no logs + assert mock_ctx.report_progress.await_count == 0 + assert mock_ctx.info.await_count == 0 + + # Secondary guard: no message containing '?' or the secret token was recorded + for call in mock_ctx.report_progress.await_args_list: + msg = call.kwargs.get("message", "") + assert "?" not in msg, f"'?' found in progress message: {msg!r}" + assert "SECRET" not in msg, f"Secret token found in progress message: {msg!r}" + + for call in mock_ctx.info.await_args_list: + # ctx.info is called with a positional string argument + args = call.args + msg = args[0] if args else "" + assert "?" not in msg, f"'?' found in info log: {msg!r}" + assert "SECRET" not in msg, f"Secret token found in info log: {msg!r}" From a317853867eb3dce752e84376e053a5ce6bf89f6 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 11:45:27 -0600 Subject: [PATCH 05/11] Phase 5: Branch-sensitive collapse for block tools Co-Authored-By: Claude Opus 4.8 --- .../tools/block/get_block_info.py | 17 +++---- .../tools/block/get_block_number.py | 30 ++++--------- tests/tools/block/test_get_block_info.py | 44 +++++++++++++++---- tests/tools/block/test_get_block_number.py | 27 ++++++++++-- 4 files changed, 72 insertions(+), 46 deletions(-) diff --git a/blockscout_mcp_server/tools/block/get_block_info.py b/blockscout_mcp_server/tools/block/get_block_info.py index 74b7995..c78fe9a 100644 --- a/blockscout_mcp_server/tools/block/get_block_info.py +++ b/blockscout_mcp_server/tools/block/get_block_info.py @@ -28,7 +28,7 @@ async def get_block_info( Get block information like timestamp, gas used, burnt fees, transaction count etc. Can optionally include the list of transaction hashes contained in the block. Transaction hashes are omitted by default; request them only when you truly need them, because on high-traffic chains the list may exhaust the context. """ # noqa: E501 - total_steps = 3.0 if include_transactions else 2.0 + total_steps = 2.0 if include_transactions else 1.0 await report_and_log_progress( ctx, @@ -37,13 +37,6 @@ async def get_block_info( message=f"Starting to fetch block info for {number_or_hash} on chain {chain_id}...", ) - await report_and_log_progress( - ctx, - progress=1.0, - total=total_steps, - message="Fetching data...", - ) - if not include_transactions: response_data = await make_blockscout_request( chain_id=chain_id, @@ -52,7 +45,7 @@ async def get_block_info( ) await report_and_log_progress( ctx, - progress=2.0, + progress=1.0, total=total_steps, message="Successfully fetched block data.", ) @@ -74,9 +67,9 @@ async def get_block_info( ) await report_and_log_progress( ctx, - progress=2.0, + progress=1.0, total=total_steps, - message="Fetched block and transaction data.", + message="Block and transaction requests completed; processing results.", ) block_info_result, txs_result = results @@ -94,7 +87,7 @@ async def get_block_info( await report_and_log_progress( ctx, - progress=3.0, + progress=2.0, total=total_steps, message="Successfully fetched all block data.", ) diff --git a/blockscout_mcp_server/tools/block/get_block_number.py b/blockscout_mcp_server/tools/block/get_block_number.py index 11baf0d..f3efd9c 100644 --- a/blockscout_mcp_server/tools/block/get_block_number.py +++ b/blockscout_mcp_server/tools/block/get_block_number.py @@ -52,17 +52,10 @@ async def get_block_number( await report_and_log_progress( ctx, progress=0.0, - total=2.0, + total=1.0, message=f"Starting to fetch latest block info on chain {chain_id}...", ) - await report_and_log_progress( - ctx, - progress=1.0, - total=2.0, - message="Fetching data...", - ) - response_data = await make_blockscout_request( chain_id=chain_id, api_path="/api/v2/main-page/blocks", @@ -71,8 +64,8 @@ async def get_block_number( await report_and_log_progress( ctx, - progress=2.0, - total=2.0, + progress=1.0, + total=1.0, message="Successfully fetched latest block data.", ) @@ -95,17 +88,10 @@ async def get_block_number( await report_and_log_progress( ctx, progress=0.0, - total=3.0, + total=2.0, message=f"Starting to resolve block number on chain {chain_id}...", ) - await report_and_log_progress( - ctx, - progress=1.0, - total=3.0, - message="Fetching data...", - ) - block_lookup = await make_blockscout_request( chain_id=chain_id, api_path="/api", @@ -135,8 +121,8 @@ async def get_block_number( await report_and_log_progress( ctx, - progress=2.0, - total=3.0, + progress=1.0, + total=2.0, message="Resolved block number. Fetching block timestamp...", ) @@ -152,8 +138,8 @@ async def get_block_number( await report_and_log_progress( ctx, - progress=3.0, - total=3.0, + progress=2.0, + total=2.0, message="Successfully resolved block number by time.", ) diff --git a/tests/tools/block/test_get_block_info.py b/tests/tools/block/test_get_block_info.py index eef058d..7c4e03d 100644 --- a/tests/tools/block/test_get_block_info.py +++ b/tests/tools/block/test_get_block_info.py @@ -35,8 +35,8 @@ async def test_get_block_info_success_no_txs(mock_ctx): assert result.data.block_details == mock_api_response assert result.data.transaction_hashes is None assert result.notes is None - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 assert "mined at" in result.content_text @@ -80,8 +80,8 @@ async def mock_request_side_effect(chain_id, api_path, params=None, **kwargs): assert result.data.block_details == mock_block_response assert result.data.transaction_hashes == ["0xtx1", "0xtx2"] assert result.notes is None - assert mock_ctx.report_progress.await_count == 4 - assert mock_ctx.info.await_count == 4 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 assert "transactions, mined at" in result.content_text @@ -126,8 +126,12 @@ async def mock_request_side_effect(chain_id, api_path, params=None, **kwargs): 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] - assert mock_ctx.report_progress.await_count == 4 - assert mock_ctx.info.await_count == 4 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 + # Watershed beat (index 1) must carry the neutral message and total=2.0 + watershed_call = mock_ctx.report_progress.await_args_list[1] + assert watershed_call.kwargs["message"] == "Block and transaction requests completed; processing results." + assert watershed_call.kwargs["total"] == 2.0 @pytest.mark.asyncio @@ -154,5 +158,29 @@ async def mock_request_side_effect(chain_id, api_path, params=None, **kwargs): chain_id=chain_id, number_or_hash=number_or_hash, include_transactions=True, ctx=mock_ctx ) assert mock_request.await_count == 2 - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 + # Watershed beat (index 1) must carry the neutral message and total=2.0 + watershed_call = mock_ctx.report_progress.await_args_list[1] + assert watershed_call.kwargs["message"] == "Block and transaction requests completed; processing results." + assert watershed_call.kwargs["total"] == 2.0 + + +@pytest.mark.asyncio +async def test_get_block_info_no_txs_upstream_failure(mock_ctx): + """Verify get_block_info (no-transactions branch) emits only the start beat when the request fails.""" + chain_id = "1" + number_or_hash = "19000000" + + with ( + patch( + "blockscout_mcp_server.tools.block.get_block_info.make_blockscout_request", new_callable=AsyncMock + ) as mock_request, + ): + mock_request.side_effect = ValueError("upstream error") + + with pytest.raises(ValueError, match="upstream error"): + await get_block_info(chain_id=chain_id, number_or_hash=number_or_hash, ctx=mock_ctx) + + assert mock_ctx.report_progress.await_count == 1 + assert mock_ctx.info.await_count == 1 diff --git a/tests/tools/block/test_get_block_number.py b/tests/tools/block/test_get_block_number.py index 5ddcb42..0ea1bfb 100644 --- a/tests/tools/block/test_get_block_number.py +++ b/tests/tools/block/test_get_block_number.py @@ -32,8 +32,8 @@ async def test_get_block_number_latest_success(mock_ctx): assert isinstance(result.data, BlockNumberData) assert result.data.block_number == 12345 assert result.data.timestamp == "2023-01-01T00:00:00Z" - assert mock_ctx.report_progress.await_count == 3 - assert mock_ctx.info.await_count == 3 + assert mock_ctx.report_progress.await_count == 2 + assert mock_ctx.info.await_count == 2 assert "Latest block on chain" in result.content_text @@ -71,11 +71,30 @@ async def test_get_block_number_by_time_success(mock_ctx): "api_path": "/api/v2/blocks/12345", "timeout": config.bs_light_timeout, } - assert mock_ctx.report_progress.await_count == 4 - assert mock_ctx.info.await_count == 4 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 assert "closest block before" in result.content_text +@pytest.mark.asyncio +async def test_get_block_number_latest_upstream_failure(mock_ctx): + """Verify get_block_number (latest branch) emits only the start beat when the request fails.""" + chain_id = "1" + + with ( + patch( + "blockscout_mcp_server.tools.block.get_block_number.make_blockscout_request", new_callable=AsyncMock + ) as mock_request, + ): + mock_request.side_effect = ValueError("upstream error") + + with pytest.raises(ValueError, match="upstream error"): + await get_block_number(chain_id=chain_id, ctx=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_number_invalid_date(mock_ctx): """Verify get_block_number rejects malformed datetime input.""" From bba3a4766593f1bd801fcd3924cc7235bc8fdc01 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 11:51:18 -0600 Subject: [PATCH 06/11] Phase 6: Slim get_address_info (keep the fetch-to-process watershed) Co-Authored-By: Claude Opus 4.8 --- .../tools/address/get_address_info.py | 12 +++---- tests/tools/address/test_get_address_info.py | 32 ++++++++++++++----- .../address/test_get_address_info_metadata.py | 4 +-- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/blockscout_mcp_server/tools/address/get_address_info.py b/blockscout_mcp_server/tools/address/get_address_info.py index f15605c..8f718a8 100644 --- a/blockscout_mcp_server/tools/address/get_address_info.py +++ b/blockscout_mcp_server/tools/address/get_address_info.py @@ -73,11 +73,9 @@ async def get_address_info( Essential for address analysis, contract investigation, token research, and DeFi protocol analysis. """ # noqa: E501 await report_and_log_progress( - ctx, progress=0.0, total=3.0, message=f"Starting to fetch address info for {address} on chain {chain_id}..." + ctx, progress=0.0, total=2.0, message=f"Starting to fetch address info for {address} on chain {chain_id}..." ) - await report_and_log_progress(ctx, progress=1.0, total=3.0, message="Fetching data...") - blockscout_api_path = f"/api/v2/addresses/{address}" first_tx_api_path = f"/api/v2/addresses/{address}/transactions" first_tx_params = {"sort": "block_number", "order": "asc"} @@ -119,9 +117,9 @@ async def get_address_info( await report_and_log_progress( ctx, - progress=2.0, - total=3.0, - message="Fetched first transaction details.", + progress=1.0, + total=2.0, + message="Address data requests completed; processing results.", ) if isinstance(metadata_result, Exception): @@ -157,7 +155,7 @@ async def get_address_info( metadata=metadata_data, ) - await report_and_log_progress(ctx, progress=3.0, total=3.0, message="Successfully fetched all address data.") + await report_and_log_progress(ctx, progress=2.0, total=2.0, message="Successfully fetched all address data.") instructions = [ "This is only the native coin balance. You MUST also call `get_tokens_by_address` to get the full portfolio.", (f"Use `direct_api_call` with endpoint `/api/v2/addresses/{address}/logs` to get Logs Emitted by Address."), diff --git a/tests/tools/address/test_get_address_info.py b/tests/tools/address/test_get_address_info.py index a6b8a99..481f50c 100644 --- a/tests/tools/address/test_get_address_info.py +++ b/tests/tools/address/test_get_address_info.py @@ -95,8 +95,12 @@ async def test_get_address_info_success_with_metadata(mock_ctx): for instr in expected_instructions: assert instr in result.instructions - assert mock_ctx.report_progress.await_count == 4 - assert mock_ctx.info.await_count == 4 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 + calls = mock_ctx.report_progress.call_args_list + assert calls[0].kwargs["total"] == 2.0 + assert calls[1].kwargs["total"] == 2.0 + assert calls[2].kwargs["total"] == 2.0 @pytest.mark.asyncio @@ -180,8 +184,12 @@ async def test_get_address_info_success_without_metadata(mock_ctx): assert result.notes is None assert result.instructions is not None and len(result.instructions) > 0 - assert mock_ctx.report_progress.await_count == 4 - assert mock_ctx.info.await_count == 4 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 + calls = mock_ctx.report_progress.call_args_list + assert calls[0].kwargs["total"] == 2.0 + assert calls[1].kwargs["total"] == 2.0 + assert calls[2].kwargs["total"] == 2.0 @pytest.mark.asyncio @@ -236,8 +244,16 @@ async def test_get_address_info_first_transaction_failure(mock_ctx): assert result.notes is not None and len(result.notes) == 1 assert "Could not retrieve first transaction details" in result.notes[0] - assert mock_ctx.report_progress.await_count == 4 - assert mock_ctx.info.await_count == 4 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 + + # Verify watershed beat carries the neutral message (honest even when first-tx failed) + calls = mock_ctx.report_progress.call_args_list + assert calls[1].kwargs["message"] == "Address data requests completed; processing results." + assert calls[2].kwargs["message"] == "Successfully fetched all address data." + assert calls[0].kwargs["total"] == 2.0 + assert calls[1].kwargs["total"] == 2.0 + assert calls[2].kwargs["total"] == 2.0 @pytest.mark.asyncio @@ -283,5 +299,5 @@ async def test_get_address_info_blockscout_failure(mock_ctx): api_path="/services/metadata/api/v1/metadata", params={"addresses": address, "chainId": chain_id} ) - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.info.await_count == 2 + assert mock_ctx.report_progress.await_count == 1 + assert mock_ctx.info.await_count == 1 diff --git a/tests/tools/address/test_get_address_info_metadata.py b/tests/tools/address/test_get_address_info_metadata.py index 754d785..87f7382 100644 --- a/tests/tools/address/test_get_address_info_metadata.py +++ b/tests/tools/address/test_get_address_info_metadata.py @@ -77,8 +77,8 @@ async def test_get_address_info_metadata_failure(mock_ctx): assert result.notes is not None and len(result.notes) == 1 assert "Could not retrieve address metadata" in result.notes[0] - assert mock_ctx.report_progress.await_count == 4 - assert mock_ctx.info.await_count == 4 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 # --------------------------------------------------------------------------- From b851933abf3907112fac952556d2d83c96c1a447 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 11:55:06 -0600 Subject: [PATCH 07/11] Phase 7: Documentation Updates Co-Authored-By: Claude Opus 4.8 --- .cursor/rules/110-new-mcp-tool.mdc | 23 +++++++++++------------ SPEC.md | 8 ++++---- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/.cursor/rules/110-new-mcp-tool.mdc b/.cursor/rules/110-new-mcp-tool.mdc index a650278..0fe1788 100644 --- a/.cursor/rules/110-new-mcp-tool.mdc +++ b/.cursor/rules/110-new-mcp-tool.mdc @@ -662,27 +662,24 @@ Instead of calling `ctx.report_progress` directly, **always use the `report_and_ ```python from blockscout_mcp_server.tools.common import report_and_log_progress -async def some_tool_with_progress( +async def some_single_fetch_tool( chain_id: Annotated[str, Field(description="The ID of the blockchain")], ctx: Context ): - """A tool demonstrating correct progress reporting.""" - # Report start + """A single-fetch tool demonstrating correct progress reporting.""" + # Report the start. The message says what is being requested and why it may + # take time, so clients can warn about delays before the fetch begins. await report_and_log_progress( - ctx, progress=0.0, total=2.0, message="Starting operation..." + ctx, progress=0.0, total=1.0, + message=f"Starting to fetch ... on chain {chain_id}...", ) - # Report intermediate step - await report_and_log_progress( - ctx, progress=1.0, total=2.0, message="Fetching data from PRO API gateway..." - ) - - # ... perform the final step ... + # ... perform the single awaited operation ... response_data = await make_blockscout_request(chain_id=chain_id, api_path="/api/v2/some_endpoint") - # Report completion + # Report completion on the same 1.0 scale. await report_and_log_progress( - ctx, progress=2.0, total=2.0, message="Successfully fetched data." + ctx, progress=1.0, total=1.0, message="Successfully fetched data." ) return response_data @@ -690,6 +687,8 @@ async def some_tool_with_progress( This centralized helper ensures that every progress update is visible, regardless of the MCP client's capabilities. +**Number of beats = number of real operations.** Report one beat per genuinely distinct, awaited operation. A single-fetch tool reports only a start and a completion on a `total=1.0` scale — do not add an instant "before fetch" beat. Tools with several real awaited steps (parallel fetches with post-processing milestones, sequential multi-request flows, long-running queries) scale `total` to the number of those operations and report a beat for each. + ### Performance Optimization #### Concurrent API Calls diff --git a/SPEC.md b/SPEC.md index 19946c1..fcb389d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -179,12 +179,12 @@ This architecture provides the flexibility of a multi-protocol server without th 4. **Blockchain Data Retrieval**: - MCP Host requests blockchain data (e.g., `get_block_number`) with specific chain_id, optionally requesting progress updates - - MCP Server, if progress is requested, reports starting the operation - - MCP Server validates the chain against the cached PRO API configuration and builds the PRO API URL (`//...`) - - MCP Server reports progress before fetching data + - If progress is requested, MCP Server reports the start beat first — before chain validation — then beats that reflect the number of genuinely distinct, awaited operations rather than a fixed sequence, and never an instant pre-fetch beat. Single-fetch tools report only a start beat and a completion beat (a `total=1.0` scale). A tool with one genuine watershed after a real wait — for example concurrent fetches followed by processing, or the boundary between two sequential requests — reports `start → watershed → completion` on a `total=2.0` scale; a watershed that follows concurrent fetches (`gather`) uses neutral, result-oriented text because it fires whether or not each individual fetch succeeded. Tools with more real awaited steps (additional sequential requests, long-running queries) report a beat for each genuinely observable operation. + - The start beat's message states what is being requested and why it may take time, so clients that surface progress can warn about possible delays even before the fetch begins. + - MCP Server validates the chain against the cached PRO API configuration and builds the PRO API URL (`//...`); this validation happens inside the request helper, after the start beat has already been reported, so an unsupported chain or a missing PRO API key raises only once the start beat (and its paired `info` log) has fired - MCP Server forwards the request to the Blockscout PRO API gateway - For potentially long-running API calls (e.g., advanced transaction filters), MCP Server provides periodic progress updates every 15 seconds (configurable via `BLOCKSCOUT_PROGRESS_INTERVAL_SECONDS`) showing elapsed time and estimated duration - - MCP Server reports progress after fetching data from Blockscout + - MCP Server reports a completion beat after the operation finishes. The start, watershed, and completion beats described here are emitted through the `report_and_log_progress` helper, so each is paired with an `info` log and clients that do not render progress UIs still receive feedback. (The separate periodic-progress mechanism for long-running calls reports its own intermediate and final notifications and is not governed by this beat-count convention.) - Response is processed and formatted before returning to the agent ### Blockscout PRO API Authentication From 7d4c8644b393bdf1282ed0d358bfa3017e5b2280 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 11:56:41 -0600 Subject: [PATCH 08/11] Phase 8: 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 1572e5d..a9cd1d1 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.dev11" +__version__ = "0.16.0.dev12" diff --git a/pyproject.toml b/pyproject.toml index 476423d..580e32c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "blockscout-mcp-server" -version = "0.16.0.dev11" +version = "0.16.0.dev12" description = "MCP server for Blockscout" requires-python = ">=3.11" dependencies = [ diff --git a/server.json b/server.json index 539e01e..4d03c8a 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.dev11", + "version": "0.16.0.dev12", "websiteUrl": "https://blockscout.com", "repository": { "url": "https://github.com/blockscout/mcp-server", From 662e7e26e3a7ebb38656ebd65f51bcb77c9869a7 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 14:50:52 -0600 Subject: [PATCH 09/11] Collapse phantom beats in advanced-filter tools; honest cache message get_transactions_by_address and get_token_transfers_by_address still emitted an instant pre-fetch "Fetching..." beat (phantom since #384 moved URL resolution into the request helper, leaving two report calls back to back). Drop that beat and rescale step counts to the real operations: - transactions: total 12 -> 11, progress_start_step 2 -> 1 (10 pages + final completion); the periodic mechanism's first beat now legitimately occupies the ~1.0 region. - token transfers: total 2 -> 1, current_step_number 2 -> 1. - smart-pagination helper defaults updated to match, with a docstring describing the step model. inspect_contract_code: completion message "Successfully fetched contract data." -> "Contract data ready." so it stays truthful on a cache hit, where no network fetch occurs. SPEC.md: split the dense progress-convention bullet into a readable nested list (no semantic change). Tests updated to the new honest beat counts and step numbers. Co-Authored-By: Claude Opus 4.8 --- SPEC.md | 5 ++- .../tools/contract/inspect_contract_code.py | 2 +- .../tools/transaction/_shared.py | 9 +++-- .../get_token_transfers_by_address.py | 11 ++---- .../get_transactions_by_address.py | 11 ++---- .../contract/test_inspect_contract_code.py | 8 ++--- .../test_get_token_transfers_by_address.py | 17 ++++----- .../test_get_transactions_by_address.py | 9 ++--- ..._get_transactions_by_address_pagination.py | 36 ++++++++----------- 9 files changed, 48 insertions(+), 60 deletions(-) diff --git a/SPEC.md b/SPEC.md index fcb389d..a5b7f38 100644 --- a/SPEC.md +++ b/SPEC.md @@ -179,7 +179,10 @@ This architecture provides the flexibility of a multi-protocol server without th 4. **Blockchain Data Retrieval**: - MCP Host requests blockchain data (e.g., `get_block_number`) with specific chain_id, optionally requesting progress updates - - If progress is requested, MCP Server reports the start beat first — before chain validation — then beats that reflect the number of genuinely distinct, awaited operations rather than a fixed sequence, and never an instant pre-fetch beat. Single-fetch tools report only a start beat and a completion beat (a `total=1.0` scale). A tool with one genuine watershed after a real wait — for example concurrent fetches followed by processing, or the boundary between two sequential requests — reports `start → watershed → completion` on a `total=2.0` scale; a watershed that follows concurrent fetches (`gather`) uses neutral, result-oriented text because it fires whether or not each individual fetch succeeded. Tools with more real awaited steps (additional sequential requests, long-running queries) report a beat for each genuinely observable operation. + - If progress is requested, MCP Server reports a start beat first — before chain validation — and then emits one beat per genuinely distinct, awaited operation rather than a fixed sequence. It never emits an instant pre-fetch beat. Concretely: + - **Single-fetch tools** report only a start beat and a completion beat (a `total=1.0` scale). + - **A tool with one genuine watershed after a real wait** — for example concurrent fetches followed by processing, or the boundary between two sequential requests — reports `start → watershed → completion` on a `total=2.0` scale. A watershed that follows concurrent fetches (`gather`) uses neutral, result-oriented text, because it fires whether or not each individual fetch succeeded. + - **Tools with more real awaited steps** (additional sequential requests, long-running queries) report a beat for each genuinely observable operation. - The start beat's message states what is being requested and why it may take time, so clients that surface progress can warn about possible delays even before the fetch begins. - MCP Server validates the chain against the cached PRO API configuration and builds the PRO API URL (`//...`); this validation happens inside the request helper, after the start beat has already been reported, so an unsupported chain or a missing PRO API key raises only once the start beat (and its paired `info` log) has fired - MCP Server forwards the request to the Blockscout PRO API gateway diff --git a/blockscout_mcp_server/tools/contract/inspect_contract_code.py b/blockscout_mcp_server/tools/contract/inspect_contract_code.py index 4c6231d..b8c4f6b 100644 --- a/blockscout_mcp_server/tools/contract/inspect_contract_code.py +++ b/blockscout_mcp_server/tools/contract/inspect_contract_code.py @@ -47,7 +47,7 @@ async def inspect_contract_code( ctx, progress=1.0, total=1.0, - message="Successfully fetched contract data.", + message="Contract data ready.", ) if file_name is None: metadata = ContractMetadata.model_validate(processed.metadata) diff --git a/blockscout_mcp_server/tools/transaction/_shared.py b/blockscout_mcp_server/tools/transaction/_shared.py index 0779e41..b3b49a3 100644 --- a/blockscout_mcp_server/tools/transaction/_shared.py +++ b/blockscout_mcp_server/tools/transaction/_shared.py @@ -135,13 +135,18 @@ async def _fetch_filtered_transactions_with_smart_pagination( ctx: Context, *, max_pages_to_fetch: int = 10, - progress_start_step: float = 2.0, - total_steps: float = 12.0, + progress_start_step: float = 1.0, + total_steps: float = 11.0, ) -> tuple[list[dict], bool]: """ Fetch and accumulate filtered transaction items across multiple pages until we have enough items. Returns a tuple of (filtered_items, has_more_pages_available). + + Progress model: the first page is ``progress_start_step`` (step 1, right after the + caller's step-0 start beat), each subsequent page advances one step, and the caller + reports the final completion beat at ``total_steps`` (= ``progress_start_step`` + + ``max_pages_to_fetch``) once processing is done. """ accumulated_items = [] current_params = initial_params.copy() diff --git a/blockscout_mcp_server/tools/transaction/get_token_transfers_by_address.py b/blockscout_mcp_server/tools/transaction/get_token_transfers_by_address.py index 25cce1a..a220623 100644 --- a/blockscout_mcp_server/tools/transaction/get_token_transfers_by_address.py +++ b/blockscout_mcp_server/tools/transaction/get_token_transfers_by_address.py @@ -68,7 +68,7 @@ async def get_token_transfers_by_address( apply_cursor_to_params(cursor, query_params) - tool_overall_total_steps = 2.0 + tool_overall_total_steps = 1.0 await report_and_log_progress( ctx, @@ -77,13 +77,6 @@ async def get_token_transfers_by_address( message=f"Starting to fetch token transfers for {address} on chain {chain_id}...", ) - await report_and_log_progress( - ctx, - progress=1.0, - total=tool_overall_total_steps, - message="Fetching token transfers...", - ) - response_data = await make_request_with_periodic_progress( ctx=ctx, request_function=make_blockscout_request, @@ -92,7 +85,7 @@ async def get_token_transfers_by_address( progress_interval_seconds=config.progress_interval_seconds, in_progress_message_template="Query in progress... ({elapsed_seconds:.0f}s / {total_hint:.0f}s hint)", tool_overall_total_steps=tool_overall_total_steps, - current_step_number=2.0, + current_step_number=1.0, current_step_message_prefix="Fetching token transfers", ) diff --git a/blockscout_mcp_server/tools/transaction/get_transactions_by_address.py b/blockscout_mcp_server/tools/transaction/get_transactions_by_address.py index 43ee9d1..480a6f4 100644 --- a/blockscout_mcp_server/tools/transaction/get_transactions_by_address.py +++ b/blockscout_mcp_server/tools/transaction/get_transactions_by_address.py @@ -60,7 +60,7 @@ async def get_transactions_by_address( apply_cursor_to_params(cursor, query_params) - tool_overall_total_steps = 12.0 + tool_overall_total_steps = 11.0 await report_and_log_progress( ctx, @@ -69,20 +69,13 @@ async def get_transactions_by_address( message=f"Starting to fetch transactions for {address} on chain {chain_id}...", ) - await report_and_log_progress( - ctx, - progress=1.0, - total=tool_overall_total_steps, - message="Fetching transactions...", - ) - filtered_items, has_more_pages = await _fetch_filtered_transactions_with_smart_pagination( chain_id=chain_id, api_path=api_path, initial_params=query_params, target_page_size=config.advanced_filters_page_size, ctx=ctx, - progress_start_step=2.0, + progress_start_step=1.0, total_steps=tool_overall_total_steps, ) diff --git a/tests/tools/contract/test_inspect_contract_code.py b/tests/tools/contract/test_inspect_contract_code.py index 03e93e7..151159e 100644 --- a/tests/tools/contract/test_inspect_contract_code.py +++ b/tests/tools/contract/test_inspect_contract_code.py @@ -43,7 +43,7 @@ async def test_inspect_contract_metadata_mode_success(mock_ctx): == "Starting to fetch contract metadata for 0xabc on chain 1..." ) assert mock_ctx.report_progress.await_args_list[0].kwargs["total"] == 1.0 - assert mock_ctx.report_progress.await_args_list[1].kwargs["message"] == "Successfully fetched contract data." + assert mock_ctx.report_progress.await_args_list[1].kwargs["message"] == "Contract data ready." assert mock_ctx.report_progress.await_args_list[1].kwargs["total"] == 1.0 assert isinstance(result, ToolResponse) assert isinstance(result.data, ContractMetadata) @@ -74,7 +74,7 @@ async def test_inspect_contract_file_content_mode_success(mock_ctx): == "Starting to fetch source code for 'A.sol' of contract 0xabc on chain 1..." ) assert mock_ctx.report_progress.await_args_list[0].kwargs["total"] == 1.0 - assert mock_ctx.report_progress.await_args_list[1].kwargs["message"] == "Successfully fetched contract data." + assert mock_ctx.report_progress.await_args_list[1].kwargs["message"] == "Contract data ready." assert mock_ctx.report_progress.await_args_list[1].kwargs["total"] == 1.0 assert isinstance(result.data, ContractSourceFile) assert result.data.file_content == "pragma" @@ -97,7 +97,7 @@ async def test_inspect_contract_file_not_found_raises_error(mock_ctx): == "Starting to fetch source code for 'B.sol' of contract 0xabc on chain 1..." ) assert mock_ctx.report_progress.await_args_list[0].kwargs["total"] == 1.0 - assert mock_ctx.report_progress.await_args_list[1].kwargs["message"] == "Successfully fetched contract data." + assert mock_ctx.report_progress.await_args_list[1].kwargs["message"] == "Contract data ready." assert mock_ctx.report_progress.await_args_list[1].kwargs["total"] == 1.0 assert "Available files: A.sol" in str(exc.value) @@ -150,4 +150,4 @@ async def test_inspect_contract_metadata_mode_truncated_sets_notes(mock_ctx): assert result.notes == ["Constructor arguments were truncated to limit context size."] assert result.instructions is None assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.report_progress.await_args_list[1].kwargs["message"] == "Successfully fetched contract data." + assert mock_ctx.report_progress.await_args_list[1].kwargs["message"] == "Contract data ready." diff --git a/tests/tools/transaction/test_get_token_transfers_by_address.py b/tests/tools/transaction/test_get_token_transfers_by_address.py index 6974de7..adcef7d 100644 --- a/tests/tools/transaction/test_get_token_transfers_by_address.py +++ b/tests/tools/transaction/test_get_token_transfers_by_address.py @@ -62,13 +62,14 @@ async def test_get_token_transfers_by_address_calls_wrapper_correctly(mock_ctx): assert call_kwargs["request_args"] == expected_request_args # Verify other wrapper configuration - assert call_kwargs["tool_overall_total_steps"] == 2.0 - assert call_kwargs["current_step_number"] == 2.0 + assert call_kwargs["tool_overall_total_steps"] == 1.0 + assert call_kwargs["current_step_number"] == 1.0 assert call_kwargs["current_step_message_prefix"] == "Fetching token transfers" - # Verify progress was reported correctly before the wrapper call - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.info.await_count == 2 + # Only the start beat is reported before the wrapper call; + # the wrapper owns the in-progress/completion beats for the single real fetch. + assert mock_ctx.report_progress.await_count == 1 + assert mock_ctx.info.await_count == 1 # Verify timing hints are passed through from config assert call_kwargs["total_duration_hint"] == config.bs_timeout @@ -110,9 +111,9 @@ async def test_get_token_transfers_by_address_chain_error(mock_ctx): # Verify the wrapper was called and raised the error mock_wrapper.assert_called_once() - # Progress should have been reported twice (start + fetching step) before the error - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.info.await_count == 2 + # Only the start beat is reported before the wrapper raises during chain resolution + assert mock_ctx.report_progress.await_count == 1 + assert mock_ctx.info.await_count == 1 @pytest.mark.asyncio diff --git a/tests/tools/transaction/test_get_transactions_by_address.py b/tests/tools/transaction/test_get_transactions_by_address.py index 0f088ce..8937c96 100644 --- a/tests/tools/transaction/test_get_transactions_by_address.py +++ b/tests/tools/transaction/test_get_transactions_by_address.py @@ -55,8 +55,8 @@ async def test_get_transactions_by_address_calls_smart_pagination_correctly(mock assert call_kwargs["chain_id"] == chain_id assert call_kwargs["api_path"] == "/api/v2/advanced-filters" assert call_kwargs["ctx"] == mock_ctx - assert call_kwargs["progress_start_step"] == 2.0 - assert call_kwargs["total_steps"] == 12.0 + assert call_kwargs["progress_start_step"] == 1.0 + assert call_kwargs["total_steps"] == 11.0 # Check the initial_params that should be passed to the smart pagination function expected_initial_params = { @@ -68,8 +68,9 @@ async def test_get_transactions_by_address_calls_smart_pagination_correctly(mock } assert call_kwargs["initial_params"] == expected_initial_params - # Verify progress was reported correctly before the smart pagination call - assert mock_ctx.report_progress.call_count == 3 # Start + after URL resolution + completion + # Verify progress was reported correctly around the smart pagination call. + # Start + completion only; per-page beats are emitted inside the (mocked) helper. + assert mock_ctx.report_progress.call_count == 2 @pytest.mark.asyncio diff --git a/tests/tools/transaction/test_get_transactions_by_address_pagination.py b/tests/tools/transaction/test_get_transactions_by_address_pagination.py index 7d9fb4c..c7df40d 100644 --- a/tests/tools/transaction/test_get_transactions_by_address_pagination.py +++ b/tests/tools/transaction/test_get_transactions_by_address_pagination.py @@ -282,9 +282,8 @@ async def test_get_transactions_by_address_multi_page_progress_reporting(mock_ct This test verifies that the enhanced progress reporting system correctly tracks and reports progress through all phases of the multi-page smart pagination: 1. Initial operation start (step 0) - 2. URL resolution (step 1) - 3. Multi-page fetching (steps 2-11, handled by smart pagination) - 4. Final completion (step 12) + 2. Multi-page fetching (steps 1-10, handled by smart pagination) + 3. Final completion (step 11) """ # ARRANGE chain_id = "1" @@ -327,11 +326,11 @@ async def test_get_transactions_by_address_multi_page_progress_reporting(mock_ct # Verify progress reporting was called correctly progress_calls = mock_progress.call_args_list - # Should have exactly 3 progress reports from get_transactions_by_address: - # 1. Initial start (progress=0.0, total=12.0) - # 2. Fetching step (progress=1.0, total=12.0) - # 3. Final completion (progress=12.0, total=12.0) - assert len(progress_calls) == 3 + # Should have exactly 2 progress reports from get_transactions_by_address: + # 1. Initial start (progress=0.0, total=11.0) + # 2. Final completion (progress=11.0, total=11.0) + # (Per-page beats for steps 1-10 are emitted inside the mocked smart-pagination helper.) + assert len(progress_calls) == 2 # Each call structure: call(ctx, progress=X, total=Y, message=Z) # args are in call_args_list[i][0] tuple (just ctx) @@ -341,27 +340,20 @@ async def test_get_transactions_by_address_multi_page_progress_reporting(mock_ct initial_call_args, initial_call_kwargs = progress_calls[0] assert initial_call_args[0] == mock_ctx # ctx assert initial_call_kwargs["progress"] == 0.0 - assert initial_call_kwargs["total"] == 12.0 + assert initial_call_kwargs["total"] == 11.0 assert "Starting to fetch transactions" in initial_call_kwargs["message"] assert address in initial_call_kwargs["message"] assert chain_id in initial_call_kwargs["message"] - # Verify fetching progress (step 1) - fetching_call_args, fetching_call_kwargs = progress_calls[1] - assert fetching_call_args[0] == mock_ctx # ctx - assert fetching_call_kwargs["progress"] == 1.0 - assert fetching_call_kwargs["total"] == 12.0 - assert "Fetching transactions" in fetching_call_kwargs["message"] - - # Verify final completion progress (step 12) - completion_call_args, completion_call_kwargs = progress_calls[2] + # Verify final completion progress (step 11) + completion_call_args, completion_call_kwargs = progress_calls[1] assert completion_call_args[0] == mock_ctx # ctx - assert completion_call_kwargs["progress"] == 12.0 - assert completion_call_kwargs["total"] == 12.0 + assert completion_call_kwargs["progress"] == 11.0 + assert completion_call_kwargs["total"] == 11.0 assert "Successfully fetched transaction data" in completion_call_kwargs["message"] # Verify smart pagination was called with correct progress parameters smart_pagination_call_args = mock_smart_pagination.call_args[1] - assert smart_pagination_call_args["progress_start_step"] == 2.0 - assert smart_pagination_call_args["total_steps"] == 12.0 + assert smart_pagination_call_args["progress_start_step"] == 1.0 + assert smart_pagination_call_args["total_steps"] == 11.0 assert smart_pagination_call_args["ctx"] == mock_ctx From 232ad4209bc75a81acc0880deb46bc4ab0e22c74 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 16:26:17 -0600 Subject: [PATCH 10/11] Add watershed beat to get_transaction_info for SPEC consistency get_transaction_info does concurrent fetches (gather) followed by processing, which the SPEC's progress convention describes as a start -> watershed -> completion (total=2.0) flow. It was reported as a single-fetch (total=1.0) tool, diverging from get_block_info which uses the watershed model for the same shape. Convert it to total=2.0: - introduce total_steps (no hardcoded literals) - reposition the post-gather beat as a neutral watershed ("... requests completed; processing results.") so it stays truthful when the optional user-operations request fails - add the completion beat before building TransactionInfoData Tests: success paths now expect 3 beats; failure paths that raise before the watershed stay at 1; ops-failure path asserts the neutral watershed message and the completion message. Co-Authored-By: Claude Opus 4.8 --- .../tools/transaction/get_transaction_info.py | 22 +++++++++- .../transaction/test_get_transaction_info.py | 42 ++++++++++++------- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/blockscout_mcp_server/tools/transaction/get_transaction_info.py b/blockscout_mcp_server/tools/transaction/get_transaction_info.py index 9c787ed..7596390 100644 --- a/blockscout_mcp_server/tools/transaction/get_transaction_info.py +++ b/blockscout_mcp_server/tools/transaction/get_transaction_info.py @@ -37,10 +37,14 @@ async def get_transaction_info( """ # noqa: E501 api_path = f"/api/v2/transactions/{transaction_hash}" + # Two genuine awaited operations: the concurrent fetch (gather) and the + # processing that follows it. Reported as start -> watershed -> completion. + total_steps = 2.0 + await report_and_log_progress( ctx, progress=0.0, - total=1.0, + total=total_steps, message=f"Starting to fetch transaction info for {transaction_hash} on chain {chain_id}...", ) @@ -65,7 +69,14 @@ async def get_transaction_info( if isinstance(ops_result, Exception): ops_error_note = f"Could not retrieve user operations. The 'user_operations' field is null. Error: {ops_result}" - await report_and_log_progress(ctx, progress=1.0, total=1.0, message="Successfully fetched transaction data.") + # Watershed: the concurrent requests have returned. Neutral, result-oriented + # wording stays truthful even when the optional user-operations request failed. + await report_and_log_progress( + ctx, + progress=1.0, + total=total_steps, + message="Transaction and user operations requests completed; processing results.", + ) processed_data, was_truncated = _process_and_truncate_tx_info_data(response_data, include_raw_input) @@ -74,6 +85,13 @@ async def get_transaction_info( user_operations = _transform_user_ops(raw_ops_response) final_data_dict["user_operations"] = user_operations + await report_and_log_progress( + ctx, + progress=2.0, + total=total_steps, + message="Successfully fetched all transaction data.", + ) + transaction_data = TransactionInfoData(**final_data_dict) notes = None diff --git a/tests/tools/transaction/test_get_transaction_info.py b/tests/tools/transaction/test_get_transaction_info.py index f8ac0d6..66d8c81 100644 --- a/tests/tools/transaction/test_get_transaction_info.py +++ b/tests/tools/transaction/test_get_transaction_info.py @@ -82,8 +82,8 @@ async def test_get_transaction_info_success(mock_ctx): data = result.data.model_dump(by_alias=True) for key, value in expected_transformed_result.items(): assert data[key] == value - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.info.await_count == 2 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 assert result.instructions is not None assert all("/api/v2/proxy/account-abstraction/operations" not in instr for instr in result.instructions) @@ -124,8 +124,8 @@ async def test_get_transaction_info_with_user_ops(mock_ctx): ), ] ) - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.info.await_count == 2 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 assert any( "Starting to fetch transaction info" in call.kwargs.get("message", "") for call in mock_ctx.report_progress.await_args_list @@ -200,8 +200,8 @@ async def test_get_transaction_info_no_user_ops(mock_ctx): ), ] ) - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.info.await_count == 2 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 assert any( "Starting to fetch transaction info" in call.kwargs.get("message", "") for call in mock_ctx.report_progress.await_args_list @@ -241,12 +241,22 @@ async def test_get_transaction_info_ops_api_failure(mock_ctx): ), ] ) - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.info.await_count == 2 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 assert any( "Starting to fetch transaction info" in call.kwargs.get("message", "") for call in mock_ctx.report_progress.await_args_list ) + # Watershed beat (index 1) must stay neutral and honest even though the ops request failed, + # and the completion beat (index 2) reports overall success on the total=2.0 scale. + calls = mock_ctx.report_progress.await_args_list + assert calls[0].kwargs["total"] == 2.0 + assert calls[1].kwargs["progress"] == 1.0 + assert calls[1].kwargs["total"] == 2.0 + assert calls[1].kwargs["message"] == "Transaction and user operations requests completed; processing results." + assert calls[2].kwargs["progress"] == 2.0 + assert calls[2].kwargs["total"] == 2.0 + assert calls[2].kwargs["message"] == "Successfully fetched all transaction data." 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) @@ -288,8 +298,8 @@ async def test_get_transaction_info_pagination_note(mock_ctx): ), ] ) - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.info.await_count == 2 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 assert any( "Starting to fetch transaction info" in call.kwargs.get("message", "") for call in mock_ctx.report_progress.await_args_list @@ -537,8 +547,8 @@ async def test_get_transaction_info_minimal_response(mock_ctx): data = result.data.model_dump(by_alias=True) for key, value in expected_result.items(): assert data[key] == value - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.info.await_count == 2 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 @pytest.mark.asyncio @@ -587,8 +597,8 @@ async def test_get_transaction_info_with_token_transfers_transformation(mock_ctx assert result.data.to_address == "0x3328..." assert isinstance(result.data.token_transfers[0], TokenTransfer) assert result.data.token_transfers[0].transfer_type == "token_minting" - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.info.await_count == 2 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 @pytest.mark.asyncio @@ -633,5 +643,5 @@ async def test_get_transaction_info_handles_null_token_transfer_metadata(mock_ct assert isinstance(result.data, TransactionInfoData) assert isinstance(result.data.token_transfers[0], TokenTransfer) assert result.data.token_transfers[0].token is None - assert mock_ctx.report_progress.await_count == 2 - assert mock_ctx.info.await_count == 2 + assert mock_ctx.report_progress.await_count == 3 + assert mock_ctx.info.await_count == 3 From 810afefdf0fef81ccb709094576efe3f4d33564d Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 17:34:36 -0600 Subject: [PATCH 11/11] Bump version to 0.16.0.dev13 main already shipped 0.16.0.dev12 via #389 after this branch diverged, so re-bump to dev13 to keep the dev version monotonic on merge. 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 a9cd1d1..a31f2e1 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.dev12" +__version__ = "0.16.0.dev13" diff --git a/pyproject.toml b/pyproject.toml index 580e32c..0b4d200 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "blockscout-mcp-server" -version = "0.16.0.dev12" +version = "0.16.0.dev13" description = "MCP server for Blockscout" requires-python = ">=3.11" dependencies = [ diff --git a/server.json b/server.json index 4d03c8a..9edab3d 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.dev12", + "version": "0.16.0.dev13", "websiteUrl": "https://blockscout.com", "repository": { "url": "https://github.com/blockscout/mcp-server",