Files
2026-07-13 12:59:43 +08:00

457 lines
24 KiB
Python

#!/usr/bin/env python3
"""
Interactive MCP Client for Agent-to-Agent Communication Tutorial
"""
import asyncio
import argparse
import logging
from typing import Dict, Any
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.message import ClientMessageMetadata
import mcp.types as types
from rich.console import Console
from rich.panel import Panel
from .utils import TokenManager, cast_input_value
console = Console()
def display_tools(tools):
"""Display available tools as a simple list."""
console.print("[bold]Available Tools:[/bold]")
for tool in tools:
tool_type = "🤖" if any(word in tool.name.lower() for word in ["agent", "travel", "research"]) else "🔧"
console.print(f" {tool_type} [cyan]{tool.name}[/cyan]")
console.print()
console.print("[dim]Type a tool name to run it with default arguments[/dim]")
def extract_text_content(result) -> str:
"""Extract text content from tool result."""
for content in result.content:
if hasattr(content, 'text'):
return content.text
return "No text content available"
async def execute_tool_with_resumption(session, command: str, args: dict, get_session_id, on_resumption_token_update, existing_tokens=None, token_manager=None):
"""Execute a tool with resumption support using send_request."""
current_session_id = get_session_id()
if not current_session_id:
raise RuntimeError("No session ID available - resumption requires a valid session")
session_id = current_session_id
# If we have an existing resumption token, pass it for resumption
if existing_tokens and existing_tokens.get("resumption_token"):
metadata = ClientMessageMetadata(
resumption_token=existing_tokens["resumption_token"],
)
else:
# Create enhanced callback that saves tool context immediately when token is received
def enhanced_callback(token: str):
# Since callback fires immediately with the actual resumption token,
# save everything needed for resumption right away
protocol_version = getattr(session, 'protocol_version', None)
if token_manager:
token_manager.save_tokens(session_id, token, protocol_version, command, args)
# Also call the original callback
return on_resumption_token_update(session_id, token, command, args)
metadata = ClientMessageMetadata(
on_resumption_token_update=enhanced_callback,
)
result = await session.send_request(
types.ClientRequest(
types.CallToolRequest(
method="tools/call",
params=types.CallToolRequestParams(
name=command,
arguments=args
),
)
),
types.CallToolResult,
metadata=metadata,
)
return result
async def interactive_mode(server_url: str):
"""Run interactive mode with tool exploration."""
# Configure logging to suppress noisy SSE parsing errors
logging.getLogger('mcp.client.streamable_http').setLevel(logging.ERROR)
# Filter out specific SSE JSON parsing errors
class SSEFilter(logging.Filter):
def filter(self, record):
# Suppress "Error parsing SSE message" and JSON validation errors
if ("Error parsing SSE message" in record.getMessage() or
"ValidationError" in record.getMessage() or
"EOF while parsing" in record.getMessage()):
return False
return True
# Apply filter to relevant loggers
for logger_name in ['mcp.client.streamable_http', 'mcp', 'pydantic_core']:
logger = logging.getLogger(logger_name)
logger.addFilter(SSEFilter())
console.print(Panel("[bold cyan]🎮 Interactive MCP Client[/bold cyan]", expand=False))
console.print("[dim]Explore MCP server tools interactively[/dim]\n")
# Check for existing resumption tokens
token_manager = TokenManager()
existing_tokens: Dict[str, Any] | None = token_manager.load_tokens()
# Prepare headers for resumption if tokens exist
headers = {}
if existing_tokens:
headers[MCP_SESSION_ID_HEADER] = existing_tokens["session_id"]
if "protocol_version" in existing_tokens:
headers[MCP_PROTOCOL_VERSION_HEADER] = existing_tokens["protocol_version"]
console.print(f"[cyan]🔄 Found existing session, attempting resumption...[/cyan]")
try:
async with streamablehttp_client(
server_url,
headers=headers if headers else None,
terminate_on_close=False # Enable resumption
) as (read_stream, write_stream, get_session_id):
# Create message handler for real-time notifications
async def message_handler(message) -> None:
try:
if isinstance(message, types.ServerNotification):
if isinstance(message.root, types.LoggingMessageNotification):
console.print(f"📡 [dim]{message.root.params.data}[/dim]")
elif isinstance(message.root, types.ProgressNotification):
progress = message.root.params
console.print(f"🔄 [yellow]{progress.message} ({progress.progress}/{progress.total})[/yellow]")
elif isinstance(message.root, types.ResourceUpdatedNotification):
console.print(f" [blue]Resource updated: {message.root.params.uri}[/blue]")
except Exception:
# Silently ignore message handler errors
pass
# Add sampling callback for research agent
async def sampling_callback(context, params):
try:
message_text = params.messages[0].content.text if params.messages else 'No message'
console.print(f"\n🧠 [bold cyan]Server requested sampling:[/bold cyan]")
console.print(f" [yellow]{message_text}[/yellow]")
# Mock response instead of prompting user
mock_response = "Based on current research, MCP has evolved significantly with new features like resumable streams, elicitation, and sampling capabilities, enabling sophisticated agent-to-agent communication patterns."
console.print(f"[dim]🤖 Auto-responding with mock data:[/dim]")
console.print(f" [green]{mock_response[:80]}...[/green]")
return types.CreateMessageResult(
role="assistant",
content=types.TextContent(
type="text",
text=mock_response
),
model="interactive-client",
stopReason="endTurn"
)
except Exception as e:
return types.ErrorData(code=-1, message=str(e))
# Add elicitation callback for travel agent
async def elicitation_callback(context, params):
try:
console.print(f"\n💬 [bold yellow]Server is asking for confirmation:[/bold yellow]")
console.print(f" [cyan]{params.message}[/cyan]")
# Get user's decision
while True:
response = console.input("\n[bold]Do you accept? (y/n/details): [/bold]").strip().lower()
if response in ['y', 'yes', 'accept']:
user_notes = console.input("[dim]Any additional notes (optional): [/dim]").strip()
console.print(f"[dim]✅ Sending acceptance with notes: '{user_notes or 'Confirmed by user'}'[/dim]")
return types.ElicitResult(
action="accept",
content={
"confirm": True,
"notes": user_notes if user_notes else "Confirmed by user"
}
)
elif response in ['n', 'no', 'decline']:
reason = console.input("[dim]Reason for declining (optional): [/dim]").strip()
return types.ElicitResult(
action="decline",
content={
"confirm": False,
"notes": reason if reason else "Declined by user"
}
)
elif response in ['d', 'details']:
console.print(f"[dim]Schema: {params.requestedSchema if hasattr(params, 'requestedSchema') else 'Not specified'}[/dim]")
else:
console.print("[red]Please enter 'y' (yes), 'n' (no), or 'd' (details)[/red]")
except Exception as e:
console.print(f"[red]Error in elicitation: {e}[/red]")
return types.ElicitResult(action="decline", content={"confirm": False, "notes": f"Error: {e}"})
# Add resumption token callback for long-running tools
async def on_resumption_token_update(session_id: str, resumption_token: str, tool_name: str, tool_args: dict):
"""Callback for when resumption token is updated during long-running operations."""
# Save the updated token with tool information
protocol_version = existing_tokens.get("protocol_version") if existing_tokens else None
token_manager.save_tokens(session_id, resumption_token, protocol_version, tool_name, tool_args)
async with ClientSession(
read_stream,
write_stream,
message_handler=message_handler,
sampling_callback=sampling_callback,
elicitation_callback=elicitation_callback
) as session:
# Handle resumption vs new session based on tokens
if existing_tokens and existing_tokens.get("resumption_token") and existing_tokens.get("last_tool") and existing_tokens.get("last_args"):
# RESUMPTION FLOW: We have everything needed for resumption
console.print(f"[cyan]🔄 Resuming existing session...[/cyan]")
console.print(f"[cyan] Session ID: {existing_tokens['session_id']}[/cyan]")
console.print(f"[cyan] Resuming tool: {existing_tokens['last_tool']}[/cyan]")
console.print(f"[green]✅ Skipping initialization, directly resuming...[/green]")
try:
# Directly resume the tool execution with cached data
result = await execute_tool_with_resumption(
session,
existing_tokens["last_tool"],
existing_tokens["last_args"],
get_session_id,
on_resumption_token_update,
existing_tokens, # Pass existing tokens for resumption
token_manager
)
console.print(f"[green]✅ Resumed tool completed successfully![/green]")
console.print(f"[green]Result:[/green]")
console.print(f" {extract_text_content(result)}")
# Task completed successfully, clean up tokens
if token_manager.delete_tokens():
console.print(f"[dim]🧹 Cleaned up resumption tokens[/dim]")
# After successful resumption, continue with normal interactive flow
console.print("\n[cyan]📋 Loading available tools for continued interaction...[/cyan]")
tools_result = await session.list_tools()
tools = tools_result.tools
tools_dict = {tool.name: tool for tool in tools}
display_tools(tools)
except Exception as resume_error:
error_msg = str(resume_error)
if "ValidationError" in error_msg and "JSON" in error_msg:
console.print(f"[yellow]⚠️ Resumed tool completed with connection issues, but likely succeeded[/yellow]")
# Even if there was a connection issue, task likely completed
if token_manager.delete_tokens():
console.print(f"[dim]🧹 Cleaned up resumption tokens[/dim]")
# Load tools for continued interaction
console.print("\n[cyan]📋 Loading available tools for continued interaction...[/cyan]")
tools_result = await session.list_tools()
tools = tools_result.tools
tools_dict = {tool.name: tool for tool in tools}
display_tools(tools)
else:
console.print(f"[red]❌ Resume failed: {resume_error}[/red]")
console.print(f"[yellow]Falling back to normal initialization...[/yellow]")
# Fallback to normal initialization
result = await session.initialize()
console.print(f"[green]✅ Connected to: {result.serverInfo.name}[/green]")
tools_result = await session.list_tools()
tools = tools_result.tools
tools_dict = {tool.name: tool for tool in tools}
display_tools(tools)
else:
# NORMAL FLOW: No resumption tokens, do full initialization
console.print("[cyan]🔌 Connecting to server...[/cyan]")
result = await session.initialize()
console.print(f"[green]✅ Connected to: {result.serverInfo.name}[/green]")
console.print(f"[green]📋 Protocol: {result.protocolVersion}[/green]")
# Save protocol version for future resumption
current_session_id = get_session_id()
if current_session_id:
console.print(f"[green]🆔 Session: {current_session_id}[/green]")
# Load and display available tools
console.print("\n[cyan]📋 Loading available tools...[/cyan]")
tools_result = await session.list_tools()
tools = tools_result.tools
tools_dict = {tool.name: tool for tool in tools}
display_tools(tools)
# Interactive command loop
console.print("\n[dim]Commands: [tool_name], 'list', 'help', 'clean-tokens', 'quit'[/dim]")
while True:
try:
command = console.input("\n[bold blue]> [/bold blue]").strip()
if command.lower() in ['quit', 'exit', 'q']:
console.print("[yellow]👋 Goodbye![/yellow]")
break
elif command.lower() in ['list', 'l']:
display_tools(tools)
elif command.lower() in ['help', 'h']:
console.print("[bold]Available Commands:[/bold]")
console.print(" [cyan]list[/cyan] or [cyan]l[/cyan] - Show available tools")
console.print(" [cyan][tool_name][/cyan] - Execute a tool")
console.print(" [cyan]help[/cyan] or [cyan]h[/cyan] - Show this help")
console.print(" [cyan]clean-tokens[/cyan] - Delete resumption tokens")
console.print(" [cyan]quit[/cyan] or [cyan]q[/cyan] - Exit")
console.print("\n[bold]Agent tools (try these!):[/bold]")
for tool in tools:
if "agent" in tool.name.lower():
console.print(f" 🤖 [yellow]{tool.name}[/yellow]")
elif command.lower() in ['clean-tokens', 'clean']:
if token_manager.delete_tokens():
console.print("[green]✅ Resumption tokens cleared[/green]")
else:
console.print("[yellow]No tokens to clear[/yellow]")
elif command in tools_dict:
tool = tools_dict[command]
# Collect arguments
args = {}
if tool.inputSchema and tool.inputSchema.get("properties"):
console.print(f"[dim]Tool '{command}' parameters:[/dim]")
for prop_name, prop_info in tool.inputSchema["properties"].items():
required = prop_name in tool.inputSchema.get("required", [])
default = prop_info.get("default", "")
desc = prop_info.get("description", "")
prompt = f" {prop_name}"
if desc:
prompt += f" ({desc})"
if default:
prompt += f" [default: {default}]"
if required:
prompt += " [required]"
prompt += ": "
value = console.input(prompt).strip()
if value:
# Cast the input value to the correct type
args[prop_name] = cast_input_value(value, prop_info)
elif required and not default:
console.print(f"[red]❌ {prop_name} is required[/red]")
break
else:
# All required args collected, execute tool
console.print(f"[cyan]🔧 Executing {command}...[/cyan]")
try:
result = await execute_tool_with_resumption(
session, command, args, get_session_id, on_resumption_token_update, existing_tokens, token_manager
)
console.print(f"[green]✅ Result:[/green]")
console.print(f" {extract_text_content(result)}")
# Task completed successfully, clean up tokens
if token_manager.delete_tokens():
console.print(f"[dim]🧹 Cleaned up resumption tokens[/dim]")
except Exception as tool_error:
error_msg = str(tool_error)
if "ValidationError" in error_msg and "JSON" in error_msg:
console.print(f"[yellow]⚠️ Tool completed with connection issues, but likely succeeded[/yellow]")
# Even if there was a connection issue, task likely completed
if token_manager.delete_tokens():
console.print(f"[dim]🧹 Cleaned up resumption tokens[/dim]")
else:
console.print(f"[red]❌ Tool failed: {tool_error}[/red]")
else:
# No parameters needed, execute tool
console.print(f"[cyan]🔧 Executing {command}...[/cyan]")
try:
result = await execute_tool_with_resumption(
session, command, {}, get_session_id, on_resumption_token_update, existing_tokens, token_manager
)
console.print(f"[green]✅ Result:[/green]")
console.print(f" {extract_text_content(result)}")
# Task completed successfully, clean up tokens
if token_manager.delete_tokens():
console.print(f"[dim]🧹 Cleaned up resumption tokens[/dim]")
except Exception as tool_error:
error_msg = str(tool_error)
if "ValidationError" in error_msg and "JSON" in error_msg:
console.print(f"[yellow]⚠️ Tool completed with connection issues, but likely succeeded[/yellow]")
# Even if there was a connection issue, task likely completed
if token_manager.delete_tokens():
console.print(f"[dim]🧹 Cleaned up resumption tokens[/dim]")
else:
console.print(f"[red]❌ Tool failed: {tool_error}[/red]")
else:
console.print(f"[red]❌ Unknown command: '{command}'[/red]")
console.print("[dim]Type 'list' to see available tools or 'help' for commands[/dim]")
except KeyboardInterrupt:
console.print("\n[yellow]👋 Interrupted![/yellow]")
break
except Exception as e:
console.print(f"[red]❌ Connection error: {e}[/red]")
async def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Interactive MCP Client")
parser.add_argument("--url", default="http://127.0.0.1:8006/mcp", help="MCP server URL")
parser.add_argument("--clean-tokens", action="store_true", help="Delete existing resumption tokens and start fresh")
args = parser.parse_args()
# Handle token cleaning
if args.clean_tokens:
token_manager = TokenManager()
if token_manager.delete_tokens():
console.print("[green]✅ Resumption tokens cleared[/green]")
else:
console.print("[yellow]No tokens to clear[/yellow]")
return
await interactive_mode(args.url)
if __name__ == "__main__":
# Configure logging to suppress noisy MCP warnings
logging.basicConfig(level=logging.WARNING)
# Suppress specific noisy loggers
logging.getLogger('mcp.client.streamable_http').setLevel(logging.ERROR)
logging.getLogger('httpx').setLevel(logging.WARNING)
try:
asyncio.run(main())
except KeyboardInterrupt:
console.print("\n[yellow]👋 Goodbye![/yellow]")
except Exception as e:
console.print(f"[red]💥 Fatal error: {e}[/red]")