Skip to main content
Glama
test_mcp_client.py16.4 kB
""" Comprehensive test suite for Frappe MCP Server using chat-style interface. This test suite uses the official MCP Python SDK to test both stdio and SSE transports with realistic chat-style prompts that trigger various MCP tools, simulating how an AI assistant like Claude would interact with the server. """ import asyncio import json import os import subprocess import sys import tempfile import time from contextlib import asynccontextmanager from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union import pytest # Import official MCP SDK components try: from mcp.client.session import ClientSession from mcp.client.stdio import stdio_client, StdioServerParameters from mcp.client.sse import sse_client from mcp.types import TextContent, ImageContent MCP_SDK_AVAILABLE = True except ImportError: MCP_SDK_AVAILABLE = False # Import server components for testing from src.server import create_server from src import __version__ class ChatTestClient: """ A test client that simulates chat-style interactions with the MCP server. This client uses natural language prompts that would trigger specific MCP tools, similar to how Claude or other AI assistants would interact with the server. """ def __init__(self, transport: str = "stdio"): """Initialize the chat test client with specified transport.""" self.transport = transport self.session: Optional[ClientSession] = None self.server_process: Optional[subprocess.Popen] = None self.server_port = 8100 # Base port for SSE testing self.available_tools: List[str] = [] self._stdio_context = None self._sse_context = None async def __aenter__(self): """Async context manager entry.""" await self.connect() return self async def __aexit__(self, exc_type, exc_val, exc_tb): """Async context manager exit.""" await self.disconnect() async def connect(self): """Connect to the MCP server using the specified transport.""" if self.transport == "stdio": await self._connect_stdio() elif self.transport == "sse": await self._connect_sse() else: raise ValueError(f"Unsupported transport: {self.transport}") async def _connect_stdio(self): """Connect using stdio transport.""" # Set up environment env = os.environ.copy() env["PYTHONPATH"] = str(Path(__file__).parent.parent) # Create server parameters for stdio client server_params = StdioServerParameters( command=sys.executable, args=["-m", "src.main", "--transport", "stdio"], env=env ) # Use the official MCP stdio client stdio_context = stdio_client(server_params) read_stream, write_stream = await stdio_context.__aenter__() self._stdio_context = stdio_context # Keep reference for cleanup self.session = ClientSession(read_stream, write_stream) await self.session.initialize() # Cache available tools tools_result = await self.session.list_tools() self.available_tools = [tool.name for tool in tools_result.tools] async def _connect_sse(self): """Connect using SSE transport.""" # Start server process with SSE transport server_cmd = [ sys.executable, "-m", "src.main", "--transport", "sse", "--port", str(self.server_port), "--host", "127.0.0.1" ] # Set up environment env = os.environ.copy() env["PYTHONPATH"] = str(Path(__file__).parent.parent) self.server_process = subprocess.Popen( server_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, cwd=Path(__file__).parent.parent ) # Wait for server to start await asyncio.sleep(2) # Connect with SSE client server_url = f"http://127.0.0.1:{self.server_port}/sse" try: sse_context = sse_client(server_url, timeout=10) read_stream, write_stream = await sse_context.__aenter__() self._sse_context = sse_context # Keep reference for cleanup self.session = ClientSession(read_stream, write_stream) await self.session.initialize() # Cache available tools tools_result = await self.session.list_tools() self.available_tools = [tool.name for tool in tools_result.tools] except Exception as e: # If SSE connection fails, it might be due to auth issues # Keep the error for reporting but continue with basic connectivity test self.session = None raise ConnectionError(f"SSE connection failed: {e}") async def disconnect(self): """Disconnect from the MCP server.""" if self.session: try: # Close session gracefully await self.session.close() except: pass # Clean up transport contexts if self._stdio_context: try: await self._stdio_context.__aexit__(None, None, None) except: pass if self._sse_context: try: await self._sse_context.__aexit__(None, None, None) except: pass if self.server_process: try: self.server_process.terminate() # Give it a moment to shutdown gracefully try: self.server_process.wait(timeout=3) except subprocess.TimeoutExpired: self.server_process.kill() except: pass async def chat(self, prompt: str) -> Tuple[bool, str, Optional[Dict]]: """ Process a chat-style prompt and determine which MCP tools to call. Returns: (success: bool, response: str, tool_result: Optional[Dict]) """ if not self.session: return False, "Not connected to MCP server", None try: # Map prompts to appropriate MCP tool calls tool_call = self._map_prompt_to_tool(prompt) if not tool_call: return False, f"No suitable tool found for prompt: {prompt}", None tool_name, arguments = tool_call if tool_name not in self.available_tools: return False, f"Tool '{tool_name}' not available. Available: {self.available_tools}", None # Call the tool result = await self.session.call_tool(tool_name, arguments) # Extract response text response_text = "" if hasattr(result, 'content') and result.content: for content in result.content: if isinstance(content, TextContent): response_text += content.text elif hasattr(content, 'text'): response_text += content.text return True, response_text, { "tool_name": tool_name, "arguments": arguments, "raw_result": result } except Exception as e: return False, f"Error processing prompt: {e}", None def _map_prompt_to_tool(self, prompt: str) -> Optional[Tuple[str, Dict[str, Any]]]: """ Map a natural language prompt to appropriate MCP tool and arguments. This simulates how an AI assistant would interpret user requests. """ prompt_lower = prompt.lower().strip() # Connectivity and basic checks - order matters for specificity if any(phrase in prompt_lower for phrase in ["version", "what version"]): return ("version", {}) if any(phrase in prompt_lower for phrase in ["ping", "alive", "running", "connected"]): return ("ping", {}) if any(phrase in prompt_lower for phrase in ["auth", "authenticate", "credentials", "logged in"]): return ("validate_auth", {}) # Schema and DocType operations if any(phrase in prompt_lower for phrase in ["list doctypes", "what doctypes", "available doctypes", "show doctypes"]): return ("get_doctype_list", {}) if "doctype schema" in prompt_lower or "fields does" in prompt_lower: # Extract doctype name from prompt if "note" in prompt_lower: return ("get_doctype_schema", {"doctype": "Note"}) elif "user" in prompt_lower: return ("get_doctype_schema", {"doctype": "User"}) # Default to Note for testing return ("get_doctype_schema", {"doctype": "Note"}) if "field options" in prompt_lower: # Extract doctype and field return ("get_field_options", {"doctype": "Note", "field": "status"}) # Document CRUD operations if any(phrase in prompt_lower for phrase in ["create note", "new note", "add note"]): # Extract title if provided title = "Test Note from Chat Client" if "title" in prompt_lower: # Simple extraction parts = prompt.split('"') if len(parts) >= 2: title = parts[1] return ("create_document", { "doctype": "Note", "data": { "title": title, "content": "This is a test note created by the chat test client." } }) if any(phrase in prompt_lower for phrase in ["count documents", "how many", "count notes"]): doctype = "Note" if "user" in prompt_lower: doctype = "User" return ("count_documents", {"doctype": doctype}) if any(phrase in prompt_lower for phrase in ["list documents", "show documents", "get documents"]): doctype = "Note" if "user" in prompt_lower: doctype = "User" return ("list_documents", {"doctype": doctype, "limit": 5}) if any(phrase in prompt_lower for phrase in ["get document", "show document", "read document"]): # This would need a real document name/ID in practice return ("get_document", {"doctype": "Note", "name": "test-note"}) # Report operations if any(phrase in prompt_lower for phrase in ["list reports", "available reports", "show reports"]): return ("list_reports", {}) if "query report" in prompt_lower: return ("run_query_report", {"report_name": "Database Storage Usage By Table"}) # Default fallback return None class ChatTestScenarios: """ Predefined chat scenarios that test comprehensive MCP functionality. Each scenario represents a realistic conversation flow that would trigger various MCP tools in sequence. """ @staticmethod def basic_connectivity_scenario(): """Basic server connectivity and status checks.""" return [ "Is the server running?", "What version are you running?", "Are my credentials valid?" ] @staticmethod def schema_exploration_scenario(): """Explore the Frappe schema and available doctypes.""" return [ "What doctypes are available?", "What fields does the Note doctype have?", "Show me the field options for Note status" ] @staticmethod def document_operations_scenario(): """Test CRUD operations on documents.""" return [ "How many Note documents are there?", "Create a new Note with title 'Test Chat Note'", "List some Note documents", "Show me document test-note" ] @staticmethod def reporting_scenario(): """Test report generation functionality.""" return [ "What reports are available?", "Run the Database Storage Usage By Table report" ] @classmethod def all_scenarios(cls): """Get all test scenarios combined.""" all_prompts = [] all_prompts.extend(cls.basic_connectivity_scenario()) all_prompts.extend(cls.schema_exploration_scenario()) all_prompts.extend(cls.document_operations_scenario()) all_prompts.extend(cls.reporting_scenario()) return all_prompts # Test functions using pytest @pytest.mark.skipif(not MCP_SDK_AVAILABLE, reason="Official MCP SDK not available") @pytest.mark.asyncio async def test_server_tools_available(): """Test that server tools can be listed without full connection.""" # Create the server directly to test tool availability server = create_server() # Get available tools tools = await server.get_tools() tool_names = list(tools.keys()) # Check that expected tools are registered expected_tools = ["ping", "version", "validate_auth", "count_documents", "list_documents"] for expected_tool in expected_tools: assert expected_tool in tool_names, f"Expected tool '{expected_tool}' not found in: {tool_names}" # Should have reasonable number of tools assert len(tool_names) >= 10, f"Expected at least 10 tools, found: {len(tool_names)}" @pytest.mark.skipif(not MCP_SDK_AVAILABLE, reason="Official MCP SDK not available") @pytest.mark.asyncio async def test_chat_prompt_mapping(): """Test that chat prompts map correctly to MCP tools.""" client = ChatTestClient("stdio") # Test prompt mapping without actual server connection test_cases = [ ("Is the server running?", "ping"), ("What version are you running?", "version"), ("Are my credentials valid?", "validate_auth"), ("How many Note documents are there?", "count_documents"), ("What doctypes are available?", "get_doctype_list"), ("What fields does the Note doctype have?", "get_doctype_schema"), ("Create a new Note with title 'Test'", "create_document"), ] for prompt, expected_tool in test_cases: tool_call = client._map_prompt_to_tool(prompt) assert tool_call is not None, f"No tool mapped for prompt: {prompt}" tool_name, arguments = tool_call assert tool_name == expected_tool, f"Expected {expected_tool}, got {tool_name} for prompt: {prompt}" def test_mcp_sdk_available(): """Verify MCP SDK is available for testing.""" assert MCP_SDK_AVAILABLE, """ Official MCP SDK not available. Install with: uv add mcp This is required for the consolidated test suite. """ def test_server_can_be_imported(): """Test that server components can be imported successfully.""" from src.server import create_server from src.tools import helpers, documents, schema, reports from src.frappe_api import FrappeApiClient # Should be able to create server server = create_server() assert server is not None assert server.name == "frappe-mcp-server" def test_version_available(): """Test that version information is available.""" assert __version__ is not None assert isinstance(__version__, str) assert len(__version__) > 0 if __name__ == "__main__": """ Run tests using pytest when executed directly. Usage: python test_mcp_client.py Or better yet, use pytest directly: uv run pytest tests/test_mcp_client.py -v """ print("🧪 Running MCP client tests...") print("💡 For best results, use: uv run pytest tests/test_mcp_client.py -v") # Run pytest programmatically import pytest import sys exit_code = pytest.main([__file__, "-v"]) sys.exit(exit_code)

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/appliedrelevance/frappe-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server