Skip to content

Implement version-aware instructions delivery #167

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
12 changes: 10 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ mcp-server/
│ ├── __init__.py # Initializes the tools sub-package
│ ├── common.py # Shared utilities and common functionality for all tools
│ ├── decorators.py # Logging decorators like @log_tool_invocation
│ ├── get_instructions.py # Implements the __get_instructions__ tool
│ ├── get_instructions.py # Implements the __get_instructions__ tool (version-aware instructions)
│ ├── ens_tools.py # Implements ENS-related tools
│ ├── search_tools.py # Implements search-related tools (e.g., lookup_token_by_symbol)
│ ├── contract_tools.py # Implements contract-related tools (e.g., get_contract_abi)
Expand Down Expand Up @@ -146,6 +146,10 @@ mcp-server/

3. **`tests/` (Test Suite)**
* This directory contains the complete test suite for the project, divided into two categories:
* **`tests/formatting/`**: Tests for the formatting module
* **`__init__.py`**: Marks formatting tests as a sub-package
* **`test_xml_formatters.py`**: Unit tests for individual XML formatting functions
* **`test_instruction_formatters.py`**: Unit tests for combined instruction formatting functions
* **`tests/tools/`**: Contains the comprehensive **unit test** suite. All external API calls are mocked, allowing these tests to run quickly and offline. It includes tests for each tool module and for shared utilities in `test_common.py`.
* Each test file corresponds to a tool module and provides comprehensive test coverage:
* **Success scenarios**: Testing normal operation with valid inputs and API responses.
Expand Down Expand Up @@ -199,6 +203,10 @@ mcp-server/
* Contains server instructions and other configuration strings.
* Ensures consistency between different parts of the application.
* Used by both server.py and tools like get_instructions.py to maintain a single source of truth.
* **`formatting/`**: Sub-package for XML formatting utilities
* **`__init__.py`**: Initializes the formatting sub-package
* **`xml_formatters.py`**: Individual XML formatting functions for instruction content
* **`instruction_formatters.py`**: Combined instruction formatting functions used by both server.py and get_instructions.py
* **`api/` (API layer)**:
* **`helpers.py`**: Shared utilities for REST API handlers, including parameter extraction and error handling.
* **`routes.py`**: Defines all REST API endpoints that wrap MCP tools.
Expand Down Expand Up @@ -226,7 +234,7 @@ mcp-server/
3. It processes the JSON response from Blockscout.
4. It transforms this response into the desired output format.
* Examples:
* `get_instructions.py`: Implements `__get_instructions__`, returning special server instructions and popular chain IDs.
* `get_instructions.py`: Implements `__get_instructions__`, returning version-aware server instructions and popular chain IDs.
* `chains_tools.py`: Implements `get_chains_list`, returning a formatted list of blockchain chains with their IDs.
* `ens_tools.py`: Implements `get_address_by_ens_name` (fixed BENS endpoint, no chain_id).
* `search_tools.py`: Implements `lookup_token_by_symbol(chain_id, symbol)`.
Expand Down
4 changes: 2 additions & 2 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ All endpoints under `/v1/` return a consistent JSON object that wraps the tool's
}
```

- `data`: The main data payload of the response. Its structure is specific to each endpoint.
- `data`: The main response payload. For instruction-only responses (like `__get_instructions__`), this may be an empty object `{}` to minimize token usage while placing essential content in the `instructions` field.
- `data_description`: (Optional) A list of strings explaining the structure or fields of the `data` payload.
- `notes`: (Optional) A list of important warnings or contextual notes, such as data truncation alerts.
- `instructions`: (Optional) A list of suggested follow-up actions for an AI agent.
- `instructions`: Optional guidance for AI agents. The `__get_instructions__` tool provides operational rules in this field, using different formats (structured objects vs. XML strings) based on client protocol version to maximize compliance.
- `pagination`: (Optional) An object containing information to retrieve the next page of results.

### Error Handling
Expand Down
17 changes: 16 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ This architecture provides the flexibility of a multi-protocol server without th
- `data`: The main data payload of the tool's response. The schema of this field can be specific to each tool.
- `data_description`: An optional list of strings that explain the structure, fields, or conventions of the `data` payload (e.g., "The `method_call` field is actually the event signature...").
- `notes`: An optional list of important contextual notes, such as warnings about data truncation or data quality issues. This field includes guidance on how to retrieve full data if it has been truncated.
- `instructions`: An optional list of suggested follow-up actions for the LLM to plan its next steps. When pagination is available, the server automatically appends pagination instructions to motivate LLMs to fetch additional pages.
- `instructions`: `list[str] | InstructionsData | None` - Optional guidance for the AI agent. For legacy clients this is a list of XML strings. Modern clients receive a structured `InstructionsData` object. Pagination hints may also be appended.
- `pagination`: An optional object that provides structured information for retrieving the next page of results.

This approach provides immense benefits, including clarity for the AI, improved testability, and a consistent, predictable API contract.
Expand Down Expand Up @@ -361,6 +361,21 @@ This architecture provides the flexibility of a multi-protocol server without th
- These custom instructions are crucial for providing the LLM with blockchain-specific context
- Instructions could include information about chain IDs, common error handling patterns, and examples of how to reason about blockchain data and DeFi protocols

#### Version-Aware Instruction Delivery

The `__get_instructions__` tool tailors its response based on the client's MCP protocol version.

**Protocol Version Detection**
- Inspect `ctx.session.client_params.protocolVersion` when available
- Versions `>= 2025-06-18` are treated as modern clients
- Older or missing versions default to legacy behavior

**Response Format Selection**
1. **Modern Clients**: return an empty `data` object and place a structured `InstructionsData` in the `instructions` field
2. **Legacy Clients**: return an empty `data` object and provide XML-formatted instruction strings in `instructions`

This approach maximizes compliance with instructions across a wide range of agents.

### Performance Optimizations and User Experience

#### Periodic Progress Tracking for Long-Running API Calls
Expand Down
3 changes: 3 additions & 0 deletions blockscout_mcp_server/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,6 @@
# The maximum length for a transaction's input data field before it's truncated.
# 514 = '0x' prefix + 512 hex characters (256 bytes).
INPUT_DATA_TRUNCATION_LIMIT = 514

# Protocol version threshold for modern MCP client features
MODERN_PROTOCOL_VERSION_THRESHOLD = "2025-06-18"
1 change: 1 addition & 0 deletions blockscout_mcp_server/formatting/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""XML formatting utilities for MCP server responses."""
24 changes: 24 additions & 0 deletions blockscout_mcp_server/formatting/instruction_formatters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Combined instruction formatting functions."""

from blockscout_mcp_server.formatting.xml_formatters import (
format_block_time_estimation_rules_xml,
format_chain_id_guidance_xml,
format_efficiency_optimization_rules_xml,
format_error_handling_rules_xml,
format_mcp_server_version_xml,
format_pagination_rules_xml,
format_time_based_query_rules_xml,
)


def format_all_instructions_as_xml_strings() -> list[str]:
"""Format all server instructions as a list of XML-tagged strings."""
return [
format_mcp_server_version_xml(),
format_error_handling_rules_xml(),
format_chain_id_guidance_xml(),
format_pagination_rules_xml(),
format_time_based_query_rules_xml(),
format_block_time_estimation_rules_xml(),
format_efficiency_optimization_rules_xml(),
]
58 changes: 58 additions & 0 deletions blockscout_mcp_server/formatting/xml_formatters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Individual XML formatting functions for instruction content."""

from blockscout_mcp_server.constants import (
BLOCK_TIME_ESTIMATION_RULES,
CHAIN_ID_RULES,
EFFICIENCY_OPTIMIZATION_RULES,
ERROR_HANDLING_RULES,
PAGINATION_RULES,
RECOMMENDED_CHAINS,
SERVER_VERSION,
TIME_BASED_QUERY_RULES,
)


def format_mcp_server_version_xml() -> str:
"""Format MCP server version as XML string."""
return f"<mcp_server_version>{SERVER_VERSION}</mcp_server_version>"


def format_error_handling_rules_xml() -> str:
"""Format error handling rules as XML string."""
return f"<error_handling_rules>\n{ERROR_HANDLING_RULES.strip()}\n</error_handling_rules>"


def format_chain_id_guidance_xml() -> str:
"""Format chain ID guidance as XML string."""
chains_list_str = "\n".join([f" * {chain['name']}: {chain['chain_id']}" for chain in RECOMMENDED_CHAINS])
return (
"<chain_id_guidance>\n"
"<rules>\n"
f"{CHAIN_ID_RULES.strip()}\n"
"</rules>\n"
"<recommended_chains>\n"
"Here is the list of IDs of most popular chains:\n"
f"{chains_list_str}\n"
"</recommended_chains>\n"
"</chain_id_guidance>"
)


def format_pagination_rules_xml() -> str:
"""Format pagination rules as XML string."""
return f"<pagination_rules>\n{PAGINATION_RULES.strip()}\n</pagination_rules>"


def format_time_based_query_rules_xml() -> str:
"""Format time-based query rules as XML string."""
return f"<time_based_query_rules>\n{TIME_BASED_QUERY_RULES.strip()}\n</time_based_query_rules>"


def format_block_time_estimation_rules_xml() -> str:
"""Format block time estimation rules as XML string."""
return f"<block_time_estimation_rules>\n{BLOCK_TIME_ESTIMATION_RULES.strip()}\n</block_time_estimation_rules>"


def format_efficiency_optimization_rules_xml() -> str:
"""Format efficiency optimization rules as XML string."""
return f"<efficiency_optimization_rules>\n{EFFICIENCY_OPTIMIZATION_RULES.strip()}\n</efficiency_optimization_rules>"
13 changes: 11 additions & 2 deletions blockscout_mcp_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
T = TypeVar("T")


# --- Model for Empty Data Payload ---
class EmptyData(BaseModel):
"""Represents an empty data payload."""

model_config = ConfigDict(extra="forbid")


# --- Models for Pagination ---
class NextCallInfo(BaseModel):
"""A structured representation of the tool call required to get the next page."""
Expand Down Expand Up @@ -295,9 +302,11 @@ class ToolResponse(BaseModel, Generic[T]):
),
)

instructions: list[str] | None = Field(
instructions: list[str] | InstructionsData | None = Field(
None,
description="A list of suggested follow-up actions or instructions for the LLM to plan its next steps.",
description=(
"A list of suggested follow-up actions or structured instructions for the LLM to plan its next steps."
),
)

pagination: PaginationInfo | None = Field(
Expand Down
46 changes: 4 additions & 42 deletions blockscout_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,10 @@
from mcp.server.fastmcp import FastMCP

from blockscout_mcp_server.constants import (
BLOCK_TIME_ESTIMATION_RULES,
CHAIN_ID_RULES,
EFFICIENCY_OPTIMIZATION_RULES,
ERROR_HANDLING_RULES,
PAGINATION_RULES,
RECOMMENDED_CHAINS,
SERVER_NAME,
SERVER_VERSION,
TIME_BASED_QUERY_RULES,
)
from blockscout_mcp_server.formatting.instruction_formatters import (
format_all_instructions_as_xml_strings,
)
from blockscout_mcp_server.tools.address_tools import (
get_address_info,
Expand All @@ -37,40 +32,7 @@
)

# Compose the instructions string for the MCP server constructor
chains_list_str = "\n".join([f" * {chain['name']}: {chain['chain_id']}" for chain in RECOMMENDED_CHAINS])
composed_instructions = f"""
Blockscout MCP server version: {SERVER_VERSION}

<error_handling_rules>
{ERROR_HANDLING_RULES.strip()}
</error_handling_rules>

<chain_id_guidance>
<rules>
{CHAIN_ID_RULES.strip()}
</rules>
<recommended_chains>
Here is the list of IDs of most popular chains:
{chains_list_str}
</recommended_chains>
</chain_id_guidance>

<pagination_rules>
{PAGINATION_RULES.strip()}
</pagination_rules>

<time_based_query_rules>
{TIME_BASED_QUERY_RULES.strip()}
</time_based_query_rules>

<block_time_estimation_rules>
{BLOCK_TIME_ESTIMATION_RULES.strip()}
</block_time_estimation_rules>

<efficiency_optimization_rules>
{EFFICIENCY_OPTIMIZATION_RULES.strip()}
</efficiency_optimization_rules>
"""
composed_instructions = "\n\n".join(format_all_instructions_as_xml_strings())

mcp = FastMCP(name=SERVER_NAME, instructions=composed_instructions)

Expand Down
17 changes: 14 additions & 3 deletions blockscout_mcp_server/tools/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
INPUT_DATA_TRUNCATION_LIMIT,
LOG_DATA_TRUNCATION_LIMIT,
)
from blockscout_mcp_server.models import NextCallInfo, PaginationInfo, ToolResponse
from blockscout_mcp_server.models import (
InstructionsData,
NextCallInfo,
PaginationInfo,
ToolResponse,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -428,7 +433,7 @@ def build_tool_response(
data: Any,
data_description: list[str] | None = None,
notes: list[str] | None = None,
instructions: list[str] | None = None,
instructions: list[str] | InstructionsData | None = None,
pagination: PaginationInfo | None = None,
) -> ToolResponse[Any]:
"""
Expand All @@ -445,7 +450,13 @@ def build_tool_response(
A ToolResponse instance.
"""
# Automatically add pagination instructions when pagination is present
final_instructions = list(instructions) if instructions is not None else []
if instructions is None:
final_instructions = []
elif isinstance(instructions, list):
final_instructions = list(instructions)
else:
# InstructionsData should bypass list processing
final_instructions = instructions

if pagination:
pagination_instructions = [
Expand Down
35 changes: 33 additions & 2 deletions blockscout_mcp_server/tools/get_instructions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from datetime import datetime

from mcp.server.fastmcp import Context

from blockscout_mcp_server.constants import (
BLOCK_TIME_ESTIMATION_RULES,
CHAIN_ID_RULES,
EFFICIENCY_OPTIMIZATION_RULES,
ERROR_HANDLING_RULES,
MODERN_PROTOCOL_VERSION_THRESHOLD,
PAGINATION_RULES,
RECOMMENDED_CHAINS,
SERVER_VERSION,
Expand All @@ -13,6 +16,7 @@
from blockscout_mcp_server.models import (
ChainIdGuidance,
ChainInfo,
EmptyData,
InstructionsData,
ToolResponse,
)
Expand All @@ -23,10 +27,20 @@
from blockscout_mcp_server.tools.decorators import log_tool_invocation


def is_modern_protocol_version(version: str | None) -> bool:
"""Return True if protocol version meets modern threshold."""
if not isinstance(version, str) or not version:
return False
try:
return datetime.fromisoformat(version) >= datetime.fromisoformat(MODERN_PROTOCOL_VERSION_THRESHOLD)
except ValueError:
return False


# 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]:
async def __get_instructions__(ctx: Context) -> ToolResponse[EmptyData]:
"""
This tool MUST be called BEFORE any other tool.
Without calling it, the MCP server will not work as expected.
Expand Down Expand Up @@ -56,6 +70,23 @@ async def __get_instructions__(ctx: Context) -> ToolResponse[InstructionsData]:
efficiency_optimization_rules=EFFICIENCY_OPTIMIZATION_RULES,
)

# Determine client protocol version
protocol_version = None
try:
if hasattr(ctx, "session") and ctx.session and ctx.session.client_params:
protocol_version = ctx.session.client_params.protocolVersion
except AttributeError:
protocol_version = None

if is_modern_protocol_version(protocol_version):
instructions_content = instructions_data
else:
from blockscout_mcp_server.formatting.instruction_formatters import (
format_all_instructions_as_xml_strings,
)

instructions_content = format_all_instructions_as_xml_strings()

# Report completion
await report_and_log_progress(
ctx,
Expand All @@ -64,4 +95,4 @@ async def __get_instructions__(ctx: Context) -> ToolResponse[InstructionsData]:
message="Server instructions ready.",
)

return build_tool_response(data=instructions_data)
return build_tool_response(data=EmptyData(), instructions=instructions_content)
Loading