Skip to content

Commit f52e8fc

Browse files
authored
Reject on /api/eth-rpc in direct_api_call (#389)
1 parent aecd3bb commit f52e8fc

7 files changed

Lines changed: 229 additions & 5 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,8 @@ mcp-server/
172172
│ │ │ ├── test_transaction_summary_handler.py # Unit tests for transaction summary handler
173173
│ │ │ └── test_user_operation_handler.py # Unit tests for user operation handler
174174
│ │ ├── test_dispatcher.py # Unit tests for direct API dispatcher
175-
│ │ └── test_direct_api_call.py # Unit tests for direct_api_call
175+
│ │ ├── test_direct_api_call.py # Unit tests for direct_api_call
176+
│ │ └── test_direct_api_call_validation.py # Unit tests for direct_api_call input validation
176177
│ ├── ens/ # Tests for ENS-related MCP tools
177178
│ │ └── test_get_address_by_ens_name.py # Unit tests for get_address_by_ens_name
178179
│ ├── initialization/ # Tests for initialization MCP tools

SPEC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,7 @@ Credit-exhaustion and rate-limit responses from the PRO API are currently not sp
508508

509509
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.
510510

511-
**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.
511+
**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.
512512

513513
**Specialized Response Handling via Dispatcher**
514514

blockscout_mcp_server/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# SPDX-License-Identifier: LicenseRef-Blockscout
22
"""Blockscout MCP Server package."""
33

4-
__version__ = "0.16.0.dev11"
4+
__version__ = "0.16.0.dev12"

blockscout_mcp_server/tools/direct_api/direct_api_call.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,18 @@ async def direct_api_call(
8181
message=f"Preparing request for chain {chain_id}...",
8282
)
8383

84+
endpoint_path = endpoint_path.strip()
8485
if endpoint_path != "/" and endpoint_path.endswith("/"):
8586
endpoint_path = endpoint_path.rstrip("/")
87+
# Reject the legacy JSON-RPC path before any network call, and before the generic
88+
# query-param check below so the corrective message wins. Splitting off any query
89+
# string, stripping whitespace that precedes it, dropping a trailing slash, and
90+
# lowercasing folds variants like "/api/eth-rpc/", "/API/ETH-RPC", "/api/eth-rpc?id=1",
91+
# and "/api/eth-rpc ?id=1" onto the same rejection.
92+
if endpoint_path.split("?", 1)[0].strip().rstrip("/").lower() == "/api/eth-rpc":
93+
raise ValueError(
94+
"The legacy JSON-RPC path '/api/eth-rpc' is no longer supported. Retry with endpoint_path='/json-rpc'."
95+
)
8696
if "?" in endpoint_path:
8797
raise ValueError("Do not include query parameters in endpoint_path. Use query_params instead.")
8898

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "blockscout-mcp-server"
3-
version = "0.16.0.dev11"
3+
version = "0.16.0.dev12"
44
description = "MCP server for Blockscout"
55
requires-python = ">=3.11"
66
dependencies = [

server.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
33
"name": "com.blockscout/mcp-server",
44
"description": "MCP server for Blockscout",
5-
"version": "0.16.0.dev11",
5+
"version": "0.16.0.dev12",
66
"websiteUrl": "https://blockscout.com",
77
"repository": {
88
"url": "https://github.com/blockscout/mcp-server",
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# SPDX-License-Identifier: LicenseRef-Blockscout
2+
from unittest.mock import AsyncMock, patch
3+
4+
import pytest
5+
6+
import blockscout_mcp_server.tools.direct_api.direct_api_call as direct_api_call_module
7+
8+
9+
@pytest.mark.asyncio
10+
async def test_direct_api_call_rejects_legacy_eth_rpc_path_get(mock_ctx):
11+
"""GET request to /api/eth-rpc raises ValueError before any network call."""
12+
with (
13+
patch(
14+
"blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request",
15+
new_callable=AsyncMock,
16+
) as mock_get,
17+
):
18+
with pytest.raises(ValueError, match="/json-rpc"):
19+
await direct_api_call_module.direct_api_call(
20+
chain_id="1",
21+
endpoint_path="/api/eth-rpc",
22+
ctx=mock_ctx,
23+
)
24+
mock_get.assert_not_awaited()
25+
26+
27+
@pytest.mark.asyncio
28+
async def test_direct_api_call_rejects_legacy_eth_rpc_path_trailing_slash(mock_ctx):
29+
"""GET request to /api/eth-rpc/ (trailing slash) raises ValueError before any network call."""
30+
with (
31+
patch(
32+
"blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request",
33+
new_callable=AsyncMock,
34+
) as mock_get,
35+
):
36+
with pytest.raises(ValueError, match="/json-rpc"):
37+
await direct_api_call_module.direct_api_call(
38+
chain_id="1",
39+
endpoint_path="/api/eth-rpc/",
40+
ctx=mock_ctx,
41+
)
42+
mock_get.assert_not_awaited()
43+
44+
45+
@pytest.mark.asyncio
46+
async def test_direct_api_call_rejects_legacy_eth_rpc_path_post(mock_ctx):
47+
"""POST request to /api/eth-rpc raises ValueError before any network call."""
48+
with (
49+
patch(
50+
"blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_post_request",
51+
new_callable=AsyncMock,
52+
) as mock_post,
53+
):
54+
with pytest.raises(ValueError, match="/json-rpc"):
55+
await direct_api_call_module.direct_api_call(
56+
chain_id="1",
57+
endpoint_path="/api/eth-rpc",
58+
method="POST",
59+
json_body={"id": 1},
60+
ctx=mock_ctx,
61+
)
62+
mock_post.assert_not_awaited()
63+
64+
65+
@pytest.mark.asyncio
66+
async def test_direct_api_call_rejects_legacy_eth_rpc_path_case_insensitive(mock_ctx):
67+
"""Case-insensitive variant /API/ETH-RPC raises ValueError before any network call."""
68+
with (
69+
patch(
70+
"blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request",
71+
new_callable=AsyncMock,
72+
) as mock_get,
73+
):
74+
with pytest.raises(ValueError, match="/json-rpc"):
75+
await direct_api_call_module.direct_api_call(
76+
chain_id="1",
77+
endpoint_path="/API/ETH-RPC",
78+
ctx=mock_ctx,
79+
)
80+
mock_get.assert_not_awaited()
81+
82+
83+
@pytest.mark.asyncio
84+
async def test_direct_api_call_rejects_legacy_eth_rpc_path_with_query_string(mock_ctx):
85+
"""A trailing query string still yields the legacy-path error, not the generic query-param one."""
86+
with (
87+
patch(
88+
"blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request",
89+
new_callable=AsyncMock,
90+
) as mock_get,
91+
):
92+
with pytest.raises(ValueError, match="no longer supported"):
93+
await direct_api_call_module.direct_api_call(
94+
chain_id="1",
95+
endpoint_path="/api/eth-rpc?id=1",
96+
ctx=mock_ctx,
97+
)
98+
mock_get.assert_not_awaited()
99+
100+
101+
@pytest.mark.asyncio
102+
async def test_direct_api_call_rejects_legacy_eth_rpc_path_surrounding_whitespace(mock_ctx):
103+
"""Surrounding whitespace around the legacy path raises ValueError before any network call."""
104+
with (
105+
patch(
106+
"blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request",
107+
new_callable=AsyncMock,
108+
) as mock_get,
109+
):
110+
with pytest.raises(ValueError, match="/json-rpc"):
111+
await direct_api_call_module.direct_api_call(
112+
chain_id="1",
113+
endpoint_path=" /api/eth-rpc ",
114+
ctx=mock_ctx,
115+
)
116+
mock_get.assert_not_awaited()
117+
118+
119+
@pytest.mark.asyncio
120+
async def test_direct_api_call_rejects_legacy_eth_rpc_path_trailing_slash_with_query_string(mock_ctx):
121+
"""A trailing slash combined with a query string still yields the legacy-path error."""
122+
with (
123+
patch(
124+
"blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request",
125+
new_callable=AsyncMock,
126+
) as mock_get,
127+
):
128+
with pytest.raises(ValueError, match="no longer supported"):
129+
await direct_api_call_module.direct_api_call(
130+
chain_id="1",
131+
endpoint_path="/api/eth-rpc/?id=1",
132+
ctx=mock_ctx,
133+
)
134+
mock_get.assert_not_awaited()
135+
136+
137+
@pytest.mark.asyncio
138+
async def test_direct_api_call_rejects_legacy_eth_rpc_path_whitespace_before_query_string(mock_ctx):
139+
"""Whitespace between the legacy path and its query string still yields the legacy-path error."""
140+
with (
141+
patch(
142+
"blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request",
143+
new_callable=AsyncMock,
144+
) as mock_get,
145+
):
146+
with pytest.raises(ValueError, match="no longer supported"):
147+
await direct_api_call_module.direct_api_call(
148+
chain_id="1",
149+
endpoint_path="/api/eth-rpc ?id=1",
150+
ctx=mock_ctx,
151+
)
152+
mock_get.assert_not_awaited()
153+
154+
155+
@pytest.mark.asyncio
156+
async def test_direct_api_call_allows_non_legacy_lookalike_path(mock_ctx):
157+
"""A path that merely contains 'eth-rpc' as a substring is not rejected and reaches the network."""
158+
with (
159+
patch(
160+
"blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request",
161+
new_callable=AsyncMock,
162+
) as mock_get,
163+
):
164+
mock_get.return_value = {"result": "ok"}
165+
await direct_api_call_module.direct_api_call(
166+
chain_id="1",
167+
endpoint_path="/api/eth-rpc-foo",
168+
ctx=mock_ctx,
169+
)
170+
mock_get.assert_awaited_once_with(chain_id="1", api_path="/api/eth-rpc-foo", params={})
171+
172+
173+
@pytest.mark.asyncio
174+
async def test_direct_api_call_allows_supported_json_rpc_path(mock_ctx):
175+
"""The supported /json-rpc path is not rejected and reaches the network."""
176+
with (
177+
patch(
178+
"blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_post_request",
179+
new_callable=AsyncMock,
180+
) as mock_post,
181+
):
182+
mock_post.return_value = {"jsonrpc": "2.0", "id": 1, "result": "0x1"}
183+
await direct_api_call_module.direct_api_call(
184+
chain_id="1",
185+
endpoint_path="/json-rpc",
186+
method="POST",
187+
json_body={"jsonrpc": "2.0", "method": "eth_blockNumber", "id": 1},
188+
ctx=mock_ctx,
189+
)
190+
mock_post.assert_awaited_once_with(
191+
chain_id="1",
192+
api_path="/json-rpc",
193+
json_body={"jsonrpc": "2.0", "method": "eth_blockNumber", "id": 1},
194+
params={},
195+
)
196+
197+
198+
@pytest.mark.asyncio
199+
async def test_direct_api_call_strips_whitespace_around_supported_path(mock_ctx):
200+
"""Surrounding whitespace on a supported path is normalized before the network call."""
201+
with (
202+
patch(
203+
"blockscout_mcp_server.tools.direct_api.direct_api_call.make_blockscout_request",
204+
new_callable=AsyncMock,
205+
) as mock_get,
206+
):
207+
mock_get.return_value = {"result": "ok"}
208+
await direct_api_call_module.direct_api_call(
209+
chain_id="1",
210+
endpoint_path=" /api/v2/stats ",
211+
ctx=mock_ctx,
212+
)
213+
mock_get.assert_awaited_once_with(chain_id="1", api_path="/api/v2/stats", params={})

0 commit comments

Comments
 (0)