├── .cursorignore ├── .gitignore ├── LICENSE ├── README.md ├── example_mcp_servers └── file_mcp_server.py ├── mcp_client.py ├── mcp_server.py ├── requirements.txt ├── screenshot.png ├── second_mcp_server.py ├── server.py ├── spec ├── file_spec.md ├── first_spec.md └── second_spec.md └── static ├── app.js ├── index.html └── js └── mcp-client.js /.cursorignore: -------------------------------------------------------------------------------- 1 | # Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | __pycache__/ 3 | .env 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Romain Beaumont 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Generic MCP Client Chat 2 | 3 | A simple chat client that connects to an MCP (Model Control Protocol) server, allowing you to interact with LLMs and use MCP tools. 4 | 5 | Vibe coded using cursor. 6 | 7 | ## Goals 8 | 9 | I believe we should have completely generic agents and completely generic UIs. 10 | 11 | People should not need to write new code to write new agents. This UI is an experiment in building this generic MCP client. 12 | 13 | ## Screenshot 14 | 15 |  16 | 17 | ## Features 18 | 19 | - Real-time chat interface with Claude 3 Sonnet 20 | - Tool support (echo and repeat tools) 21 | - WebSocket-based communication 22 | - Modern, responsive UI 23 | - Connection status monitoring 24 | - Error handling and user feedback 25 | - Multi-server support with automatic tool discovery 26 | - Support for custom MCP servers 27 | 28 | ## Prerequisites 29 | 30 | - Python 3.8 or higher 31 | - Anthropic API key 32 | 33 | ## Setup 34 | 35 | 1. Clone the repository: 36 | ```bash 37 | git clone https://github.com/rom1504/generic-mcp-client-chat.git 38 | cd generic-mcp-client-chat 39 | ``` 40 | 41 | 2. Create and activate a virtual environment (optional but recommended): 42 | ```bash 43 | python -m venv .venv 44 | source .venv/bin/activate # On Windows: .venv\Scripts\activate 45 | ``` 46 | 47 | 3. Install dependencies: 48 | ```bash 49 | pip install -r requirements.txt 50 | ``` 51 | 52 | 4. Create a `.env` file in the project root: 53 | ``` 54 | ANTHROPIC_API_KEY=your_api_key_here 55 | ``` 56 | 57 | ## Running the Application 58 | 59 | 1. Start the default MCP server: 60 | ```bash 61 | python mcp_server.py 62 | ``` 63 | 64 | 2. (Optional) Start the second MCP server: 65 | ```bash 66 | python second_mcp_server.py 67 | ``` 68 | 69 | 3. Start the main server: 70 | ```bash 71 | python server.py 72 | ``` 73 | 74 | 4. Open your web browser and navigate to: 75 | ``` 76 | http://localhost:8001 77 | ``` 78 | 79 | 5. To use the second MCP server's tools: 80 | - Click "Add Server" in the web interface 81 | - Enter server name (e.g., "math_server") 82 | - Enter server URL: `http://localhost:8002/mcp` 83 | - Click "Connect" 84 | 85 | ## Tool Support 86 | 87 | The system supports multiple MCP servers with different tools: 88 | 89 | ### Default MCP Server (Port 8000) 90 | - `echo`: Echoes back the input message 91 | - `repeat`: Repeats the input message a specified number of times (default: 10) 92 | 93 | ### Math Tools Server (Port 8002) 94 | - `count_letters`: Count the number of letters in a word 95 | - `fibonacci`: Calculate the fibonacci number for a given input 96 | 97 | ### File System Server (Port 8003) 98 | - `ls`: List contents of a directory with file/folder icons 99 | - `cd`: Change current working directory 100 | 101 | To use the tools, simply ask Claude to use them. For example: 102 | - "Can you use the echo tool to repeat back my message?" 103 | - "Please use the repeat tool to repeat 'Hello World!' 5 times" 104 | - "Count the letters in the word 'hello'" 105 | - "Calculate the 10th fibonacci number" 106 | - "List the contents of the current directory" 107 | - "Change to the parent directory" 108 | 109 | ## Project Structure 110 | 111 | - `mcp_server.py`: WebSocket server that provides MCP tools 112 | - `server.py`: Main server that connects to MCP servers and handles chat 113 | - `static/`: Frontend files 114 | - `index.html`: Main chat interface 115 | - `js/mcp-client.js`: Frontend JavaScript 116 | - `css/styles.css`: Styling 117 | - `.env`: Configuration file (create this) 118 | 119 | ## Error Handling 120 | 121 | The system handles various error cases: 122 | - Invalid JSON messages 123 | - Unknown tool calls 124 | - Connection issues 125 | - API errors 126 | 127 | All errors are displayed to the user in the chat interface with appropriate styling. 128 | 129 | ## Development 130 | 131 | - The project uses FastAPI for the backend 132 | - FastMCP for MCP server implementation 133 | - Anthropic's Claude API for chat 134 | - Vanilla JavaScript for the frontend 135 | 136 | ## Contributing 137 | 138 | [Your contribution guidelines] 139 | 140 | ## License 141 | 142 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 143 | 144 | ## Example MCP Servers 145 | 146 | This repository includes several example MCP servers to demonstrate different capabilities. To use any of these example servers: 147 | 1. Start the desired server (e.g., `python example_mcp_servers/file_mcp_server.py`) 148 | 2. Click "Add Server" in the web interface 149 | 3. Enter a server name (e.g., "file_server") 150 | 4. Enter the server URL (e.g., `http://localhost:8003/mcp`) 151 | 5. Click "Connect" 152 | 153 | ## Available MCP Servers 154 | 155 | Beyond the example servers in this repository, you can connect to public MCP servers. Visit [mcpservers.org/remote-mcp-servers](https://mcpservers.org/remote-mcp-servers) for a list of available servers, or check out the [awesome-mcp-servers](https://github.com/punkpeye/awesome-mcp-servers) repository for a curated collection of MCP server implementations. 156 | 157 | A particularly useful one is the [Fetch MCP Server](https://remote.mcpservers.org/fetch/mcp) which allows retrieving and processing web content. 158 | 159 | To use any of these servers, add them through the web interface by clicking "Add Server" and entering their MCP endpoint URL. -------------------------------------------------------------------------------- /example_mcp_servers/file_mcp_server.py: -------------------------------------------------------------------------------- 1 | from fastmcp import FastMCP 2 | import logging 3 | import os 4 | from pathlib import Path 5 | 6 | # Configure logging 7 | logging.basicConfig( 8 | level=logging.INFO, 9 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 10 | ) 11 | logger = logging.getLogger(__name__) 12 | 13 | # Create an MCP server 14 | mcp = FastMCP( 15 | "File System Tools Server", 16 | description="A server providing file system navigation tools", 17 | version="1.0.0" 18 | ) 19 | 20 | # Keep track of current directory 21 | current_dir = os.getcwd() 22 | 23 | @mcp.tool() 24 | def ls(path: str = ".") -> str: 25 | """List contents of a directory""" 26 | logger.info(f"ls tool called with path: {path}") 27 | try: 28 | # Resolve path relative to current directory 29 | full_path = os.path.join(current_dir, path) 30 | full_path = os.path.abspath(full_path) 31 | 32 | # Check if path exists 33 | if not os.path.exists(full_path): 34 | return f"Error: Path '{path}' does not exist" 35 | 36 | # Check if it's a directory 37 | if not os.path.isdir(full_path): 38 | return f"Error: '{path}' is not a directory" 39 | 40 | # List contents 41 | items = os.listdir(full_path) 42 | 43 | # Format output 44 | result = f"Contents of {path}:\n" 45 | for item in sorted(items): 46 | item_path = os.path.join(full_path, item) 47 | if os.path.isdir(item_path): 48 | result += f"📁 {item}/\n" 49 | else: 50 | result += f"📄 {item}\n" 51 | return result 52 | except Exception as e: 53 | logger.error(f"Error in ls: {str(e)}") 54 | return f"Error: {str(e)}" 55 | 56 | @mcp.tool() 57 | def cd(path: str) -> str: 58 | """Change current directory""" 59 | logger.info(f"cd tool called with path: {path}") 60 | global current_dir 61 | try: 62 | # Resolve path relative to current directory 63 | new_dir = os.path.join(current_dir, path) 64 | new_dir = os.path.abspath(new_dir) 65 | 66 | # Check if path exists 67 | if not os.path.exists(new_dir): 68 | return f"Error: Path '{path}' does not exist" 69 | 70 | # Check if it's a directory 71 | if not os.path.isdir(new_dir): 72 | return f"Error: '{path}' is not a directory" 73 | 74 | # Update current directory 75 | current_dir = new_dir 76 | return f"Changed directory to: {current_dir}" 77 | except Exception as e: 78 | logger.error(f"Error in cd: {str(e)}") 79 | return f"Error: {str(e)}" 80 | 81 | if __name__ == "__main__": 82 | logger.info("Starting File System MCP server...") 83 | logger.info("Server will be available at http://localhost:8003/mcp") 84 | logger.info("Available tools:") 85 | logger.info("- ls: List contents of a directory") 86 | logger.info("- cd: Change current directory") 87 | mcp.run( 88 | transport="streamable-http", 89 | host="0.0.0.0", 90 | port=8003, 91 | path="/mcp" 92 | ) -------------------------------------------------------------------------------- /mcp_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import Dict, Optional, Any 4 | from fastmcp import Client 5 | 6 | # Configure logging 7 | logging.basicConfig( 8 | level=logging.INFO, 9 | format='%(asctime)s - %(levelname)s - %(message)s' 10 | ) 11 | logger = logging.getLogger(__name__) 12 | 13 | class MCPManager: 14 | def __init__(self): 15 | self._clients: Dict[str, Client] = {} 16 | self._connections: Dict[str, asyncio.Task] = {} 17 | 18 | async def connect(self, server_name: str, server_url: str) -> bool: 19 | """Connect to an MCP server and store the connection""" 20 | if server_name in self._clients: 21 | logger.info(f"Already connected to {server_name}") 22 | return True 23 | 24 | logger.info(f"Connecting to MCP server '{server_name}' at {server_url}") 25 | try: 26 | # Create a new client connection 27 | client = Client(server_url) 28 | # Start the connection 29 | connection = asyncio.create_task(client.__aenter__()) 30 | await connection 31 | 32 | # Store the client and connection 33 | self._clients[server_name] = client 34 | self._connections[server_name] = connection 35 | 36 | # Test the connection by listing tools 37 | tools = await client.list_tools() 38 | logger.info(f"Successfully connected to '{server_name}'. Available tools: {len(tools)}") 39 | return True 40 | 41 | except Exception as e: 42 | logger.error(f"Error connecting to '{server_name}': {type(e).__name__} - {e}") 43 | await self.disconnect(server_name) 44 | return False 45 | 46 | async def disconnect(self, server_name: str) -> bool: 47 | """Disconnect from an MCP server""" 48 | if server_name not in self._clients: 49 | logger.warning(f"No connection found for '{server_name}'") 50 | return True 51 | 52 | logger.info(f"Disconnecting from '{server_name}'") 53 | try: 54 | client = self._clients.pop(server_name) 55 | connection = self._connections.pop(server_name) 56 | 57 | # Close the client connection 58 | await client.__aexit__(None, None, None) 59 | # Cancel the connection task if it's still running 60 | if not connection.done(): 61 | connection.cancel() 62 | 63 | logger.info(f"Successfully disconnected from '{server_name}'") 64 | return True 65 | 66 | except Exception as e: 67 | logger.error(f"Error disconnecting from '{server_name}': {type(e).__name__} - {e}") 68 | return False 69 | 70 | def is_connected(self, server_name: str) -> bool: 71 | """Check if connected to a server""" 72 | return server_name in self._clients 73 | 74 | async def call_tool(self, server_name: str, tool_name: str, parameters: dict) -> Any: 75 | """Call a tool on a specific server""" 76 | if not self.is_connected(server_name): 77 | raise ConnectionError(f"Not connected to server: {server_name}") 78 | 79 | client = self._clients[server_name] 80 | try: 81 | result = await client.call_tool(tool_name, parameters) 82 | # Handle different types of responses 83 | if isinstance(result, list) and len(result) > 0 and hasattr(result[0], 'text'): 84 | # Handle list of TextContent objects 85 | return result[0].text 86 | elif hasattr(result, 'text'): 87 | return result.text 88 | elif isinstance(result, (str, int, float, bool)): 89 | return str(result) 90 | elif isinstance(result, (list, dict)): 91 | return str(result) # Convert to string to ensure proper serialization 92 | else: 93 | return str(result) 94 | except Exception as e: 95 | logger.error(f"Error calling tool '{tool_name}' on '{server_name}': {type(e).__name__} - {e}") 96 | raise 97 | 98 | async def list_tools(self, server_name: str) -> list: 99 | """List tools available on a specific server""" 100 | if not self.is_connected(server_name): 101 | raise ConnectionError(f"Not connected to server: {server_name}") 102 | 103 | client = self._clients[server_name] 104 | try: 105 | return await client.list_tools() 106 | except Exception as e: 107 | logger.error(f"Error listing tools on '{server_name}': {type(e).__name__} - {e}") 108 | raise 109 | 110 | async def get_resource(self, server_name: str, resource_path: str) -> str: 111 | """Get a resource from a specific server""" 112 | if not self.is_connected(server_name): 113 | raise ConnectionError(f"Not connected to server: {server_name}") 114 | 115 | client = self._clients[server_name] 116 | try: 117 | result = await client.get_resource(resource_path) 118 | # Handle different types of responses 119 | if hasattr(result, 'text'): 120 | return result.text 121 | elif isinstance(result, (str, int, float, bool)): 122 | return str(result) 123 | elif isinstance(result, (list, dict)): 124 | return str(result) # Convert to string to ensure proper serialization 125 | else: 126 | return str(result) 127 | except Exception as e: 128 | logger.error(f"Error getting resource '{resource_path}' from '{server_name}': {type(e).__name__} - {e}") 129 | raise 130 | 131 | async def main(): 132 | """Test the MCP manager with multiple servers""" 133 | manager = MCPManager() 134 | 135 | # Connect to multiple servers 136 | servers = { 137 | "server1": "http://localhost:8000/mcp", 138 | "server2": "http://localhost:8001/mcp" # Example second server 139 | } 140 | 141 | # Connect to all servers 142 | for name, url in servers.items(): 143 | if await manager.connect(name, url): 144 | logger.info(f"Connected to {name}") 145 | 146 | # List tools 147 | tools = await manager.list_tools(name) 148 | logger.info(f"Tools on {name}: {tools}") 149 | 150 | # Call echo tool if available 151 | if any(tool.name == "echo" for tool in tools): 152 | result = await manager.call_tool(name, "echo", {"message": f"Hello from {name}!"}) 153 | logger.info(f"Echo result from {name}: {result}") 154 | 155 | # Get conversation history 156 | try: 157 | history = await manager.get_resource(name, "conversation://history") 158 | logger.info(f"History from {name}: {history}") 159 | except Exception as e: 160 | logger.warning(f"Could not get history from {name}: {e}") 161 | 162 | # Disconnect from all servers 163 | for name in list(servers.keys()): 164 | await manager.disconnect(name) 165 | 166 | if __name__ == "__main__": 167 | asyncio.run(main()) -------------------------------------------------------------------------------- /mcp_server.py: -------------------------------------------------------------------------------- 1 | from fastmcp import FastMCP 2 | import logging 3 | 4 | # Configure logging 5 | logging.basicConfig( 6 | level=logging.INFO, 7 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 8 | ) 9 | logger = logging.getLogger(__name__) 10 | 11 | # Create an MCP server 12 | mcp = FastMCP( 13 | "Tools Server", 14 | description="A server providing echo and repeat tools", 15 | version="1.0.0" 16 | ) 17 | 18 | @mcp.tool() 19 | def echo(message: str) -> str: 20 | """Echoes back the input message""" 21 | logger.info(f"Echo tool called with message: {message}") 22 | return message 23 | 24 | @mcp.tool() 25 | def repeat(message: str, times: int = 10) -> str: 26 | """Repeats the input message a specified number of times (default: 10)""" 27 | logger.info(f"Repeat tool called with message: {message} and times: {times}") 28 | repeated = "\n".join([message] * times) 29 | return f"Repeating {times} times:\n{repeated}" 30 | 31 | if __name__ == "__main__": 32 | logger.info("Starting MCP server...") 33 | logger.info("Server will be available at http://localhost:8000/mcp") 34 | logger.info("Available tools:") 35 | logger.info("- echo: Echoes back the input message") 36 | logger.info("- repeat: Repeats the input message a specified number of times") 37 | mcp.run( 38 | transport="streamable-http", 39 | host="0.0.0.0", 40 | port=8000, 41 | path="/mcp" 42 | ) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.109.2 2 | uvicorn==0.27.1 3 | python-dotenv==1.0.1 4 | websockets>=15.0.1 5 | pydantic==2.6.1 6 | pydantic-settings>=2.5.2 7 | anthropic==0.18.1 8 | httpx>=0.27.0 9 | starlette>=0.40.0,<0.47.0 10 | anyio>=4.5.0 11 | mcp>=1.8.0 12 | aiohttp==3.9.3 13 | fastmcp>=2.0.0 14 | requests==2.31.0 -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rom1504/generic-mcp-client-chat/57df72088904f3c54a2a786848f638c2fed337fc/screenshot.png -------------------------------------------------------------------------------- /second_mcp_server.py: -------------------------------------------------------------------------------- 1 | from fastmcp import FastMCP 2 | import logging 3 | 4 | # Configure logging 5 | logging.basicConfig( 6 | level=logging.INFO, 7 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 8 | ) 9 | logger = logging.getLogger(__name__) 10 | 11 | # Create an MCP server 12 | mcp = FastMCP( 13 | "Math Tools Server", 14 | description="A server providing letter counting and fibonacci tools", 15 | version="1.0.0" 16 | ) 17 | 18 | @mcp.tool() 19 | def count_letters(word: str) -> str: 20 | """Count the number of letters in a word""" 21 | logger.info(f"Count letters tool called with word: {word}") 22 | count = len(word) 23 | return f"The word '{word}' has {count} letters" 24 | 25 | @mcp.tool() 26 | def fibonacci(n: int) -> str: 27 | """Calculate the fibonacci number for a given input""" 28 | logger.info(f"Fibonacci tool called with n: {n}") 29 | if n < 0: 30 | return "Error: Input must be a non-negative integer" 31 | 32 | def fib(n): 33 | if n <= 1: 34 | return n 35 | a, b = 0, 1 36 | for _ in range(2, n + 1): 37 | a, b = b, a + b 38 | return b 39 | 40 | result = fib(n) 41 | return f"Fibonacci({n}) = {result}" 42 | 43 | if __name__ == "__main__": 44 | logger.info("Starting second MCP server...") 45 | logger.info("Server will be available at http://localhost:8002/mcp") 46 | logger.info("Available tools:") 47 | logger.info("- count_letters: Count the number of letters in a word") 48 | logger.info("- fibonacci: Calculate the fibonacci number for a given input") 49 | mcp.run( 50 | transport="streamable-http", 51 | host="0.0.0.0", 52 | port=8002, 53 | path="/mcp" 54 | ) -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, HTTPException, Request, WebSocket 2 | from fastapi.middleware.cors import CORSMiddleware 3 | from fastapi.staticfiles import StaticFiles 4 | from pydantic import BaseModel 5 | from typing import Optional, Dict, List 6 | import aiohttp 7 | import asyncio 8 | import os 9 | from dotenv import load_dotenv 10 | from mcp_client import MCPManager 11 | import logging 12 | from contextlib import asynccontextmanager 13 | from anthropic import Anthropic 14 | import json 15 | import re 16 | from fastapi.responses import FileResponse 17 | 18 | # Configure logging 19 | logging.basicConfig( 20 | level=logging.INFO, 21 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 22 | ) 23 | logger = logging.getLogger(__name__) 24 | 25 | load_dotenv() 26 | 27 | # Initialize Anthropic client 28 | api_key = os.getenv("ANTHROPIC_API_KEY") 29 | if not api_key: 30 | raise ValueError("ANTHROPIC_API_KEY environment variable is not set") 31 | anthropic = Anthropic(api_key=api_key) 32 | 33 | @asynccontextmanager 34 | async def lifespan(app: FastAPI): 35 | # Startup 36 | default_mcp_server_url = "http://localhost:8000/mcp" # Full path 37 | default_server_name = "default_mcp" 38 | logger.info(f"Attempting to connect to default MCP server '{default_server_name}' at {default_mcp_server_url} on startup.") 39 | 40 | # Perform a quick availability check first 41 | if await check_server_availability(default_mcp_server_url): 42 | logger.info(f"Basic availability check passed for {default_mcp_server_url}. Proceeding with FastMCP connection.") 43 | success = await mcp_manager.connect(default_server_name, default_mcp_server_url) 44 | if success: 45 | logger.info(f"Successfully connected to default MCP server '{default_server_name}' using MCPManager.") 46 | try: 47 | tools = await mcp_manager.list_tools(default_server_name) 48 | logger.info(f"Tools on '{default_server_name}': {tools}") 49 | except Exception as e: 50 | logger.error(f"Error listing tools on default server after startup connection: {e}") 51 | else: 52 | logger.error(f"Failed to connect to default MCP server '{default_server_name}' using MCPManager after availability check.") 53 | else: 54 | logger.error(f"Basic availability check failed for default MCP server at {default_mcp_server_url}. Will not attempt FastMCP connection on startup.") 55 | 56 | yield 57 | 58 | # Disconnect from all servers 59 | for server_name in list(mcp_manager._clients.keys()): 60 | await mcp_manager.disconnect(server_name) 61 | 62 | app = FastAPI(lifespan=lifespan) 63 | 64 | # Add CORS middleware 65 | app.add_middleware( 66 | CORSMiddleware, 67 | allow_origins=["*"], 68 | allow_credentials=True, 69 | allow_methods=["*"], 70 | allow_headers=["*"], 71 | ) 72 | 73 | # Mount static files 74 | app.mount("/static", StaticFiles(directory="static"), name="static") 75 | 76 | # Instantiate the MCP manager 77 | mcp_manager = MCPManager() 78 | 79 | # Data models 80 | class ServerConfig(BaseModel): 81 | server_name: str 82 | server_url: str 83 | api_key: Optional[str] = None 84 | 85 | class ConnectionRequest(BaseModel): 86 | server_name: str 87 | server_url: str 88 | api_key: Optional[str] = None 89 | 90 | class ChatRequest(BaseModel): 91 | message: str # Removed server_name since we'll use all servers 92 | 93 | class DisconnectRequest(BaseModel): 94 | server_name: str 95 | 96 | async def check_server_availability(url: str) -> bool: 97 | """Basic check if an HTTP server is listening at the URL (before full MCP connection).""" 98 | logger.info(f"Performing basic availability check for URL: {url}") 99 | try: 100 | async with aiohttp.ClientSession() as session: 101 | async with session.get(url, timeout=3) as response: 102 | # We just care if it responds, status 200-4xx is fine for a basic ping 103 | logger.info(f"Availability check for {url} got status: {response.status}") 104 | return True # Server is at least responding to HTTP 105 | except asyncio.TimeoutError: 106 | logger.error(f"Timeout during basic availability check for {url}") 107 | return False 108 | except aiohttp.ClientConnectorError as e: 109 | logger.error(f"Connection error during basic availability check for {url}: {e}") 110 | return False 111 | except Exception as e: 112 | logger.error(f"Unexpected error during basic availability check for {url}: {e}") 113 | return False 114 | 115 | @app.post("/api/connect") 116 | async def connect_mcp_server(request: ConnectionRequest): 117 | logger.info(f"API call to connect to MCP server: '{request.server_name}' at {request.server_url}") 118 | 119 | if not request.server_url.startswith(("http://", "https://")): 120 | raise HTTPException(status_code=400, detail="Invalid server_url format. Must include http:// or https://") 121 | 122 | # The server_url for connect should already include the /mcp path if needed by FastMCP server 123 | if await check_server_availability(request.server_url): 124 | logger.info(f"Basic availability check passed for '{request.server_name}'. Proceeding with FastMCP connection.") 125 | success = await mcp_manager.connect(request.server_name, request.server_url) 126 | if success: 127 | logger.info(f"Successfully initiated connection to '{request.server_name}'.") 128 | return {"status": "connected", "server_name": request.server_name} 129 | else: 130 | logger.error(f"Failed to connect to '{request.server_name}' using MCPManager after availability check.") 131 | raise HTTPException(status_code=500, detail=f"Failed to connect to MCP server '{request.server_name}'. Check server logs.") 132 | else: 133 | logger.error(f"Basic availability check failed for '{request.server_name}' at {request.server_url}.") 134 | raise HTTPException(status_code=503, detail=f"MCP server '{request.server_name}' not available at {request.server_url}.") 135 | 136 | @app.post("/api/disconnect") 137 | async def disconnect_mcp_server(request: DisconnectRequest): 138 | logger.info(f"API call to disconnect from MCP server: '{request.server_name}'") 139 | success = await mcp_manager.disconnect(request.server_name) 140 | if success: 141 | logger.info(f"Successfully processed disconnect for '{request.server_name}'.") 142 | return {"status": "disconnected", "server_name": request.server_name} 143 | else: 144 | # Disconnect might return False if error during cleanup, but still considered processed. 145 | logger.warning(f"Disconnect for '{request.server_name}' completed, possibly with minor issues (e.g., already disconnected).") 146 | return {"status": "disconnected_with_issues_or_not_found", "server_name": request.server_name} 147 | 148 | @app.post("/api/chat") 149 | async def chat(request: Request): 150 | try: 151 | data = await request.json() 152 | message = data.get("message", "") 153 | 154 | # Get response from Claude with all available tools 155 | all_tools = [] 156 | for server_name in mcp_manager._clients.keys(): 157 | if mcp_manager.is_connected(server_name): 158 | try: 159 | tools = await mcp_manager.list_tools(server_name) 160 | logger.info(f"Found {len(tools)} tools on server {server_name}") 161 | for tool in tools: 162 | tool_info = { 163 | "name": f"{server_name}.{tool.name}", # Prefix tool name with server 164 | "description": tool.description, 165 | "input_schema": tool.inputSchema, 166 | "server": server_name 167 | } 168 | logger.info(f"Adding tool: {tool_info['name']}") 169 | all_tools.append(tool_info) 170 | except Exception as e: 171 | logger.error(f"Error getting tools from server {server_name}: {e}") 172 | 173 | logger.info(f"Total tools available: {len(all_tools)}") 174 | 175 | # Create system message with tool descriptions 176 | system_content = f"""You are a helpful AI assistant with access to the following tools: 177 | {json.dumps(all_tools, indent=2)} 178 | 179 | When you need to use a tool, respond with a JSON object in this format: 180 | {{ 181 | "tool": "server_name.tool_name", 182 | "parameters": {{ 183 | "param_name": "param_value" 184 | }} 185 | }} 186 | 187 | For example, to use the echo tool, respond with: 188 | {{ 189 | "tool": "default_mcp.echo", 190 | "parameters": {{ 191 | "message": "your message here" 192 | }} 193 | }} 194 | 195 | Otherwise, respond normally with your message.""" 196 | 197 | # Get response from Claude 198 | response = anthropic.messages.create( 199 | model="claude-3-7-sonnet-20250219", 200 | max_tokens=1024, 201 | system=system_content, 202 | messages=[{"role": "user", "content": message}] 203 | ) 204 | 205 | # Check if response is a tool call 206 | try: 207 | response_text = response.content[0].text.strip() 208 | # Try to extract JSON from code block 209 | code_block_match = re.search(r'```json\s*(\{[\s\S]*?\})\s*```', response_text) 210 | if code_block_match: 211 | json_str = code_block_match.group(1) 212 | tool_call = json.loads(json_str) 213 | else: 214 | # Try to find the first JSON object in the text 215 | json_match = re.search(r'(\{[\s\S]*\})', response_text) 216 | if json_match: 217 | tool_call = json.loads(json_match.group(1)) 218 | else: 219 | tool_call = json.loads(response_text) 220 | 221 | if isinstance(tool_call, dict) and "tool" in tool_call and "parameters" in tool_call: 222 | # Parse server and tool name 223 | server_name, tool_name = tool_call["tool"].split(".", 1) 224 | logger.info(f"Executing tool call: {tool_name} on server {server_name}") 225 | # Execute the tool call 226 | tool_response = await mcp_manager.call_tool( 227 | server_name, 228 | tool_name, 229 | tool_call["parameters"] 230 | ) 231 | return {"response": str(tool_response)} 232 | except Exception as e: 233 | logger.error(f"Tool call parsing/execution failed: {e}") 234 | # Not a tool call, return Claude's response 235 | pass 236 | 237 | return {"response": response.content[0].text} 238 | except Exception as e: 239 | logger.error(f"Error in chat endpoint: {e}") 240 | raise HTTPException(status_code=500, detail=str(e)) 241 | 242 | @app.get("/api/servers") 243 | async def list_servers(): 244 | """List all connected servers""" 245 | connected_servers = [] 246 | for server_name in mcp_manager._clients.keys(): 247 | if mcp_manager.is_connected(server_name): 248 | connected_servers.append(server_name) 249 | logger.info(f"Listing connected servers: {connected_servers}") 250 | return {"servers": connected_servers} 251 | 252 | @app.get("/") 253 | async def get_index(): 254 | """Serve the main page""" 255 | return FileResponse("static/index.html") 256 | 257 | if __name__ == "__main__": 258 | import uvicorn 259 | logger.info("Starting FastAPI server for MCP manager on http://localhost:8001") 260 | logger.info("Ensure your target MCP server(s) (e.g., mcp_server.py) are running.") 261 | # The startup event will try to connect to the default MCP server. 262 | uvicorn.run(app, host="0.0.0.0", port=8001) -------------------------------------------------------------------------------- /spec/file_spec.md: -------------------------------------------------------------------------------- 1 | # File System MCP Server Specification 2 | 3 | This document specifies the tools provided by the File System MCP server, which enables file system navigation capabilities. 4 | 5 | ## Server Information 6 | 7 | - **Name**: File System Tools Server 8 | - **Description**: A server providing file system navigation tools 9 | - **Version**: 1.0.0 10 | - **Port**: 8003 11 | - **Endpoint**: http://localhost:8003/mcp 12 | 13 | ## Tools 14 | 15 | ### List Directory Contents 16 | 17 | **Name**: ls 18 | 19 | **Description**: Lists the contents of a directory, showing files and folders with visual indicators. 20 | 21 | **Parameters**: 22 | ```json 23 | { 24 | "path": "string" // Optional, defaults to current directory 25 | } 26 | ``` 27 | 28 | **Returns**: A formatted string showing the directory contents, with: 29 | - 📁 for directories 30 | - 📄 for files 31 | - Sorted alphabetically 32 | - One item per line 33 | 34 | **Example**: 35 | ```json 36 | { 37 | "path": "." 38 | } 39 | ``` 40 | 41 | **Example Response**: 42 | ``` 43 | Contents of .: 44 | 📁 example_mcp_servers/ 45 | 📄 README.md 46 | 📄 requirements.txt 47 | 📄 server.py 48 | ``` 49 | 50 | ### Change Directory 51 | 52 | **Name**: cd 53 | 54 | **Description**: Changes the current working directory. The server maintains the current directory state between calls. 55 | 56 | **Parameters**: 57 | ```json 58 | { 59 | "path": "string" // Required, path to change to 60 | } 61 | ``` 62 | 63 | **Returns**: A confirmation message with the new current directory path. 64 | 65 | **Example**: 66 | ```json 67 | { 68 | "path": "example_mcp_servers" 69 | } 70 | ``` 71 | 72 | **Example Response**: 73 | ``` 74 | Changed directory to: /home/user/project/example_mcp_servers 75 | ``` 76 | 77 | ## Error Handling 78 | 79 | Both tools handle the following error cases: 80 | - Non-existent paths 81 | - Paths that are not directories 82 | - Permission errors 83 | - Invalid path formats 84 | 85 | Error responses are prefixed with "Error:" and include a descriptive message. 86 | 87 | ## State Management 88 | 89 | The server maintains the current working directory state between tool calls. This means: 90 | - `cd` commands affect subsequent `ls` commands 91 | - The state persists until the server is restarted 92 | - Relative paths are resolved from the current directory 93 | 94 | ## Security Considerations 95 | 96 | - The server only provides read-only access to the file system 97 | - Paths are resolved relative to the server's working directory 98 | - No file modification operations are provided 99 | - All paths are validated before use -------------------------------------------------------------------------------- /spec/first_spec.md: -------------------------------------------------------------------------------- 1 | # Project Session Summary: MCP Server, Client, and FastAPI Integration 2 | 3 | ## Goal 4 | Set up a minimal system for chatting with an LLM and MCP tools, using: 5 | - An MCP server exposing tools/resources 6 | - A FastAPI server serving the frontend 7 | - A frontend (HTML/JS) that connects to the MCP server(s) via the MCP client 8 | 9 | ## Steps Taken 10 | 11 | 1. **Initial Setup** 12 | - Created an MCP server (`mcp_server.py`) exposing an `echo` tool and a conversation history resource. 13 | - Created a FastAPI server (`server.py`) to serve static files and handle CORS. 14 | - Built a frontend (`static/index.html`) allowing users to connect to one or more MCP servers and chat with the LLM/tools. 15 | 16 | 2. **MCP Protocol Compliance** 17 | - Ensured the MCP server uses the official MCP Python SDK and exposes tools/resources as per protocol. 18 | - Verified that the MCP server runs as a standalone ASGI app (not mounted inside FastAPI). 19 | - Confirmed the frontend uses the MCP client SDK to connect to MCP servers. 20 | 21 | 3. **Troubleshooting** 22 | - Encountered errors when trying to mount the MCP server inside FastAPI; resolved by running MCP and FastAPI servers separately. 23 | - Diagnosed a persistent FastAPI error (`ValueError: too many values to unpack (expected 2)`) as a version incompatibility between FastAPI and Starlette. 24 | - Solution: Downgraded Starlette to a version compatible with FastAPI 0.104.1 (`pip install 'starlette<0.37.0'`). 25 | 26 | ## Final Structure 27 | - `mcp_server.py`: Standalone MCP server (port 8000) 28 | - `server.py`: FastAPI static server (port 8001) 29 | - `static/index.html`: Frontend using MCP client SDK 30 | - `spec/first_spec.md`: This summary 31 | 32 | ## Next Steps 33 | - Add more tools/resources to the MCP server as needed 34 | - Expand frontend features or connect to additional MCP servers 35 | - Ensure all package versions remain compatible -------------------------------------------------------------------------------- /spec/second_spec.md: -------------------------------------------------------------------------------- 1 | # Second Iteration Specification 2 | 3 | ## Overview 4 | This document outlines the changes and improvements made to the MCP Chat Interface in the second iteration. 5 | 6 | ## Changes Made 7 | 8 | ### 1. Multi-Server Support 9 | - Removed server selection from chat interface 10 | - Implemented automatic connection to all available MCP servers 11 | - Added support for multiple server tools in Claude's context 12 | - Tools are now prefixed with their server name (e.g., "default_mcp.echo") 13 | 14 | ### 2. Tool Improvements 15 | - Removed conversation history tool 16 | - Added repeat tool that repeats messages 10 times 17 | - Improved tool call parsing to handle JSON in code blocks 18 | - Added better error handling and logging for tool execution 19 | 20 | ### 3. Frontend Updates 21 | - Simplified UI by removing server selection 22 | - Added server management section for adding new servers 23 | - Improved status display for connected servers 24 | - Added system messages to show server connection status 25 | 26 | ### 4. Backend Improvements 27 | - Enhanced tool call parsing with regex support 28 | - Added better error handling and logging 29 | - Improved server connection management 30 | - Added support for default server configuration 31 | 32 | ## Technical Details 33 | 34 | ### Tool Call Parsing 35 | The system now handles tool calls in multiple formats: 36 | 1. JSON in code blocks (```json ... ```) 37 | 2. Raw JSON in the response 38 | 3. JSON embedded in text 39 | 40 | Example of tool call format: 41 | ```json 42 | { 43 | "tool": "server_name.tool_name", 44 | "parameters": { 45 | "param_name": "param_value" 46 | } 47 | } 48 | ``` 49 | 50 | ### Server Configuration 51 | Default server configuration in `server.py`: 52 | ```python 53 | DEFAULT_SERVERS = { 54 | "default_mcp": "http://localhost:8000/mcp" 55 | } 56 | ``` 57 | 58 | ### Tool Definitions 59 | Current tools in `mcp_server.py`: 60 | 1. Echo Tool 61 | - Input: message (string) 62 | - Output: Echoed message 63 | 2. Repeat Tool 64 | - Input: message (string) 65 | - Output: Message repeated 10 times 66 | 67 | ## Future Improvements 68 | 69 | ### Potential Enhancements 70 | 1. Add more sophisticated tools 71 | 2. Implement tool result caching 72 | 3. Add support for tool chaining 73 | 4. Improve error recovery 74 | 5. Add tool usage statistics 75 | 76 | ### Known Limitations 77 | 1. No persistent server connections 78 | 2. Limited tool parameter validation 79 | 3. No tool result formatting options 80 | 4. No tool usage history 81 | 82 | ## Testing 83 | 84 | ### Manual Testing 85 | 1. Start MCP server 86 | 2. Start main server 87 | 3. Open web interface 88 | 4. Test echo tool 89 | 5. Test repeat tool 90 | 6. Add new server 91 | 7. Test tools from new server 92 | 93 | ### Expected Behavior 94 | - Claude should recognize available tools 95 | - Tool calls should be properly parsed and executed 96 | - Results should be displayed in chat 97 | - Server connections should be maintained 98 | - Error messages should be clear and helpful -------------------------------------------------------------------------------- /static/app.js: -------------------------------------------------------------------------------- 1 | let ws = null; 2 | const chatContainer = document.getElementById('chat-container'); 3 | const messageInput = document.getElementById('message-input'); 4 | 5 | function connect() { 6 | ws = new WebSocket('ws://localhost:8001/ws'); 7 | 8 | ws.onopen = () => { 9 | console.log('Connected to server'); 10 | addMessage('System', 'Connected to server', 'assistant-message'); 11 | }; 12 | 13 | ws.onmessage = (event) => { 14 | console.log('Received message:', event.data); 15 | try { 16 | const response = JSON.parse(event.data); 17 | if (response.type === 'error') { 18 | addMessage('Error', response.content, 'assistant-message'); 19 | } else if (response.type === 'message') { 20 | addMessage('Assistant', response.content, 'assistant-message'); 21 | } else if (response.type === 'tool_response') { 22 | addMessage('Tool Response', response.content, 'assistant-message'); 23 | } else { 24 | addMessage('Assistant', JSON.stringify(response), 'assistant-message'); 25 | } 26 | } catch (e) { 27 | console.error('Error parsing message:', e); 28 | addMessage('Error', 'Invalid response from server', 'assistant-message'); 29 | } 30 | }; 31 | 32 | ws.onclose = () => { 33 | console.log('Disconnected from server'); 34 | addMessage('System', 'Disconnected from server', 'assistant-message'); 35 | // Try to reconnect after 5 seconds 36 | setTimeout(connect, 5000); 37 | }; 38 | 39 | ws.onerror = (error) => { 40 | console.error('WebSocket error:', error); 41 | addMessage('System', 'Error connecting to server', 'assistant-message'); 42 | }; 43 | } 44 | 45 | function addMessage(sender, content, className) { 46 | const messageDiv = document.createElement('div'); 47 | messageDiv.className = `message ${className}`; 48 | messageDiv.innerHTML = `${sender}: ${content}`; 49 | chatContainer.appendChild(messageDiv); 50 | chatContainer.scrollTop = chatContainer.scrollHeight; 51 | } 52 | 53 | function sendMessage() { 54 | const message = messageInput.value.trim(); 55 | if (message && ws && ws.readyState === WebSocket.OPEN) { 56 | const messageObj = { 57 | type: 'message', 58 | content: message 59 | }; 60 | 61 | console.log('Sending message:', messageObj); 62 | ws.send(JSON.stringify(messageObj)); 63 | addMessage('You', message, 'user-message'); 64 | messageInput.value = ''; 65 | } else { 66 | console.error('Cannot send message:', { 67 | message, 68 | wsReady: ws?.readyState, 69 | wsOpen: ws?.readyState === WebSocket.OPEN 70 | }); 71 | } 72 | } 73 | 74 | // Handle Enter key press 75 | messageInput.addEventListener('keypress', (e) => { 76 | if (e.key === 'Enter') { 77 | sendMessage(); 78 | } 79 | }); 80 | 81 | // Connect when the page loads 82 | connect(); -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 |