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
16 changes: 14 additions & 2 deletions .cursor/rules/110-new-mcp-tool.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ When adding a new tool to the MCP server, you need to modify these files:
2. **Create or modify a tool module file** in `blockscout_mcp_server/tools/`:
- Choose an existing module (e.g., `block_tools.py`, `address_tools.py`) if your tool fits with existing functionality
- Create a new module if your tool introduces a new category of functionality
- Decorate each tool function with `@log_tool_invocation` from `tools.common`

3. **Register the tool in `blockscout_mcp_server/server.py`**:
- Import the tool function
Expand Down Expand Up @@ -162,9 +163,15 @@ For tools that query Blockscout API (which now support dynamic chain resolution)
```python
from typing import Annotated, Optional
from pydantic import Field
from blockscout_mcp_server.tools.common import make_blockscout_request, get_blockscout_base_url, build_tool_response
from blockscout_mcp_server.tools.common import (
make_blockscout_request,
get_blockscout_base_url,
build_tool_response,
log_tool_invocation,
)
from blockscout_mcp_server.models import ToolResponse, YourDataModel

@log_tool_invocation
async def tool_name(
chain_id: Annotated[str, Field(description="The ID of the blockchain")],
required_arg: Annotated[str, Field(description="Description of required argument")],
Expand Down Expand Up @@ -206,9 +213,14 @@ For tools that use fixed API endpoints (like BENS or other services):
```python
from typing import Annotated, Optional
from pydantic import Field
from blockscout_mcp_server.tools.common import make_bens_request, build_tool_response # or other API helper
from blockscout_mcp_server.tools.common import (
make_bens_request,
build_tool_response, # or other API helper
log_tool_invocation,
)
from blockscout_mcp_server.models import ToolResponse, YourDataModel

@log_tool_invocation
async def tool_name(
required_arg: Annotated[str, Field(description="Description of required argument")],
optional_arg: Annotated[Optional[str], Field(description="Description of optional argument")] = None
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ mcp-server/
* Provides shared utilities and common functionality for all MCP tools.
* Handles API communication, chain resolution, pagination, data processing, and error handling.
* Implements standardized patterns used across the tool ecosystem.
* Includes logging helpers such as the `@log_tool_invocation` decorator.
* **Individual Tool Modules** (e.g., `ens_tools.py`, `transaction_tools.py`):
* Each file will group logically related tools.
* Each tool will be implemented as an `async` Python function.
Expand Down
5 changes: 5 additions & 0 deletions blockscout_mcp_server/server.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from typing import Annotated

import typer
Expand Down Expand Up @@ -112,6 +113,10 @@ def main_command(
Use --http to enable HTTP Streamable mode.
Use --http and --rest to enable the REST API.
"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
if http:
if rest:
print(f"Starting Blockscout MCP Server with REST API on {http_host}:{http_port}")
Expand Down
5 changes: 5 additions & 0 deletions blockscout_mcp_server/tools/address_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@
encode_cursor,
extract_log_cursor_params,
get_blockscout_base_url,
log_tool_invocation,
make_blockscout_request,
make_metadata_request,
report_and_log_progress,
)


@log_tool_invocation
async def get_address_info(
chain_id: Annotated[str, Field(description="The ID of the blockchain")],
address: Annotated[str, Field(description="Address to get information about")],
Expand Down Expand Up @@ -89,6 +91,7 @@ async def get_address_info(
return build_tool_response(data=address_data, notes=notes)


@log_tool_invocation
async def get_tokens_by_address(
chain_id: Annotated[str, Field(description="The ID of the blockchain")],
address: Annotated[str, Field(description="Wallet address")],
Expand Down Expand Up @@ -182,6 +185,7 @@ def extract_nft_cursor_params(item: dict) -> dict:
}


@log_tool_invocation
async def nft_tokens_by_address(
chain_id: Annotated[str, Field(description="The ID of the blockchain")],
address: Annotated[str, Field(description="NFT owner address")],
Expand Down Expand Up @@ -288,6 +292,7 @@ async def nft_tokens_by_address(
return build_tool_response(data=nft_holdings, pagination=pagination)


@log_tool_invocation
async def get_address_logs(
chain_id: Annotated[str, Field(description="The ID of the blockchain")],
address: Annotated[str, Field(description="Account address")],
Expand Down
3 changes: 3 additions & 0 deletions blockscout_mcp_server/tools/block_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
from blockscout_mcp_server.tools.common import (
build_tool_response,
get_blockscout_base_url,
log_tool_invocation,
make_blockscout_request,
report_and_log_progress,
)


@log_tool_invocation
async def get_block_info(
chain_id: Annotated[str, Field(description="The ID of the blockchain")],
number_or_hash: Annotated[str, Field(description="Block number or hash")],
Expand Down Expand Up @@ -96,6 +98,7 @@ async def get_block_info(
return build_tool_response(data=block_data, notes=notes)


@log_tool_invocation
async def get_latest_block(
chain_id: Annotated[str, Field(description="The ID of the blockchain")], ctx: Context
) -> ToolResponse[LatestBlockData]:
Expand Down
2 changes: 2 additions & 0 deletions blockscout_mcp_server/tools/chains_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
from blockscout_mcp_server.models import ChainInfo, ToolResponse
from blockscout_mcp_server.tools.common import (
build_tool_response,
log_tool_invocation,
make_chainscout_request,
report_and_log_progress,
)


@log_tool_invocation
async def get_chains_list(ctx: Context) -> ToolResponse[list[ChainInfo]]:
"""
Get the list of known blockchain chains with their IDs.
Expand Down
21 changes: 21 additions & 0 deletions blockscout_mcp_server/tools/common.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import base64
import functools
import inspect
import json
import logging
import time
from collections.abc import Awaitable, Callable
from typing import Any
Expand All @@ -15,6 +18,24 @@
)
from blockscout_mcp_server.models import NextCallInfo, PaginationInfo, ToolResponse

logger = logging.getLogger(__name__)


def log_tool_invocation(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
"""Log the tool name and arguments when it is invoked."""
sig = inspect.signature(func)

@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
bound = sig.bind_partial(*args, **kwargs)
bound.apply_defaults()
arg_dict = dict(bound.arguments)
arg_dict.pop("ctx", None)
logger.info("Tool invoked: %s with args: %s", func.__name__, arg_dict)
return await func(*args, **kwargs)

return wrapper


class ChainNotFoundError(ValueError):
"""Exception raised when a chain ID cannot be found or resolved to a Blockscout URL."""
Expand Down
2 changes: 2 additions & 0 deletions blockscout_mcp_server/tools/contract_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from blockscout_mcp_server.tools.common import (
build_tool_response,
get_blockscout_base_url,
log_tool_invocation,
make_blockscout_request,
report_and_log_progress,
)
Expand All @@ -15,6 +16,7 @@
# More elegant solution needs to be found.


@log_tool_invocation
async def get_contract_abi(
chain_id: Annotated[str, Field(description="The ID of the blockchain")],
address: Annotated[str, Field(description="Smart contract address")],
Expand Down
2 changes: 2 additions & 0 deletions blockscout_mcp_server/tools/ens_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
from blockscout_mcp_server.models import EnsAddressData, ToolResponse
from blockscout_mcp_server.tools.common import (
build_tool_response,
log_tool_invocation,
make_bens_request,
report_and_log_progress,
)


@log_tool_invocation
async def get_address_by_ens_name(
name: Annotated[str, Field(description="ENS domain name to resolve")], ctx: Context
) -> ToolResponse[EnsAddressData]:
Expand Down
7 changes: 6 additions & 1 deletion blockscout_mcp_server/tools/get_instructions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@
InstructionsData,
ToolResponse,
)
from blockscout_mcp_server.tools.common import build_tool_response, report_and_log_progress
from blockscout_mcp_server.tools.common import (
build_tool_response,
log_tool_invocation,
report_and_log_progress,
)


# It is very important to keep the tool description in such form to force the LLM to call this tool first
# before calling any other tool. Altering of the description could provide opportunity to LLM to skip this tool.
@log_tool_invocation
async def __get_instructions__(ctx: Context) -> ToolResponse[InstructionsData]:
"""
This tool MUST be called BEFORE any other tool.
Expand Down
2 changes: 2 additions & 0 deletions blockscout_mcp_server/tools/search_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from blockscout_mcp_server.tools.common import (
build_tool_response,
get_blockscout_base_url,
log_tool_invocation,
make_blockscout_request,
report_and_log_progress,
)
Expand All @@ -15,6 +16,7 @@
TOKEN_RESULTS_LIMIT = 7


@log_tool_invocation
async def lookup_token_by_symbol(
chain_id: Annotated[str, Field(description="The ID of the blockchain")],
symbol: Annotated[str, Field(description="Token symbol or name to search for")],
Expand Down
6 changes: 6 additions & 0 deletions blockscout_mcp_server/tools/transaction_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
extract_advanced_filters_cursor_params,
extract_log_cursor_params,
get_blockscout_base_url,
log_tool_invocation,
make_blockscout_request,
make_request_with_periodic_progress,
report_and_log_progress,
Expand Down Expand Up @@ -207,6 +208,7 @@ async def _fetch_filtered_transactions_with_smart_pagination(
return accumulated_items, has_more_pages


@log_tool_invocation
async def get_transactions_by_address(
chain_id: Annotated[str, Field(description="The ID of the blockchain")],
address: Annotated[str, Field(description="Address which either sender or receiver of the transaction")],
Expand Down Expand Up @@ -320,6 +322,7 @@ async def get_transactions_by_address(
return build_tool_response(data=validated_items, pagination=pagination)


@log_tool_invocation
async def get_token_transfers_by_address(
chain_id: Annotated[str, Field(description="The ID of the blockchain")],
address: Annotated[str, Field(description="Address which either transfer initiator or transfer receiver")],
Expand Down Expand Up @@ -435,6 +438,7 @@ async def get_token_transfers_by_address(
return build_tool_response(data=sliced_items, pagination=pagination)


@log_tool_invocation
async def transaction_summary(
chain_id: Annotated[str, Field(description="The ID of the blockchain")],
transaction_hash: Annotated[str, Field(description="Transaction hash")],
Expand Down Expand Up @@ -480,6 +484,7 @@ async def transaction_summary(
return build_tool_response(data=summary_data)


@log_tool_invocation
async def get_transaction_info(
chain_id: Annotated[str, Field(description="The ID of the blockchain")],
transaction_hash: Annotated[str, Field(description="Transaction hash")],
Expand Down Expand Up @@ -543,6 +548,7 @@ async def get_transaction_info(
return build_tool_response(data=transaction_data, notes=notes)


@log_tool_invocation
async def get_transaction_logs(
chain_id: Annotated[str, Field(description="The ID of the blockchain")],
transaction_hash: Annotated[str, Field(description="Transaction hash")],
Expand Down
23 changes: 23 additions & 0 deletions tests/tools/test_log_tool_invocation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import logging

import pytest
from mcp.server.fastmcp import Context

from blockscout_mcp_server.tools.common import log_tool_invocation


@pytest.mark.asyncio
async def test_log_tool_invocation_decorator(caplog: pytest.LogCaptureFixture, mock_ctx: Context) -> None:
caplog.set_level(logging.INFO, logger="blockscout_mcp_server.tools.common")

@log_tool_invocation
async def dummy_tool(a: int, b: int, ctx: Context) -> int:
return a + b

result = await dummy_tool(1, 2, ctx=mock_ctx)

assert result == 3
log_text = caplog.text
assert "Tool invoked: dummy_tool" in log_text
assert "'ctx'" not in log_text
assert str(mock_ctx) not in log_text