#!/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]")