Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
2 changes: 1 addition & 1 deletion blockscout_mcp_server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-License-Identifier: LicenseRef-Blockscout
"""Blockscout MCP Server package."""

__version__ = "0.16.0.dev11"
__version__ = "0.16.0.dev12"
10 changes: 10 additions & 0 deletions blockscout_mcp_server/tools/direct_api/direct_api_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "blockscout-mcp-server"
version = "0.16.0.dev11"
version = "0.16.0.dev12"
description = "MCP server for Blockscout"
requires-python = ">=3.11"
dependencies = [
Expand Down
2 changes: 1 addition & 1 deletion server.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "com.blockscout/mcp-server",
"description": "MCP server for Blockscout",
"version": "0.16.0.dev11",
"version": "0.16.0.dev12",
"websiteUrl": "https://blockscout.com",
"repository": {
"url": "https://github.com/blockscout/mcp-server",
Expand Down
213 changes: 213 additions & 0 deletions tests/tools/direct_api/test_direct_api_call_validation.py
Original file line number Diff line number Diff line change
@@ -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={})
Loading