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..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. 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** 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/blockscout_mcp_server/tools/direct_api/direct_api_call.py b/blockscout_mcp_server/tools/direct_api/direct_api_call.py index 76404f2..e60cd0a 100644 --- a/blockscout_mcp_server/tools/direct_api/direct_api_call.py +++ b/blockscout_mcp_server/tools/direct_api/direct_api_call.py @@ -81,8 +81,18 @@ 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. 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'." + ) if "?" in endpoint_path: raise ValueError("Do not include query parameters in endpoint_path. Use query_params instead.") 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", 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..4764d43 --- /dev/null +++ b/tests/tools/direct_api/test_direct_api_call_validation.py @@ -0,0 +1,213 @@ +# 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() + + +@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() + + +@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() + + +@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() + + +@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() + + +@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() + + +@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.""" + 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_with(chain_id="1", api_path="/api/eth-rpc-foo", params={}) + + +@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_with( + chain_id="1", + api_path="/json-rpc", + json_body={"jsonrpc": "2.0", "method": "eth_blockNumber", "id": 1}, + params={}, + ) + + +@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_with(chain_id="1", api_path="/api/v2/stats", params={})