diff --git a/README.md b/README.md index aab4c07..de1db73 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,353 @@ -# Cariddi +# Cariddi - MCP Client and Server +A complete MCP (Model Context Protocol) solution consisting of: +- **Cariddi Server**: A FastMCP server with filesystem tools for file operations and command execution +- **Cariddi Client**: A Python client that uses Ollama models for inference and connects to MCP servers, specialized as a Crypto Solver Agent for CTF challenges + +## Project Structure + +``` +Cariddi/ +├── Cariddi/ # MCP Server implementation +│ ├── main.py # FastMCP server entry point +│ ├── modules/ +│ │ └── filesystem.py # Filesystem operation implementations +│ ├── requirements.txt +│ ├── Dockerfile +│ ├── docker-compose.yml +│ └── mcp.json # MCP server configuration +├── CariddiClient/ # MCP Client with Ollama +│ ├── mcpClient.py # Main client implementation +│ └── requirements.txt +└── challs/ # CTF challenges + └── cryptoEasy/ + ├── challenge.py + └── cryptoeasy.txt +``` + +--- + +## Cariddi Server + +A FastMCP server that provides filesystem tools for file operations, command execution, and Python file writing with proper handling of escape characters. + +### Server Setup + +1. Navigate to the server directory: +```bash +cd Cariddi +``` + +2. Create and activate virtual environment: +```bash +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +3. Install dependencies: +```bash +pip install -r requirements.txt +``` + +### Running the Server + +```bash +source venv/bin/activate # On Windows: venv\Scripts\activate +python main.py +``` + +The server will start on `http://0.0.0.0:8000/mcp` with streamable-http transport. + +### Environment Variables + +- `FASTMCP_HOST` or `MCP_HOST`: Server host (default: `0.0.0.0`) +- `FASTMCP_PORT` or `MCP_PORT`: Server port (default: `8000`) + +### Using MCP Inspector + +The MCP Inspector is a visual tool for testing and debugging MCP servers. + +#### Install and Run Inspector + +1. Make sure your server is running (see above) + +2. Run the inspector to connect to your server: +```bash +npx @modelcontextprotocol/inspector --url http://localhost:8000/mcp +``` + +The inspector will open in your browser (typically at `http://localhost:5173`). + +#### Alternative: Run Inspector with Server + +You can also run the inspector and server together: +```bash +npx @modelcontextprotocol/inspector python main.py +``` + +#### Setup MCP Inspector + +Use "Streamable HTTP" + +URL: `http://localhost:8000/mcp` + +and press connect. + +### Docker Deployment + +#### Build and Run with Docker + +1. Navigate to the server directory: +```bash +cd Cariddi +``` + +2. Build the Docker image: +```bash +docker build -t cariddi-mcp-server . +``` + +3. Run the container: +```bash +docker run -d -p 8000:8000 --name cariddi-mcp cariddi-mcp-server +``` + +#### Using Docker Compose + +1. Navigate to the server directory: +```bash +cd Cariddi +``` + +2. Start the server: +```bash +docker-compose up -d +``` + +3. View logs: +```bash +docker-compose logs -f +``` + +4. Stop the server: +```bash +docker-compose down +``` + +The server will be accessible at `http://localhost:8000/mcp` from your host machine. + +### Server Tools + +The server provides the following tools: + +- **`listFiles(path: str)`** - List all files in the given path +- **`readFile(path: str)`** - Read the contents of a file +- **`writeFile(path: str, content: str)`** - Write contents to a file +- **`executeCommand(command: str)`** - Execute a shell command and return stdout, stderr, and return code +- **`writePythonFile(path: str, content: str)`** - Write a Python file handling streaming and escape characters correctly (handles code blocks and unicode escapes) + +--- + +## Cariddi Client + +A Python MCP client that uses Ollama models for inference. The client is specialized as a **Crypto Solver Agent** for CTF (Capture The Flag) challenges, capable of identifying, analyzing, and solving cryptographic challenges. + +### Client Requirements + +- Python 3.7+ +- Ollama installed and running (see https://ollama.ai/) + +### Client Installation + +1. Navigate to the client directory: +```bash +cd CariddiClient +``` + +2. Install Python dependencies: +```bash +pip install -r requirements.txt +``` + +3. Make sure Ollama is running: +```bash +ollama serve +``` + +4. Pull a model (if you haven't already): +```bash +ollama pull ministral-3 +# or +ollama pull llama3.2 +``` + +### Client Usage + +#### List available models +```bash +python mcpClient.py --list-models +``` + +#### Send a single prompt +```bash +python mcpClient.py --prompt "What is the capital of France?" +``` + +#### Interactive mode +```bash +python mcpClient.py --interactive +``` + +#### Custom Ollama URL and model +```bash +python mcpClient.py --base-url http://localhost:11434 --model ministral-3 --prompt "Hello!" +``` + +#### Connect to MCP server (streamable HTTP) +```bash +# Connect to MCP server via streamable HTTP +python mcpClient.py --mcp-server "http://localhost:8000/mcp" --prompt "Use tools to help me" + +# With authentication headers +python mcpClient.py --mcp-server "http://localhost:8000/mcp" --mcp-headers '{"Authorization": "Bearer token"}' --interactive +``` + +### Client Examples + +```bash +# Simple question +python mcpClient.py --prompt "Explain quantum computing in simple terms" + +# Interactive chat +python mcpClient.py -i + +# Use a different model +python mcpClient.py --model mistral --prompt "Write a haiku about coding" +``` + +### Client Features + +- Connects to local or remote Ollama instances +- Supports chat and generation modes +- **Connect to MCP servers** and use their tools automatically +- Tool registration for extensibility +- Interactive and non-interactive modes +- Health checking for Ollama server +- Automatic tool calling from MCP server tools +- **Specialized Crypto Solver Agent** with built-in knowledge for CTF challenges + +### Crypto Solver Agent + +The client is configured as a specialized Crypto Solver Agent that: + +1. **Exploration**: Lists files in `/tmp` directory to identify relevant challenge files +2. **Analysis**: Identifies cryptographic schemes (RSA, AES, DES, XOR, etc.) and vulnerabilities +3. **Execution**: Writes and executes Python scripts to solve challenges +4. **Validation**: Searches for flags in the format `flag{...}` + +The agent can handle: +- **RSA**: Small modulus factorization, low public exponent attacks, Wiener attack, Hastad attack, common modulus attacks +- **Symmetric Encryption**: AES/DES with various modes (ECB, CBC), IV vulnerabilities, key reuse +- **Classical Ciphers**: Frequency analysis, fixed-key attacks +- **Encoding**: Base64, Hex, Big-Endian/Little-Endian conversions + +### Connecting to an MCP Server + +The client uses **FastMCP** to connect to an existing MCP server via **streamable HTTP**. Once connected, the client: + +1. Automatically loads available tools from the MCP server +2. Passes them to Ollama as usable tools +3. Executes tools when requested by the model +4. Returns results to the model to continue the conversation + +#### Example with MCP Server + +```bash +# Connect to an MCP server via streamable HTTP +python mcpClient.py --mcp-server "http://localhost:8000/mcp" --interactive + +# With authentication headers +python mcpClient.py --mcp-server "http://localhost:8000/mcp" --mcp-headers '{"Authorization": "Bearer your-token"}' --prompt "Use your tools" +``` + +### Default Configuration + +- **Default Ollama URL**: `http://localhost:11434` +- **Default Model**: `ministral-3` +- **Default MCP Server**: `http://localhost:8000/mcp` + +--- + +## Complete Workflow Example + +### 1. Start the MCP Server + +```bash +cd Cariddi +python main.py +``` + +The server will start on `http://localhost:8000/mcp`. + +### 2. Run the Client and Connect to the Server + +In another terminal: + +```bash +cd CariddiClient +python mcpClient.py --mcp-server "http://localhost:8000/mcp" --interactive +``` + +### 3. Use the Crypto Solver Agent + +The client will automatically discover and use the server's tools (like `listFiles`, `readFile`, `writeFile`, `executeCommand`, `writePythonFile`) through Ollama. You can ask it to solve CTF challenges: + +``` +You: Analyze the files in /tmp and solve the crypto challenge +``` + +The agent will: +- List files in `/tmp` +- Read relevant files +- Analyze the cryptographic scheme +- Write and execute Python scripts to solve the challenge +- Return the flag + +--- + +## CTF Challenges + +The `challs/` directory contains CTF challenges for testing the Crypto Solver Agent: + +- **cryptoEasy**: A Diffie-Hellman based challenge with AES encryption + +--- + +## Development + +### Server Development + +The server is built using FastMCP and provides filesystem operations. To add new tools: + +1. Implement the tool function in `modules/filesystem.py` +2. Register it as an MCP tool in `main.py` using `@mcpServer.tool()` + +### Client Development + +The client uses FastMCP for server communication and Ollama for inference. To modify the agent's behavior: + +1. Edit the system prompt in `mcpClient.py` (line 248) +2. Add custom tools using `registerTool()` method +3. Modify the tool execution logic in `_executeTool()` method + +--- + +## License + +[Add your license information here] + +--- + +## Contributing + +[Add contributing guidelines here] diff --git a/challs/cryptoEasy/challenge.py b/challs/cryptoEasy/challenge.py new file mode 100644 index 0000000..285717d --- /dev/null +++ b/challs/cryptoEasy/challenge.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 + +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad, unpad +import os + +def factorial(x): + prod = 1 + for i in range (1,x+1): + prod = prod * i + return prod + +a = 3 +b = 8 +p = 159043501668831001976189741401919059600158436023339250375247150721773143712698491956718970846959154624950002991005143073475212844582380943612898306056733646147380223572684106846684017427300415826606628398091756029258247836173822579694289151452726958472153473864316673552015163436466970719494284188245853583109 +g = factorial(p-1) + +flag = "flag{...}" + +def getDHkey(): + A = pow(g,a,p) + B = pow(g,b,p) + K = pow(B,a,p) + + return K + +def handle(): + keyExchanged = str(getDHkey()) + encryptedFlag = encrypt(flag.encode("utf-8"),keyExchanged) + print("Il messaggio crittografato è: {0}".format(encryptedFlag.hex())) + + return + +def fakePadding(k): + if (len(k) > 16): + raise ValueError('La tua chiave è più lunga di 16 byte') + else: + if len(k) == 16: + return k + else: + missingBytes = 16 - len(k) + for i in range(missingBytes): + k = ''.join([k,"0"]) + return k + +def encrypt(f,k): + key = bytes(fakePadding(k),"utf-8") + + cipher = AES.new(key, AES.MODE_ECB) + encryptedFlag = cipher.encrypt(pad(f, AES.block_size)) + return encryptedFlag + +def decrypt(f, k): + + key = fakePadding(str(k)) + + chiave = bytes(key, "utf-8") + cipher = AES.new(chiave, AES.MODE_ECB) + decryptedFlag = cipher.decrypt(f) + return decryptedFlag + +if __name__ == "__main__": + handle() \ No newline at end of file diff --git a/challs/cryptoEasy/cryptoeasy.txt b/challs/cryptoEasy/cryptoeasy.txt new file mode 100644 index 0000000..85acc78 --- /dev/null +++ b/challs/cryptoEasy/cryptoeasy.txt @@ -0,0 +1,3 @@ +Diffie Hellman è così costoso computazionalmente se si usano valori particolari, come venirne fuori? + +Ciphertext: b5609cfbad99f1b20ec3a93b97f379d8426f934ffcb77d83ea9161fefa78d243 \ No newline at end of file diff --git a/mcpClient/mcpClient.py b/mcpClient/mcpClient.py new file mode 100644 index 0000000..cbe4565 --- /dev/null +++ b/mcpClient/mcpClient.py @@ -0,0 +1,510 @@ +#!/usr/bin/env python3 +""" +Simple MCP client that uses Ollama models for inference. +""" + +from fastmcp.client.transports import NodeStdioTransport, PythonStdioTransport, SSETransport, StreamableHttpTransport + + +import json +import sys +import os +import asyncio +from typing import Optional, Dict, Any, List +import requests +from fastmcp import Client as FastMcpClient + + +class OllamaClient: + """Client for interacting with Ollama API.""" + + def __init__(self, baseUrl: str = "http://localhost:11434", model: str = "gpt-oss:20b"): + self.baseUrl = baseUrl + self.model = model + + def listModels(self) -> List[str]: + """List available Ollama models.""" + try: + response = requests.get(f"{self.baseUrl}/api/tags", timeout=10) + response.raise_for_status() + data = response.json() + return [model["name"] for model in data.get("models", [])] + except requests.RequestException as e: + print(f"Error listing models: {e}", file=sys.stderr) + return [] + + def chat(self, messages: List[Dict[str, str]], options: Optional[Dict[str, Any]] = None) -> str: + """Send chat messages to Ollama and get response.""" + payload = { + "model": self.model, + "messages": messages, + "stream": False, + } + + if options: + payload["options"] = options + + try: + response = requests.post( + f"{self.baseUrl}/api/chat", + json=payload, + timeout=60*60 + ) + response.raise_for_status() + data = response.json() + return data.get("message", {}).get("content", "") + except requests.RequestException as e: + print(f"Error in chat request: {e}", file=sys.stderr) + raise + + def generate(self, prompt: str, options: Optional[Dict[str, Any]] = None) -> str: + """Generate text from a prompt using Ollama.""" + payload = { + "model": self.model, + "prompt": prompt, + "stream": False, + } + + if options: + payload["options"] = options + + try: + response = requests.post( + f"{self.baseUrl}/api/generate", + json=payload, + timeout=120 + ) + response.raise_for_status() + data = response.json() + return data.get("response", "") + except requests.RequestException as e: + print(f"Error in generate request: {e}", file=sys.stderr) + raise + + def checkHealth(self) -> bool: + """Check if Ollama server is accessible.""" + try: + response = requests.get(f"{self.baseUrl}/api/tags", timeout=5) + return response.status_code == 200 + except requests.RequestException: + return False + + +class McpServerWrapper: + """Wrapper around FastMCP Client for easier use.""" + + def __init__(self, httpUrl: str, headers: Optional[Dict[str, str]] = None): + self.httpUrl = httpUrl.rstrip("/") + self.headers = headers or {} + self.client: Optional[FastMcpClient] = None + self.serverTools: List[Dict[str, Any]] = [] + + async def connect(self) -> bool: + """Connect and initialize with MCP server via HTTP.""" + try: + # FastMcpClient doesn't support headers parameter directly + # Headers would need to be passed via custom transport or auth + # For now, we initialize without headers + self.client = FastMcpClient(self.httpUrl) + await self.client.__aenter__() + # Load tools after connection + tools = await self.listServerTools() + return True + except Exception as e: + print(f"Error connecting to MCP server: {e}", file=sys.stderr) + return False + + async def disconnect(self): + """Disconnect from MCP server.""" + if self.client: + await self.client.__aexit__(None, None, None) + self.client = None + + async def listServerTools(self) -> List[Dict[str, Any]]: + """List tools available from MCP server.""" + if not self.client: + return [] + + try: + tools = await self.client.list_tools() + self.serverTools = tools + return tools + except Exception as e: + print(f"Error listing tools: {e}", file=sys.stderr) + return [] + + async def callServerTool(self, name: str, arguments: Dict[str, Any]) -> Any: + """Call a tool on the MCP server.""" + if not self.client: + raise RuntimeError("Not connected to MCP server") + + try: + result = await self.client.call_tool(name, arguments) + # FastMCP call_tool returns a result object with .content + if hasattr(result, 'content'): + content = result.content + # If content is a list, return it as is (will be serialized later) + if isinstance(content, list): + return content + return content + elif isinstance(result, list): + # Handle list of results + if len(result) > 0: + # Extract content from each item if it exists + contents = [] + for item in result: + if hasattr(item, 'content'): + contents.append(item.content) + else: + contents.append(item) + return contents if len(contents) > 1 else contents[0] if contents else None + return result + return result + except Exception as e: + raise RuntimeError(f"Tool call failed: {str(e)}") + + async def listServerResources(self) -> List[Dict[str, Any]]: + """List resources available from MCP server.""" + if not self.client: + return [] + + try: + resources = await self.client.list_resources() + return resources + except Exception as e: + print(f"Error listing resources: {e}", file=sys.stderr) + return [] + + +class OllamaMcpClient: + """Simple MCP client that uses Ollama for inference.""" + + def __init__(self, ollamaClient: OllamaClient, mcpServer: Optional[McpServerWrapper] = None): + self.ollamaClient = ollamaClient + self.mcpServer = mcpServer + self.tools: List[Dict[str, Any]] = [] + self.resources: List[Dict[str, Any]] = [] + + def _serializeToolResult(self, result: Any) -> Any: + """Serialize tool result to JSON-serializable format.""" + # Handle TextContent and other content objects + if hasattr(result, 'text'): + return result.text + if hasattr(result, 'content'): + content = result.content + if hasattr(content, 'text'): + return content.text + return content + # Handle lists of content objects + if isinstance(result, list): + return [self._serializeToolResult(item) for item in result] + # Handle dicts + if isinstance(result, dict): + return {k: self._serializeToolResult(v) for k, v in result.items()} + # Already serializable (str, int, float, bool, None) + return result + + async def _loadServerTools(self): + """Load tools from connected MCP server.""" + if self.mcpServer: + serverTools = await self.mcpServer.listServerTools() + for tool in serverTools: + # Handle both Pydantic Tool objects and dicts + if hasattr(tool, "name"): + # Pydantic Tool object - access attributes directly + name = getattr(tool, "name", "") + description = getattr(tool, "description", "") + # Try both camelCase and snake_case for inputSchema + inputSchema = getattr(tool, "inputSchema", getattr(tool, "input_schema", {})) + else: + # Dict - use .get() + name = tool.get("name", "") + description = tool.get("description", "") + inputSchema = tool.get("inputSchema", tool.get("input_schema", {})) + + self.tools.append({ + "name": name, + "description": description, + "inputSchema": inputSchema + }) + + def registerTool(self, name: str, description: str, parameters: Dict[str, Any]): + """Register a tool that can be used by the model.""" + self.tools.append({ + "name": name, + "description": description, + "inputSchema": { + "type": "object", + "properties": parameters, + "required": list(parameters.keys()) + } + }) + + async def processRequest(self, prompt: str, context: Optional[List[str]] = None, maxIterations: int = 5) -> str: + """Process a request using Ollama with optional context and tool support.""" + messages = [ + { + "role": "system", + "content": """Sei un Crypto Solver Agent specializzato in sfide CTF (Capture The Flag). Il tuo obiettivo primario è identificare, analizzare e risolvere sfide crittografiche memorizzate nella directory /tmp per recuperare la flag. REGOLE OPERATIVE: Esplorazione: Inizia sempre elencando i file presenti in /tmp. Identifica i file rilevanti come sorgenti Python (.py), output di testo (.txt), file cifrati o chiavi pubbliche/private (.pem, .pub). Analisi: Leggi i file trovati. Determina il tipo di crittografia coinvolta. Casi comuni: RSA: analizza parametri come n, e, c. Verifica se n è piccolo (fattorizzabile), se e è basso (attacco radice e-esima) o se ci sono vulnerabilità note (Wiener, Hastad, moduli comuni). Simmetrica (AES/DES): cerca la modalità (ECB, CBC), vulnerabilità nel IV, o riutilizzo della chiave. XOR/Cifrari Classici: esegui analisi delle frequenze o attacchi a chiave fissa. Encoding: gestisci correttamente Base64, Hex, Big-Endian/Little-Endian. Esecuzione: Scrivi ed esegui script Python per risolvere la sfida. Utilizza librerie come pycryptodome, gmpy2 o sympy se disponibili nell'ambiente. Non limitarti a spiegare la teoria: scrivi il codice necessario a produrre il plaintext. Validazione: Una volta decifrato il contenuto, cerca stringhe nel formato flag{...}. Se il risultato non è leggibile, rivaluta l'approccio e prova una strategia alternativa. REQUISITI DI OUTPUT: Fornisci una breve spiegazione della vulnerabilità trovata. Mostra il codice Python risolutivo che hai generato. Restituisci la flag finale in modo chiaramente visibile. LIMITI: Opera esclusivamente all'interno della directory /tmp. Non tentare di forzare la password di sistema; concentrati sulla logica crittografica. Se mancano dati (es. un file citato nel codice non è presente), chiedi esplicitamente o cercalo nelle sottocartelle di /tmp. Inizia ora analizzando il contenuto di /tmp.""" + } + ] + + if context: + messages.append({ + "role": "system", + "content": f"Context:\n{'\n\n'.join(context)}" + }) + + if self.tools: + toolDescriptions = json.dumps(self.tools, indent=2) + messages.append({ + "role": "system", + "content": f"Available tools:\n{toolDescriptions}\n\nTo use a tool, respond with JSON: {{\"tool_name\": \"name\", \"tool_args\": {{...}}}}" + }) + + messages.append({ + "role": "user", + "content": prompt + }) + + iteration = 0 + while iteration < maxIterations: + response = self.ollamaClient.chat(messages) + + # Check if response contains tool call + toolCall = self._parseToolCall(response) + if toolCall: + toolName = toolCall.get("tool_name") + toolArgs = toolCall.get("tool_args", {}) + + # Print agent intent (response before tool call) + print(f"\n[Agent Intent]: {response}", file=sys.stderr) + print(f"[Tool Call Detected]: {toolName} with arguments: {toolArgs}", file=sys.stderr) + + # Try to call the tool + try: + print(f"[Executing Tool]: {toolName} with arguments: {toolArgs}", file=sys.stderr) + toolResult = await self._executeTool(toolName, toolArgs) + # Serialize tool result to JSON-serializable format + serializedResult = self._serializeToolResult(toolResult) + print(f"[Tool Output]: {json.dumps(serializedResult, indent=2)}", file=sys.stderr) + messages.append({ + "role": "assistant", + "content": response + }) + messages.append({ + "role": "user", + "content": f"Tool result: {json.dumps(serializedResult)}" + }) + iteration += 1 + continue + except Exception as e: + print(f"[Tool Error]: {str(e)}", file=sys.stderr) + messages.append({ + "role": "assistant", + "content": response + }) + messages.append({ + "role": "user", + "content": f"Tool error: {str(e)}" + }) + iteration += 1 + continue + + # No tool call, return response + print(f"\n[Agent Response (Final)]: {response}", file=sys.stderr) + return response + + return messages[-1].get("content", "Max iterations reached") + + def _parseToolCall(self, response: str) -> Optional[Dict[str, Any]]: + """Try to parse tool call from response.""" + # Try to find JSON object in response + try: + # Look for JSON in response + startIdx = response.find("{") + endIdx = response.rfind("}") + 1 + if startIdx >= 0 and endIdx > startIdx: + jsonStr = response[startIdx:endIdx] + parsed = json.loads(jsonStr) + if "tool_name" in parsed: + return parsed + except: + pass + return None + + async def _executeTool(self, toolName: str, toolArgs: Dict[str, Any]) -> Any: + """Execute a tool - either from server or local.""" + # First check if it's a server tool + if self.mcpServer: + # Check if tool exists in server tools + for tool in self.mcpServer.serverTools: + # Handle both Pydantic Tool objects and dicts + tool_name = getattr(tool, "name", None) if hasattr(tool, "name") else tool.get("name") if isinstance(tool, dict) else None + if tool_name == toolName: + return await self.mcpServer.callServerTool(toolName, toolArgs) + + # Check local tools + if toolName == "get_time": + from datetime import datetime + return datetime.now().isoformat() + elif toolName == "count_words": + text = toolArgs.get("text", "") + return len(text.split()) + + raise ValueError(f"Tool '{toolName}' not found") + + def listTools(self) -> List[Dict[str, Any]]: + """List all registered tools.""" + return self.tools + + def listResources(self) -> List[Dict[str, Any]]: + """List all available resources.""" + return self.resources + + +async def async_main(args, ollamaClient: OllamaClient): + """Async main function.""" + # Connect to MCP server if specified + mcpServerWrapper = None + if args.mcp_server: + headers = {} + if args.mcp_headers: + try: + headers = json.loads(args.mcp_headers) + except json.JSONDecodeError: + print("Warning: Invalid JSON in --mcp-headers, ignoring", file=sys.stderr) + + mcpServerWrapper = McpServerWrapper(httpUrl=args.mcp_server, headers=headers) + if not await mcpServerWrapper.connect(): + print("Error: Failed to connect to MCP server", file=sys.stderr) + sys.exit(1) + print("Connected to MCP server via streamable HTTP", file=sys.stderr) + + # Initialize MCP client + mcpClient = OllamaMcpClient(ollamaClient, mcpServerWrapper) + + # Load server tools + if mcpServerWrapper: + await mcpClient._loadServerTools() + serverTools = await mcpServerWrapper.listServerTools() + if serverTools: + # Handle both Pydantic Tool objects and dicts + tool_names = [ + getattr(t, "name", "") if hasattr(t, "name") else t.get("name", "") if isinstance(t, dict) else "" + for t in serverTools + ] + print(f"Available MCP server tools: {tool_names}", file=sys.stderr) + + # Register some example tools + mcpClient.registerTool( + name="get_time", + description="Get the current time", + parameters={} + ) + mcpClient.registerTool( + name="count_words", + description="Count words in a text", + parameters={ + "text": { + "type": "string", + "description": "The text to count words in" + } + } + ) + + # Process prompt or run interactively + if args.prompt: + response = await mcpClient.processRequest(args.prompt) + print(response) + elif args.interactive: + print("MCP Client with Ollama - Interactive Mode") + print("Type 'quit' or 'exit' to exit\n") + while True: + try: + prompt = input("You: ").strip() + if prompt.lower() in ["quit", "exit"]: + break + if not prompt: + continue + response = await mcpClient.processRequest(prompt) + print(f"Assistant: {response}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + break + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + + # Cleanup + if mcpServerWrapper: + await mcpServerWrapper.disconnect() + + +def main(): + """Main function to run the MCP client.""" + import argparse + + parser = argparse.ArgumentParser(description="MCP client using Ollama") + parser.add_argument( + "--base-url", + default="http://localhost:11434", + help="Ollama base URL (default: http://localhost:11434)" + ) + parser.add_argument( + "--model", + default="ministral-3", + help="Ollama model to use (default: ministral-3)" + ) + parser.add_argument( + "--list-models", + action="store_true", + help="List available Ollama models and exit" + ) + parser.add_argument( + "--prompt", + help="Prompt to send to the model" + ) + parser.add_argument( + "--interactive", + "-i", + action="store_true", + help="Run in interactive mode" + ) + parser.add_argument( + "--mcp-server", + help="HTTP URL for MCP server (e.g., 'http://localhost:8000/mcp')", + default="http://localhost:8000/mcp" + ) + parser.add_argument( + "--mcp-headers", + help="Additional headers for MCP server as JSON string (e.g., '{\"Authorization\": \"Bearer token\"}')" + ) + + args = parser.parse_args() + + # Initialize Ollama client + ollamaClient = OllamaClient(baseUrl=args.base_url, model=args.model) + + # Check health + if not ollamaClient.checkHealth(): + print(f"Error: Cannot connect to Ollama at {args.base_url}", file=sys.stderr) + print("Make sure Ollama is running and accessible.", file=sys.stderr) + sys.exit(1) + + # List models if requested + if args.list_models: + models = ollamaClient.listModels() + print("Available models:") + for model in models: + print(f" - {model}") + sys.exit(0) + + # Run async main + asyncio.run(async_main(args, ollamaClient)) + + if not args.prompt and not args.interactive: + parser.print_help() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/mcpClient/requirements.txt b/mcpClient/requirements.txt new file mode 100644 index 0000000..34061a6 --- /dev/null +++ b/mcpClient/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31.0 +fastmcp>=0.9.0 \ No newline at end of file diff --git a/mcpServer/.dockerignore b/mcpServer/.dockerignore new file mode 100644 index 0000000..8ad7962 --- /dev/null +++ b/mcpServer/.dockerignore @@ -0,0 +1,39 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +wheels/ + +# Virtual environments +venv/ +.venv/ +env/ +ENV/ + +# Git +.git/ +.gitignore + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker +Dockerfile +.dockerignore +docker-compose.yml + +# Documentation +README.md diff --git a/mcpServer/Dockerfile b/mcpServer/Dockerfile new file mode 100644 index 0000000..c857e23 --- /dev/null +++ b/mcpServer/Dockerfile @@ -0,0 +1,27 @@ +# Use Python 3.12 slim image +FROM python:3.12-slim + +# Set working directory +WORKDIR /app + +# Set environment variables +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + FASTMCP_HOST=0.0.0.0 \ + FASTMCP_PORT=8000 + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY modules/ ./modules/ +COPY main.py . + +# Expose port 8000 for the MCP server +EXPOSE 8000 + +# Run the MCP server +CMD ["python", "main.py"] diff --git a/mcpServer/docker-compose.yml b/mcpServer/docker-compose.yml new file mode 100644 index 0000000..096c2a9 --- /dev/null +++ b/mcpServer/docker-compose.yml @@ -0,0 +1,11 @@ +services: + mcp-server: + build: . + container_name: cariddi-mcp-server + ports: + - "8000:8000" + environment: + - PYTHONUNBUFFERED=1 + - FASTMCP_HOST=0.0.0.0 + - FASTMCP_PORT=8000 + restart: unless-stopped \ No newline at end of file diff --git a/mcpServer/main.py b/mcpServer/main.py new file mode 100644 index 0000000..5cfc62a --- /dev/null +++ b/mcpServer/main.py @@ -0,0 +1,41 @@ +import os +from mcp.server.fastmcp import FastMCP +from modules.filesystem import internal_listFiles, internal_readFile, internal_writeFile, internal_executeCommand, internal_writePythonFile + +mcpServer = FastMCP( + "Cariddi", + host=os.getenv("FASTMCP_HOST", os.getenv("MCP_HOST", "0.0.0.0")), + port=int(os.getenv("FASTMCP_PORT", os.getenv("MCP_PORT", "8000"))), +) + +@mcpServer.tool() +def listFiles(path: str) -> list[str]: + """List all files in the given path""" + return internal_listFiles(path) + +@mcpServer.tool() +def readFile(path: str) -> str: + """Read the contents of a file""" + return internal_readFile(path) + +@mcpServer.tool() +def writeFile(path: str, content: str) -> bool: + """Write the contents of a file""" + return internal_writeFile(path, content) + +@mcpServer.tool() +def executeCommand(command: str) -> dict: + """Execute a command""" + return internal_executeCommand(command) + +@mcpServer.tool() +def writePythonFile(path: str, content: str) -> str: + """Write a Python file handling streaming and escape characters correctly.""" + return internal_writePythonFile(path, content) + + +if __name__ == "__main__": + try: + mcpServer.run(transport="streamable-http") + except KeyboardInterrupt: + print("Server stopped by user") \ No newline at end of file diff --git a/mcpServer/mcp.json b/mcpServer/mcp.json new file mode 100644 index 0000000..6965c64 --- /dev/null +++ b/mcpServer/mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "cariddi": { + "url": "http://localhost:8000/mcp" + } + } +} diff --git a/mcpServer/modules/filesystem.py b/mcpServer/modules/filesystem.py new file mode 100644 index 0000000..fd5fbc5 --- /dev/null +++ b/mcpServer/modules/filesystem.py @@ -0,0 +1,61 @@ +import os +from typing import List +import subprocess +def internal_listFiles(path: str) -> List[str]: + """List all files in the given path""" + if os.path.exists(path) and os.path.isdir(path): + result = os.listdir(path) + return result + print(f"Path does not exist or is not a directory: {path}") + return [] + +def internal_readFile(path: str) -> str: + """Read the contents of a file""" + if os.path.exists(path) and os.path.isfile(path): + try: + with open(path, "r", encoding="utf-8") as f: + content = f.read() + return content + except Exception as e: + error_msg = f"Error reading file: {str(e)}" + return error_msg + return "" + +def internal_writeFile(path: str, content: str) -> bool: + """Write the contents of a file""" + try: + with open(path, "w", encoding="utf-8") as f: + f.write(content) + return True + except Exception as e: + print(f"Error writing file: {str(e)}") + return False + +def internal_executeCommand(command: str) -> str: + """Execute a command""" + try: + result = subprocess.run(command, shell=True, capture_output=True, text=True) + output = { + "stderr": result.stderr, + "stdout": result.stdout, + "returncode": result.returncode + } + return output + except Exception as e: + print(f"Error executing command: {str(e)}") + return "" + +def internal_writePythonFile(path: str, content: str) -> str: + """Write a Python file handling streaming and escape characters correctly.""" + content = content.encode('utf-8').decode('unicode_escape') if '\\n' in content else content + if "```python" in content: + content = content.split("```python")[1].split("```")[0].strip() + elif "```" in content: + content = content.split("```")[1].split("```")[0].strip() + + try: + with open(path, "w", encoding="utf-8") as f: + f.write(content) + return f"File saved correctly in {path}" + except Exception as e: + return f"Error: {str(e)}" \ No newline at end of file diff --git a/mcpServer/requirements.txt b/mcpServer/requirements.txt new file mode 100644 index 0000000..344ab7b --- /dev/null +++ b/mcpServer/requirements.txt @@ -0,0 +1 @@ +mcp[cli]>=1.25.0