From b3a81a2b053a049a7d6a2ef552574ef37215592d Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Tue, 2 Jun 2026 19:11:24 -0600 Subject: [PATCH 1/8] Phase 1: Reject the legacy JSON-RPC path Co-Authored-By: Claude Opus 4.8 --- .../tools/direct_api/direct_api_call.py | 4 + .../test_direct_api_call_validation.py | 84 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/tools/direct_api/test_direct_api_call_validation.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..9aedfb3 100644 --- a/blockscout_mcp_server/tools/direct_api/direct_api_call.py +++ b/blockscout_mcp_server/tools/direct_api/direct_api_call.py @@ -83,6 +83,10 @@ async def direct_api_call( if endpoint_path != "/" and endpoint_path.endswith("/"): endpoint_path = endpoint_path.rstrip("/") + if endpoint_path.lower() == "/api/eth-rpc": + raise ValueError( + "The legacy JSON-RPC path '/api/eth-rpc' is no longer supported. Retry with endpoint_path='/json-rpc'." + ) if "?" in endpoint_path: raise ValueError("Do not include query parameters in endpoint_path. Use query_params instead.") diff --git a/tests/tools/direct_api/test_direct_api_call_validation.py b/tests/tools/direct_api/test_direct_api_call_validation.py new file mode 100644 index 0000000..df59463 --- /dev/null +++ b/tests/tools/direct_api/test_direct_api_call_validation.py @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +from unittest.mock import AsyncMock, patch + +import pytest + +import blockscout_mcp_server.tools.direct_api.direct_api_call as direct_api_call_module + + +@pytest.mark.asyncio +async def test_direct_api_call_rejects_legacy_eth_rpc_path_get(mock_ctx): + """GET request to /api/eth-rpc raises ValueError before any network call.""" + with ( + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_get, + ): + with pytest.raises(ValueError, match="/json-rpc"): + await direct_api_call_module.direct_api_call( + chain_id="1", + endpoint_path="/api/eth-rpc", + ctx=mock_ctx, + ) + mock_get.assert_not_awaited() + assert mock_ctx.report_progress.await_count == 1 + + +@pytest.mark.asyncio +async def test_direct_api_call_rejects_legacy_eth_rpc_path_trailing_slash(mock_ctx): + """GET request to /api/eth-rpc/ (trailing slash) raises ValueError before any network call.""" + with ( + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_get, + ): + with pytest.raises(ValueError, match="/json-rpc"): + await direct_api_call_module.direct_api_call( + chain_id="1", + endpoint_path="/api/eth-rpc/", + ctx=mock_ctx, + ) + mock_get.assert_not_awaited() + assert mock_ctx.report_progress.await_count == 1 + + +@pytest.mark.asyncio +async def test_direct_api_call_rejects_legacy_eth_rpc_path_post(mock_ctx): + """POST request to /api/eth-rpc raises ValueError before any network call.""" + with ( + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_post_request", + new_callable=AsyncMock, + ) as mock_post, + ): + with pytest.raises(ValueError, match="/json-rpc"): + await direct_api_call_module.direct_api_call( + chain_id="1", + endpoint_path="/api/eth-rpc", + method="POST", + json_body={"id": 1}, + ctx=mock_ctx, + ) + mock_post.assert_not_awaited() + assert mock_ctx.report_progress.await_count == 1 + + +@pytest.mark.asyncio +async def test_direct_api_call_rejects_legacy_eth_rpc_path_case_insensitive(mock_ctx): + """Case-insensitive variant /API/ETH-RPC raises ValueError before any network call.""" + with ( + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_get, + ): + with pytest.raises(ValueError, match="/json-rpc"): + await direct_api_call_module.direct_api_call( + chain_id="1", + endpoint_path="/API/ETH-RPC", + ctx=mock_ctx, + ) + mock_get.assert_not_awaited() + assert mock_ctx.report_progress.await_count == 1 From 492a4f99456221351ff6aa3aa16376e04b46b28a Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Tue, 2 Jun 2026 19:13:53 -0600 Subject: [PATCH 2/8] Phase 2: Documentation updates (SPEC.md and AGENTS.md index) Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 3 ++- SPEC.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fccb520..fefc645 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,7 +172,8 @@ mcp-server/ │ │ │ ├── test_transaction_summary_handler.py # Unit tests for transaction summary handler │ │ │ └── test_user_operation_handler.py # Unit tests for user operation handler │ │ ├── test_dispatcher.py # Unit tests for direct API dispatcher -│ │ └── test_direct_api_call.py # Unit tests for direct_api_call +│ │ ├── test_direct_api_call.py # Unit tests for direct_api_call +│ │ └── test_direct_api_call_validation.py # Unit tests for direct_api_call input validation │ ├── ens/ # Tests for ENS-related MCP tools │ │ └── test_get_address_by_ens_name.py # Unit tests for get_address_by_ens_name │ ├── initialization/ # Tests for initialization MCP tools diff --git a/SPEC.md b/SPEC.md index 19946c1..efa5d49 100644 --- a/SPEC.md +++ b/SPEC.md @@ -509,7 +509,7 @@ Credit-exhaustion and rate-limit responses from the PRO API are currently not sp 4. **Output Conciseness**: Endpoints that return excessively large or complex raw data payloads are generally excluded from the curated list, preventing LLM context overflow and maintaining the server's overall context optimization strategy. - **Implementation**: The tool functions as a thin wrapper around the core HTTP request helpers. It accepts a `chain_id`, the full `endpoint_path`, optional `query_params`, an optional `cursor` for pagination, an optional `method` (`"GET"` or `"POST"`, defaulting to `"GET"`), and an optional `json_body` (dict) for POST requests. For GET requests, behavior is unchanged: pagination is supported via opaque cursors that encode raw `next_page_params` from the Blockscout API. For POST requests (e.g., JSON-RPC calls to `/json-rpc`), the `json_body` is sent as the request body; pagination is not supported for POST responses. The tool enforces strict parameter validation: `json_body` is only allowed with `method="POST"`, `method="POST"` requires a non-null `json_body`, `json_body` must be a dict (not a scalar or list), and `cursor` is rejected for POST requests. The POST request helper uses a strictly conservative retry policy — retrying only on connection-level failures (`ConnectError`, `ConnectTimeout`) where the request provably never reached the server, since POST requests are not idempotent. The tool leverages the existing `ToolResponse` model for consistent output and integrates with the server's robust HTTP request handling and error propagation mechanisms. To ensure safety, the tool enforces a configurable response size limit (controlled by `BLOCKSCOUT_DIRECT_API_RESPONSE_SIZE_LIMIT`). In REST mode, this limit can be bypassed by setting the `X-Blockscout-Allow-Large-Response: true` header, allowing scripts to retrieve full datasets while protecting AI agents from context overflow. + **Implementation**: The tool functions as a thin wrapper around the core HTTP request helpers. It accepts a `chain_id`, the full `endpoint_path`, optional `query_params`, an optional `cursor` for pagination, an optional `method` (`"GET"` or `"POST"`, defaulting to `"GET"`), and an optional `json_body` (dict) for POST requests. For GET requests, behavior is unchanged: pagination is supported via opaque cursors that encode raw `next_page_params` from the Blockscout API. For POST requests (e.g., JSON-RPC calls to `/json-rpc`), the `json_body` is sent as the request body; pagination is not supported for POST responses. The tool enforces strict parameter validation: `json_body` is only allowed with `method="POST"`, `method="POST"` requires a non-null `json_body`, `json_body` must be a dict (not a scalar or list), and `cursor` is rejected for POST requests. As part of the same pre-network validation, the tool also rejects the legacy JSON-RPC path: because the JSON-RPC entry point moved from the historical `/api/eth-rpc` path to `/json-rpc` during the PRO API migration, a request that still targets `/api/eth-rpc` is refused before any network call with a corrective error that names the supported `/json-rpc` path. The path is never silently rewritten — the rejection is a transparent, just-in-time signal that steers the caller onto the supported convention without spending an authenticated API credit on a request known to fail. The POST request helper uses a strictly conservative retry policy — retrying only on connection-level failures (`ConnectError`, `ConnectTimeout`) where the request provably never reached the server, since POST requests are not idempotent. The tool leverages the existing `ToolResponse` model for consistent output and integrates with the server's robust HTTP request handling and error propagation mechanisms. To ensure safety, the tool enforces a configurable response size limit (controlled by `BLOCKSCOUT_DIRECT_API_RESPONSE_SIZE_LIMIT`). In REST mode, this limit can be bypassed by setting the `X-Blockscout-Allow-Large-Response: true` header, allowing scripts to retrieve full datasets while protecting AI agents from context overflow. **Specialized Response Handling via Dispatcher** From 18a06a08763d9ee5afc213aeeba947dab7dc9973 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Tue, 2 Jun 2026 19:15:35 -0600 Subject: [PATCH 3/8] Phase 3: 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 4e9b0ed308924e03b213e55970c5d02f595bef21 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 10:49:01 -0600 Subject: [PATCH 4/8] Harden legacy /api/eth-rpc rejection against query/whitespace variants Normalize endpoint_path (strip query string, whitespace, trailing slash, case) before comparing to the legacy path, so the corrective /json-rpc message wins over the generic query-param error and surrounding-whitespace variants are caught too. Add tests for the query-string and whitespace variants, plus negative tests confirming a lookalike (/api/eth-rpc-foo) and the supported /json-rpc path are not rejected. Co-Authored-By: Claude Opus 4.8 --- .../tools/direct_api/direct_api_call.py | 6 +- .../test_direct_api_call_validation.py | 76 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) 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 9aedfb3..83870b0 100644 --- a/blockscout_mcp_server/tools/direct_api/direct_api_call.py +++ b/blockscout_mcp_server/tools/direct_api/direct_api_call.py @@ -83,7 +83,11 @@ async def direct_api_call( if endpoint_path != "/" and endpoint_path.endswith("/"): endpoint_path = endpoint_path.rstrip("/") - if endpoint_path.lower() == "/api/eth-rpc": + # Reject the legacy JSON-RPC path before any network call, and before the generic + # query-param check below so the corrective message wins. Normalizing away surrounding + # whitespace, a trailing slash, and any query string maps variants like "/api/eth-rpc/", + # "/API/ETH-RPC", "/api/eth-rpc?id=1", and "/api/eth-rpc " onto the same rejection. + if endpoint_path.split("?", 1)[0].strip().rstrip("/").lower() == "/api/eth-rpc": raise ValueError( "The legacy JSON-RPC path '/api/eth-rpc' is no longer supported. Retry with endpoint_path='/json-rpc'." ) diff --git a/tests/tools/direct_api/test_direct_api_call_validation.py b/tests/tools/direct_api/test_direct_api_call_validation.py index df59463..e00fd2e 100644 --- a/tests/tools/direct_api/test_direct_api_call_validation.py +++ b/tests/tools/direct_api/test_direct_api_call_validation.py @@ -82,3 +82,79 @@ async def test_direct_api_call_rejects_legacy_eth_rpc_path_case_insensitive(mock ) mock_get.assert_not_awaited() assert mock_ctx.report_progress.await_count == 1 + + +@pytest.mark.asyncio +async def test_direct_api_call_rejects_legacy_eth_rpc_path_with_query_string(mock_ctx): + """A trailing query string still yields the legacy-path error, not the generic query-param one.""" + with ( + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_get, + ): + with pytest.raises(ValueError, match="no longer supported"): + await direct_api_call_module.direct_api_call( + chain_id="1", + endpoint_path="/api/eth-rpc?id=1", + ctx=mock_ctx, + ) + mock_get.assert_not_awaited() + assert mock_ctx.report_progress.await_count == 1 + + +@pytest.mark.asyncio +async def test_direct_api_call_rejects_legacy_eth_rpc_path_surrounding_whitespace(mock_ctx): + """Surrounding whitespace around the legacy path raises ValueError before any network call.""" + with ( + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_get, + ): + with pytest.raises(ValueError, match="/json-rpc"): + await direct_api_call_module.direct_api_call( + chain_id="1", + endpoint_path=" /api/eth-rpc ", + ctx=mock_ctx, + ) + mock_get.assert_not_awaited() + assert mock_ctx.report_progress.await_count == 1 + + +@pytest.mark.asyncio +async def test_direct_api_call_allows_non_legacy_lookalike_path(mock_ctx): + """A path that merely contains 'eth-rpc' as a substring is not rejected and reaches the network.""" + with ( + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_get, + ): + mock_get.return_value = {"result": "ok"} + await direct_api_call_module.direct_api_call( + chain_id="1", + endpoint_path="/api/eth-rpc-foo", + ctx=mock_ctx, + ) + mock_get.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_direct_api_call_allows_supported_json_rpc_path(mock_ctx): + """The supported /json-rpc path is not rejected and reaches the network.""" + 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", "id": 1, "result": "0x1"} + await direct_api_call_module.direct_api_call( + chain_id="1", + endpoint_path="/json-rpc", + method="POST", + json_body={"jsonrpc": "2.0", "method": "eth_blockNumber", "id": 1}, + ctx=mock_ctx, + ) + mock_post.assert_awaited_once() From 34d1783993c98db33cda56d69f81de9da0c3315b Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 10:59:30 -0600 Subject: [PATCH 5/8] Normalize endpoint whitespace and decouple rejection tests from progress count - Strip surrounding whitespace from endpoint_path once up front so supported paths are normalized the same way as the legacy-path guard (previously only the rejection comparison trimmed whitespace, leaving " /json-rpc " to fail opaquely upstream). - Drop the now-redundant .strip() from the guard comparison; the remaining .rstrip("/") still handles the slash-before-query case (/api/eth-rpc/?id=1). - Remove the report_progress.await_count assertions from the rejection tests; assert_not_awaited() already proves the rejection is pre-network, and the count coupled the tests to the internal progress sequence. - Add a test asserting whitespace on a supported path is stripped before the network call. Co-Authored-By: Claude Opus 4.8 --- .../tools/direct_api/direct_api_call.py | 10 +++++--- .../test_direct_api_call_validation.py | 25 ++++++++++++++----- 2 files changed, 25 insertions(+), 10 deletions(-) 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 83870b0..99263ba 100644 --- a/blockscout_mcp_server/tools/direct_api/direct_api_call.py +++ b/blockscout_mcp_server/tools/direct_api/direct_api_call.py @@ -81,13 +81,15 @@ async def direct_api_call( message=f"Preparing request for chain {chain_id}...", ) + endpoint_path = endpoint_path.strip() if endpoint_path != "/" and endpoint_path.endswith("/"): endpoint_path = endpoint_path.rstrip("/") # Reject the legacy JSON-RPC path before any network call, and before the generic - # query-param check below so the corrective message wins. Normalizing away surrounding - # whitespace, a trailing slash, and any query string maps variants like "/api/eth-rpc/", - # "/API/ETH-RPC", "/api/eth-rpc?id=1", and "/api/eth-rpc " onto the same rejection. - if endpoint_path.split("?", 1)[0].strip().rstrip("/").lower() == "/api/eth-rpc": + # query-param check below so the corrective message wins. Surrounding whitespace is + # already stripped above; splitting off any query string, dropping a trailing slash, + # and lowercasing maps variants like "/api/eth-rpc/", "/API/ETH-RPC", and + # "/api/eth-rpc?id=1" onto the same rejection. + if endpoint_path.split("?", 1)[0].rstrip("/").lower() == "/api/eth-rpc": raise ValueError( "The legacy JSON-RPC path '/api/eth-rpc' is no longer supported. Retry with endpoint_path='/json-rpc'." ) diff --git a/tests/tools/direct_api/test_direct_api_call_validation.py b/tests/tools/direct_api/test_direct_api_call_validation.py index e00fd2e..5c632a4 100644 --- a/tests/tools/direct_api/test_direct_api_call_validation.py +++ b/tests/tools/direct_api/test_direct_api_call_validation.py @@ -22,7 +22,6 @@ async def test_direct_api_call_rejects_legacy_eth_rpc_path_get(mock_ctx): ctx=mock_ctx, ) mock_get.assert_not_awaited() - assert mock_ctx.report_progress.await_count == 1 @pytest.mark.asyncio @@ -41,7 +40,6 @@ async def test_direct_api_call_rejects_legacy_eth_rpc_path_trailing_slash(mock_c ctx=mock_ctx, ) mock_get.assert_not_awaited() - assert mock_ctx.report_progress.await_count == 1 @pytest.mark.asyncio @@ -62,7 +60,6 @@ async def test_direct_api_call_rejects_legacy_eth_rpc_path_post(mock_ctx): ctx=mock_ctx, ) mock_post.assert_not_awaited() - assert mock_ctx.report_progress.await_count == 1 @pytest.mark.asyncio @@ -81,7 +78,6 @@ async def test_direct_api_call_rejects_legacy_eth_rpc_path_case_insensitive(mock ctx=mock_ctx, ) mock_get.assert_not_awaited() - assert mock_ctx.report_progress.await_count == 1 @pytest.mark.asyncio @@ -100,7 +96,6 @@ async def test_direct_api_call_rejects_legacy_eth_rpc_path_with_query_string(moc ctx=mock_ctx, ) mock_get.assert_not_awaited() - assert mock_ctx.report_progress.await_count == 1 @pytest.mark.asyncio @@ -119,7 +114,6 @@ async def test_direct_api_call_rejects_legacy_eth_rpc_path_surrounding_whitespac ctx=mock_ctx, ) mock_get.assert_not_awaited() - assert mock_ctx.report_progress.await_count == 1 @pytest.mark.asyncio @@ -158,3 +152,22 @@ async def test_direct_api_call_allows_supported_json_rpc_path(mock_ctx): ctx=mock_ctx, ) mock_post.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_direct_api_call_strips_whitespace_around_supported_path(mock_ctx): + """Surrounding whitespace on a supported path is normalized before the network call.""" + with ( + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_get, + ): + mock_get.return_value = {"result": "ok"} + await direct_api_call_module.direct_api_call( + chain_id="1", + endpoint_path=" /api/v2/stats ", + ctx=mock_ctx, + ) + mock_get.assert_awaited_once() + assert mock_get.await_args.kwargs["api_path"] == "/api/v2/stats" From e385f574f9cd200876914140fa3cc0dbfc150e62 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 11:11:16 -0600 Subject: [PATCH 6/8] Harden legacy-path matcher for whitespace before query string Strip whitespace from the pre-query segment so "/api/eth-rpc ?id=1" folds onto the same rejection instead of falling through to the generic query-param error. Add two rejection tests: trailing-slash + query string, and whitespace before the query string. Co-Authored-By: Claude Opus 4.8 --- .../tools/direct_api/direct_api_call.py | 10 +++--- .../test_direct_api_call_validation.py | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) 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 99263ba..e60cd0a 100644 --- a/blockscout_mcp_server/tools/direct_api/direct_api_call.py +++ b/blockscout_mcp_server/tools/direct_api/direct_api_call.py @@ -85,11 +85,11 @@ async def direct_api_call( if endpoint_path != "/" and endpoint_path.endswith("/"): endpoint_path = endpoint_path.rstrip("/") # Reject the legacy JSON-RPC path before any network call, and before the generic - # query-param check below so the corrective message wins. Surrounding whitespace is - # already stripped above; splitting off any query string, dropping a trailing slash, - # and lowercasing maps variants like "/api/eth-rpc/", "/API/ETH-RPC", and - # "/api/eth-rpc?id=1" onto the same rejection. - if endpoint_path.split("?", 1)[0].rstrip("/").lower() == "/api/eth-rpc": + # query-param check below so the corrective message wins. Splitting off any query + # string, stripping whitespace that precedes it, dropping a trailing slash, and + # lowercasing folds variants like "/api/eth-rpc/", "/API/ETH-RPC", "/api/eth-rpc?id=1", + # and "/api/eth-rpc ?id=1" onto the same rejection. + if endpoint_path.split("?", 1)[0].strip().rstrip("/").lower() == "/api/eth-rpc": raise ValueError( "The legacy JSON-RPC path '/api/eth-rpc' is no longer supported. Retry with endpoint_path='/json-rpc'." ) diff --git a/tests/tools/direct_api/test_direct_api_call_validation.py b/tests/tools/direct_api/test_direct_api_call_validation.py index 5c632a4..7ed03f3 100644 --- a/tests/tools/direct_api/test_direct_api_call_validation.py +++ b/tests/tools/direct_api/test_direct_api_call_validation.py @@ -116,6 +116,42 @@ async def test_direct_api_call_rejects_legacy_eth_rpc_path_surrounding_whitespac mock_get.assert_not_awaited() +@pytest.mark.asyncio +async def test_direct_api_call_rejects_legacy_eth_rpc_path_trailing_slash_with_query_string(mock_ctx): + """A trailing slash combined with a query string still yields the legacy-path error.""" + with ( + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_get, + ): + with pytest.raises(ValueError, match="no longer supported"): + await direct_api_call_module.direct_api_call( + chain_id="1", + endpoint_path="/api/eth-rpc/?id=1", + ctx=mock_ctx, + ) + mock_get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_direct_api_call_rejects_legacy_eth_rpc_path_whitespace_before_query_string(mock_ctx): + """Whitespace between the legacy path and its query string still yields the legacy-path error.""" + with ( + patch( + "blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_get, + ): + with pytest.raises(ValueError, match="no longer supported"): + await direct_api_call_module.direct_api_call( + chain_id="1", + endpoint_path="/api/eth-rpc ?id=1", + ctx=mock_ctx, + ) + mock_get.assert_not_awaited() + + @pytest.mark.asyncio async def test_direct_api_call_allows_non_legacy_lookalike_path(mock_ctx): """A path that merely contains 'eth-rpc' as a substring is not rejected and reaches the network.""" From 1916a6a5521f686c3cc12db76e784e9897c75f58 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 16:37:09 -0600 Subject: [PATCH 7/8] test: assert exact helper call args in direct_api_call pass-through tests Tighten the three pass-through tests from assert_awaited_once() to assert_awaited_once_with(...) so they lock down the exact api_path/params (and json_body for POST) contract passed to the network helper. This guards the look-alike path against silent rewrites and pins normalization behavior. Co-Authored-By: Claude Opus 4.8 --- .../direct_api/test_direct_api_call_validation.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/tools/direct_api/test_direct_api_call_validation.py b/tests/tools/direct_api/test_direct_api_call_validation.py index 7ed03f3..4764d43 100644 --- a/tests/tools/direct_api/test_direct_api_call_validation.py +++ b/tests/tools/direct_api/test_direct_api_call_validation.py @@ -167,7 +167,7 @@ async def test_direct_api_call_allows_non_legacy_lookalike_path(mock_ctx): endpoint_path="/api/eth-rpc-foo", ctx=mock_ctx, ) - mock_get.assert_awaited_once() + mock_get.assert_awaited_once_with(chain_id="1", api_path="/api/eth-rpc-foo", params={}) @pytest.mark.asyncio @@ -187,7 +187,12 @@ async def test_direct_api_call_allows_supported_json_rpc_path(mock_ctx): json_body={"jsonrpc": "2.0", "method": "eth_blockNumber", "id": 1}, ctx=mock_ctx, ) - mock_post.assert_awaited_once() + mock_post.assert_awaited_once_with( + chain_id="1", + api_path="/json-rpc", + json_body={"jsonrpc": "2.0", "method": "eth_blockNumber", "id": 1}, + params={}, + ) @pytest.mark.asyncio @@ -205,5 +210,4 @@ async def test_direct_api_call_strips_whitespace_around_supported_path(mock_ctx) endpoint_path=" /api/v2/stats ", ctx=mock_ctx, ) - mock_get.assert_awaited_once() - assert mock_get.await_args.kwargs["api_path"] == "/api/v2/stats" + mock_get.assert_awaited_once_with(chain_id="1", api_path="/api/v2/stats", params={}) From d902bd59acd7138a4b3747eec44257d030f5a468 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 3 Jun 2026 17:17:21 -0600 Subject: [PATCH 8/8] docs: tighten legacy /api/eth-rpc rejection note in SPEC.md Condense the two-sentence description to a single clause that keeps the behavioral contract (pre-network rejection, error names /json-rpc, never silently rewritten) and drops the PR-rationale editorializing, matching the density of the surrounding Implementation paragraph. Co-Authored-By: Claude Opus 4.8 --- SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index efa5d49..0b82126 100644 --- a/SPEC.md +++ b/SPEC.md @@ -509,7 +509,7 @@ Credit-exhaustion and rate-limit responses from the PRO API are currently not sp 4. **Output Conciseness**: Endpoints that return excessively large or complex raw data payloads are generally excluded from the curated list, preventing LLM context overflow and maintaining the server's overall context optimization strategy. - **Implementation**: The tool functions as a thin wrapper around the core HTTP request helpers. It accepts a `chain_id`, the full `endpoint_path`, optional `query_params`, an optional `cursor` for pagination, an optional `method` (`"GET"` or `"POST"`, defaulting to `"GET"`), and an optional `json_body` (dict) for POST requests. For GET requests, behavior is unchanged: pagination is supported via opaque cursors that encode raw `next_page_params` from the Blockscout API. For POST requests (e.g., JSON-RPC calls to `/json-rpc`), the `json_body` is sent as the request body; pagination is not supported for POST responses. The tool enforces strict parameter validation: `json_body` is only allowed with `method="POST"`, `method="POST"` requires a non-null `json_body`, `json_body` must be a dict (not a scalar or list), and `cursor` is rejected for POST requests. As part of the same pre-network validation, the tool also rejects the legacy JSON-RPC path: because the JSON-RPC entry point moved from the historical `/api/eth-rpc` path to `/json-rpc` during the PRO API migration, a request that still targets `/api/eth-rpc` is refused before any network call with a corrective error that names the supported `/json-rpc` path. The path is never silently rewritten — the rejection is a transparent, just-in-time signal that steers the caller onto the supported convention without spending an authenticated API credit on a request known to fail. The POST request helper uses a strictly conservative retry policy — retrying only on connection-level failures (`ConnectError`, `ConnectTimeout`) where the request provably never reached the server, since POST requests are not idempotent. The tool leverages the existing `ToolResponse` model for consistent output and integrates with the server's robust HTTP request handling and error propagation mechanisms. To ensure safety, the tool enforces a configurable response size limit (controlled by `BLOCKSCOUT_DIRECT_API_RESPONSE_SIZE_LIMIT`). In REST mode, this limit can be bypassed by setting the `X-Blockscout-Allow-Large-Response: true` header, allowing scripts to retrieve full datasets while protecting AI agents from context overflow. + **Implementation**: The tool functions as a thin wrapper around the core HTTP request helpers. It accepts a `chain_id`, the full `endpoint_path`, optional `query_params`, an optional `cursor` for pagination, an optional `method` (`"GET"` or `"POST"`, defaulting to `"GET"`), and an optional `json_body` (dict) for POST requests. For GET requests, behavior is unchanged: pagination is supported via opaque cursors that encode raw `next_page_params` from the Blockscout API. For POST requests (e.g., JSON-RPC calls to `/json-rpc`), the `json_body` is sent as the request body; pagination is not supported for POST responses. The tool enforces strict parameter validation: `json_body` is only allowed with `method="POST"`, `method="POST"` requires a non-null `json_body`, `json_body` must be a dict (not a scalar or list), and `cursor` is rejected for POST requests. As part of the same pre-network validation, the legacy JSON-RPC path `/api/eth-rpc` (which moved to `/json-rpc` during the PRO API migration) is rejected before any network call with a corrective error naming `/json-rpc`; it is never silently rewritten. The POST request helper uses a strictly conservative retry policy — retrying only on connection-level failures (`ConnectError`, `ConnectTimeout`) where the request provably never reached the server, since POST requests are not idempotent. The tool leverages the existing `ToolResponse` model for consistent output and integrates with the server's robust HTTP request handling and error propagation mechanisms. To ensure safety, the tool enforces a configurable response size limit (controlled by `BLOCKSCOUT_DIRECT_API_RESPONSE_SIZE_LIMIT`). In REST mode, this limit can be bypassed by setting the `X-Blockscout-Allow-Large-Response: true` header, allowing scripts to retrieve full datasets while protecting AI agents from context overflow. **Specialized Response Handling via Dispatcher**