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
23 changes: 11 additions & 12 deletions .cursor/rules/110-new-mcp-tool.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -659,34 +659,33 @@ 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
```

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
Expand Down
11 changes: 7 additions & 4 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,15 @@ 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 (`<pro_api_base_url>/<chain_id>/...`)
- MCP Server reports progress before fetching data
- 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 (`<pro_api_base_url>/<chain_id>/...`); 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
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.dev12"
__version__ = "0.16.0.dev13"
12 changes: 5 additions & 7 deletions blockscout_mcp_server/tools/address/get_address_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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."),
Expand Down
7 changes: 2 additions & 5 deletions blockscout_mcp_server/tools/address/get_tokens_by_address.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
6 changes: 2 additions & 4 deletions blockscout_mcp_server/tools/address/nft_tokens_by_address.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", [])
Expand Down
17 changes: 5 additions & 12 deletions blockscout_mcp_server/tools/block/get_block_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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.",
)
Expand All @@ -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
Expand All @@ -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.",
)
Expand Down
30 changes: 8 additions & 22 deletions blockscout_mcp_server/tools/block/get_block_number.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.",
)

Expand All @@ -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",
Expand Down Expand Up @@ -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...",
)

Expand All @@ -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.",
)

Expand Down
20 changes: 2 additions & 18 deletions blockscout_mcp_server/tools/contract/_shared.py
Original file line number Diff line number Diff line change
@@ -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,
)


Expand All @@ -23,20 +21,14 @@ 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()
cache_key = f"{chain_id}:{normalized_address}"
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
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down
14 changes: 3 additions & 11 deletions blockscout_mcp_server/tools/contract/get_contract_abi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.",
)

Expand Down
10 changes: 8 additions & 2 deletions blockscout_mcp_server/tools/contract/inspect_contract_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="Contract data ready.",
)
if file_name is None:
metadata = ContractMetadata.model_validate(processed.metadata)
instructions = None
Expand Down
Loading
Loading