diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e4b1744 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# Docker ignore file + +# Virtual environment +venv/ +env/ +.venv/ + +# Python cache +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.pytest_cache/ +.coverage + +# Logs +logs/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Test files +test_*.py +*_test.py \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..69d54b3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Dockerfile for Reddit MCP Server +FROM python:3.9-slim + +# Set working directory +WORKDIR /app + +# Copy requirements first (for better caching) +COPY requirements.txt . + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY src/ ./src/ +COPY run_mcp_server.sh . + +# Make the run script executable +RUN chmod +x run_mcp_server.sh + +# Expose port +EXPOSE 5000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:5000/health || exit 1 + +# Default command +CMD ["./run_mcp_server.sh"] \ No newline at end of file diff --git a/README.md b/README.md index 24eb3ba..ac6f7c6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A command-line interface for browsing Reddit posts with search capabilities, int - **Comments Viewer**: Cycle through comments using 'c' key - **AI Integration**: Get AI-generated summaries using Ollama API - **Pagination**: Navigate between pages of results ('n' key) +- **MCP Server**: REST API endpoints for Reddit querying with resilience and retry logic ## Project Structure @@ -22,7 +23,7 @@ reddit-cli/ ├── src/ │ ├── __init__.py │ ├── main.py # Main application entrypoint -│ ├── reddit_client.py # Reddit API client implementation +│ ├── mcp_server.py # MCP server implementation with REST endpoints │ └── ai_client.py # Ollama AI integration ├── tests/ │ └── test_cli.py # Unit tests @@ -53,6 +54,7 @@ pip install -r requirements.txt ## Usage +### Running the CLI Interface Run the CLI interface with a search query: ```bash python -m src.main "python programming" @@ -63,6 +65,51 @@ Or run without arguments to enter interactive mode: python -m src.main ``` +### Running the MCP Server +Start the MCP server: +```bash +python src/mcp_server.py +``` + +The server will be available at `http://localhost:5000` + +### MCP Server Endpoints + +#### Search Posts +``` +GET /search?q={query}&limit={limit}&after={after} +``` + +#### Get Post Details +``` +GET /posts/{post_id} +``` + +#### Get Post Comments +``` +GET /posts/{post_id}/comments?limit={limit} +``` + +#### Get AI Summary +``` +GET /posts/{post_id}/summary +``` + +#### Get Trending Posts +``` +GET /trending?limit={limit}&after={after} +``` + +#### Health Check +``` +GET /health +``` + +#### OpenAPI Specification +``` +GET /openapi.json +``` + ## Keyboard Controls - `n` - Go to next page of results @@ -77,6 +124,7 @@ The application uses environment variables for configuration: - `OLLAMA_BASE_URL` - Ollama server address (default: http://192.168.8.223:11434) - `OLLAMA_MODEL` - AI model to use (default: gpt-oss:20b) +- `PORT` - MCP server port (default: 5000) ## Requirements @@ -84,6 +132,8 @@ The application uses environment variables for configuration: - requests - rich - pyyaml +- flask +- flask-cors - pytest - pytest-cov @@ -99,6 +149,11 @@ Or run basic functionality checks: python test_cli.py ``` +Run MCP server tests: +```bash +python test_mcp_server.py +``` + ## Contributing 1. Fork the repository diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e40d7fb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +version: '3.8' + +services: + reddit-mcp-server: + build: . + ports: + - "5000:5000" + environment: + - OLLAMA_BASE_URL=http://host.docker.internal:11434 + - OLLAMA_MODEL=gpt-oss:20b + - PORT=5000 + volumes: + - ./logs:/app/logs + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + restart: unless-stopped + networks: + - reddit-network + + # Optional: Ollama service for development (uncomment if needed) + # ollama: + # image: ollama/ollama:latest + # ports: + # - "11434:11434" + # volumes: + # - ollama-data:/root/.ollama + # networks: + # - reddit-network + +volumes: + ollama-data: + +networks: + reddit-network: + driver: bridge \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2a0b36b..f5bf226 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,8 @@ requests>=2.20.0 rich>=10.0.0 pyyaml>=5.4.0 +flask>=2.0.0 +flask-cors>=3.0.0 # Testing pytest>=6.0.0 diff --git a/run_mcp_server.sh b/run_mcp_server.sh new file mode 100755 index 0000000..d972cfc --- /dev/null +++ b/run_mcp_server.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +# Script to run the MCP server +# This script demonstrates how to start the MCP server with proper environment setup + +echo "Starting Reddit MCP Server..." + +# Check if virtual environment exists +if [ ! -d "venv" ]; then + echo "Virtual environment not found. Please run setup.sh first." + exit 1 +fi + +# Activate virtual environment +source venv/bin/activate + +# Set default environment variables if not already set +export OLLAMA_BASE_URL="${OLLAMA_BASE_URL:-http://192.168.8.223:11434}" +export OLLAMA_MODEL="${OLLAMA_MODEL:-gpt-oss:20b}" +export PORT="${PORT:-5000}" + +echo "Environment variables:" +echo " OLLAMA_BASE_URL: $OLLAMA_BASE_URL" +echo " OLLAMA_MODEL: $OLLAMA_MODEL" +echo " PORT: $PORT" + +echo "" +echo "Starting MCP server on port $PORT..." +echo "Server will be available at http://localhost:$PORT" +echo "" +echo "Available endpoints:" +echo " GET /search?q={query}&limit={limit}&after={after}" +echo " GET /posts/{post_id}" +echo " GET /posts/{post_id}/comments?limit={limit}" +echo " GET /posts/{post_id}/summary" +echo " GET /trending?limit={limit}&after={after}" +echo " GET /health" +echo " GET /openapi.json" +echo "" +echo "Press Ctrl+C to stop the server" + +# Run the MCP server +python src/mcp_server.py + +echo "Server stopped." \ No newline at end of file diff --git a/src/mcp_server.py b/src/mcp_server.py new file mode 100644 index 0000000..98170f0 --- /dev/null +++ b/src/mcp_server.py @@ -0,0 +1,775 @@ +#!/usr/bin/env python3 +""" +MCP Server for Reddit CLI +Exposes Reddit querying capabilities through MCP endpoints with resilience and retry logic. +""" + +import asyncio +import json +import logging +import os +import time +from typing import Any, Dict, List, Optional +from urllib.parse import quote + +import requests +from flask import Flask, jsonify, request +from flask_cors import CORS + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize Flask app +app = Flask(__name__) +CORS(app) + +# Global configuration +OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://192.168.8.223:11434") +OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "gpt-oss:20b") +REDDIT_BASE_URL = "https://www.reddit.com" + +class RedditClient: + """Client for interacting with Reddit API with retry logic""" + + def __init__(self): + self.base_url = REDDIT_BASE_URL + self.session = requests.Session() + self.session.headers.update({"User-Agent": "RedditCLI/0.1 by User"}) + + def _make_request_with_retry(self, url: str, params: Dict = None, max_retries: int = 3, + retry_delay: float = 1.0) -> requests.Response: + """ + Make HTTP request with exponential backoff retry logic + + Args: + url: Request URL + params: Request parameters + max_retries: Maximum number of retry attempts + retry_delay: Initial delay between retries (seconds) + + Returns: + Response object + + Raises: + requests.exceptions.RequestException: If all retries fail + """ + for attempt in range(max_retries + 1): + try: + response = self.session.get(url, params=params, timeout=10) + response.raise_for_status() + return response + except requests.exceptions.RequestException as e: + if attempt < max_retries: + logger.warning(f"Attempt {attempt + 1} failed: {e}. Retrying in {retry_delay} seconds...") + time.sleep(retry_delay) + retry_delay *= 2 # Exponential backoff + else: + logger.error(f"All {max_retries + 1} attempts failed for {url}") + raise e + + def search_posts( + self, query: str, limit: int = 15, after: Optional[str] = None + ) -> Dict[str, Any]: + """ + Search for Reddit posts matching the query with retry logic + + Args: + query: Search terms + limit: Number of posts to return (default 15) + after: Pagination token for next page + + Returns: + Dictionary containing search results and pagination info + """ + params = {"q": query, "limit": limit, "sort": "hot", "type": "link"} + + if after: + params["after"] = after + + try: + response = self._make_request_with_retry( + f"{self.base_url}/search.json", params=params + ) + return response.json() + except requests.exceptions.RequestException as e: + raise Exception(f"Failed to fetch posts: {str(e)}") + + def get_post_details(self, post_id: str) -> Dict[str, Any]: + """ + Get detailed information about a specific post with retry logic + + Args: + post_id: Reddit post ID + + Returns: + Dictionary with post details + """ + try: + response = self._make_request_with_retry( + f"{self.base_url}/by_id/t3_{post_id}.json" + ) + data = response.json() + + # Extract post from the response structure + if isinstance(data, list) and len(data) > 0: + return data[0].get("data", {}) + elif isinstance(data, dict): + return data.get("data", {}) + + return {} + except requests.exceptions.RequestException as e: + raise Exception(f"Failed to fetch post details: {str(e)}") + + def get_post_comments(self, post_id: str, limit: int = 100) -> List[Dict[str, Any]]: + """ + Get comments for a specific post with retry logic + + Args: + post_id: Reddit post ID + limit: Maximum number of comments to fetch + + Returns: + List of comment dictionaries + """ + try: + response = self._make_request_with_retry( + f"{self.base_url}/comments/{post_id}.json", + params={"limit": limit} + ) + data = response.json() + + # Extract comments from the nested structure + comments = [] + if isinstance(data, list) and len(data) > 1: + comment_data = data[1].get("data", {}).get("children", []) + for child in comment_data: + comment = child.get("data", {}) + # Flatten the comment structure to include author and body + comments.append( + { + "author": comment.get("author", "unknown"), + "body": comment.get("body", ""), + "score": comment.get("score", 0), + "created_utc": comment.get("created_utc", 0), + } + ) + + return comments + except requests.exceptions.RequestException as e: + raise Exception(f"Failed to fetch comments: {str(e)}") + + def format_timestamp(self, timestamp: int) -> str: + """ + Format Unix timestamp into readable date string + + Args: + timestamp: Unix timestamp + + Returns: + Formatted date string + """ + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) + + +class AIClient: + """Client for interacting with Ollama AI API with retry logic""" + + def __init__(self): + self.base_url = OLLAMA_BASE_URL + self.model = OLLAMA_MODEL + self.session = requests.Session() + + def _make_request_with_retry(self, url: str, json_data: Dict = None, max_retries: int = 3, + retry_delay: float = 1.0) -> requests.Response: + """ + Make HTTP request with exponential backoff retry logic + + Args: + url: Request URL + json_data: JSON data to send + max_retries: Maximum number of retry attempts + retry_delay: Initial delay between retries (seconds) + + Returns: + Response object + + Raises: + requests.exceptions.RequestException: If all retries fail + """ + for attempt in range(max_retries + 1): + try: + response = self.session.post(url, json=json_data, timeout=30) + response.raise_for_status() + return response + except requests.exceptions.RequestException as e: + if attempt < max_retries: + logger.warning(f"Attempt {attempt + 1} failed: {e}. Retrying in {retry_delay} seconds...") + time.sleep(retry_delay) + retry_delay *= 2 # Exponential backoff + else: + logger.error(f"All {max_retries + 1} attempts failed for {url}") + raise e + + def generate_summary(self, post_body: str, comments: List[str]) -> str: + """ + Generate AI summary of a post with comments and retry logic + + Args: + post_body: The main body text of the post + comments: List of comment strings + + Returns: + Generated summary from AI + """ + # Select 100 random comments (or all if less than 100) + selected_comments = comments[:100] + + # Format prompt for the AI model + prompt = self._create_prompt(post_body, selected_comments) + + try: + response = self._make_request_with_retry( + f"{self.base_url}/api/generate", + json={"model": self.model, "prompt": prompt, "stream": False} + ) + data = response.json() + return data.get("response", "").strip() + except requests.exceptions.RequestException as e: + raise Exception(f"Failed to generate AI summary: {str(e)}") + + def _create_prompt(self, post_body: str, comments: List[str]) -> str: + """ + Create a formatted prompt for the AI with post and comments + + Args: + post_body: The main body text of the post + comments: List of comment strings + + Returns: + Formatted prompt string + """ + # Join comments into a single string with proper formatting + comments_text = "\n".join( + [f"Comment {i + 1}: {comment}" for i, comment in enumerate(comments)] + ) + + if not comments_text: + comments_text = "No comments available." + + prompt = f""" + Summarize the following Reddit post and its comments in 2-3 sentences. + + Post: + {post_body} + + Comments: + {comments_text} + + Summary: + """ + + return prompt + + +# Initialize clients +reddit_client = RedditClient() +ai_client = AIClient() + +@app.route('/search', methods=['GET']) +def search_posts(): + """Search for Reddit posts""" + try: + query = request.args.get('q', '') + limit = int(request.args.get('limit', 15)) + after = request.args.get('after', None) + + if not query: + return jsonify({"error": "Query parameter 'q' is required"}), 400 + + data = reddit_client.search_posts(query, limit, after) + + # Extract posts from response + posts = [] + + # Check if we have data in the expected response format + if "data" in data and "children" in data["data"]: + for child in data["data"]["children"]: + post_data = child.get("data", {}) + if post_data: + posts.append( + { + "id": post_data.get("id"), + "title": post_data.get("title", "No title"), + "subreddit": post_data.get("subreddit", "unknown"), + "created_utc": post_data.get("created_utc", 0), + "url": post_data.get("url", ""), + "body": post_data.get( + "selftext", post_data.get("body", "") + ), + "score": post_data.get("score", 0), + } + ) + + # Get the after token for pagination + after_token = data["data"].get("after") + else: + after_token = None + + response_data = { + "posts": posts, + "after": after_token, + "query": query, + "limit": limit + } + + return jsonify(response_data) + + except Exception as e: + logger.error(f"Error in search_posts: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/posts/', methods=['GET']) +def get_post_details(post_id): + """Get detailed information about a specific post""" + try: + post_data = reddit_client.get_post_details(post_id) + + if not post_data: + return jsonify({"error": "Post not found"}), 404 + + # Format the response + response_data = { + "id": post_data.get("id"), + "title": post_data.get("title", "No title"), + "subreddit": post_data.get("subreddit", "unknown"), + "created_utc": post_data.get("created_utc", 0), + "url": post_data.get("url", ""), + "body": post_data.get("selftext", post_data.get("body", "")), + "score": post_data.get("score", 0), + "author": post_data.get("author", "unknown"), + "permalink": post_data.get("permalink", ""), + "num_comments": post_data.get("num_comments", 0) + } + + return jsonify(response_data) + + except Exception as e: + logger.error(f"Error in get_post_details: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/posts//comments', methods=['GET']) +def get_post_comments(post_id): + """Get comments for a specific post""" + try: + limit = int(request.args.get('limit', 100)) + comments = reddit_client.get_post_comments(post_id, limit) + + response_data = { + "post_id": post_id, + "comments": comments, + "count": len(comments) + } + + return jsonify(response_data) + + except Exception as e: + logger.error(f"Error in get_post_comments: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/posts//summary', methods=['GET']) +def get_post_summary(post_id): + """Get AI-generated summary for a post""" + try: + # First get the post details + post_data = reddit_client.get_post_details(post_id) + if not post_data: + return jsonify({"error": "Post not found"}), 404 + + # Get comments + comments = reddit_client.get_post_comments(post_id, limit=100) + + # Generate summary + post_body = post_data.get("selftext", post_data.get("body", "")) + comment_bodies = [comment.get("body", "") for comment in comments] + + summary = ai_client.generate_summary(post_body, comment_bodies) + + response_data = { + "post_id": post_id, + "summary": summary, + "post_title": post_data.get("title", "No title"), + "subreddit": post_data.get("subreddit", "unknown") + } + + return jsonify(response_data) + + except Exception as e: + logger.error(f"Error in get_post_summary: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/trending', methods=['GET']) +def get_trending(): + """Get trending posts""" + try: + limit = int(request.args.get('limit', 15)) + after = request.args.get('after', None) + + # Use the same search parameters but with different sort + params = {"q": "all", "limit": limit, "sort": "top", "type": "link"} + + if after: + params["after"] = after + + data = reddit_client.search_posts("all", limit, after) + + # Extract posts from response + posts = [] + + # Check if we have data in the expected response format + if "data" in data and "children" in data["data"]: + for child in data["data"]["children"]: + post_data = child.get("data", {}) + if post_data: + posts.append( + { + "id": post_data.get("id"), + "title": post_data.get("title", "No title"), + "subreddit": post_data.get("subreddit", "unknown"), + "created_utc": post_data.get("created_utc", 0), + "url": post_data.get("url", ""), + "body": post_data.get( + "selftext", post_data.get("body", "") + ), + "score": post_data.get("score", 0), + } + ) + + # Get the after token for pagination + after_token = data["data"].get("after") + else: + after_token = None + + response_data = { + "posts": posts, + "after": after_token, + "limit": limit + } + + return jsonify(response_data) + + except Exception as e: + logger.error(f"Error in get_trending: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/health', methods=['GET']) +def health_check(): + """Health check endpoint""" + return jsonify({"status": "healthy", "service": "reddit-mcp-server"}) + + +@app.route('/openapi.json', methods=['GET']) +def openapi_spec(): + """Serve OpenAPI specification""" + spec = { + "openapi": "3.0.0", + "info": { + "title": "Reddit MCP API", + "version": "1.0.0", + "description": "API for querying Reddit posts with MCP server capabilities" + }, + "servers": [ + { + "url": "http://localhost:5000", + "description": "Local development server" + } + ], + "paths": { + "/search": { + "get": { + "summary": "Search Reddit posts", + "description": "Search for Reddit posts by query term", + "parameters": [ + { + "name": "q", + "in": "query", + "required": True, + "schema": { + "type": "string" + }, + "description": "Search query" + }, + { + "name": "limit", + "in": "query", + "required": False, + "schema": { + "type": "integer", + "default": 15 + }, + "description": "Number of posts to return" + }, + { + "name": "after", + "in": "query", + "required": False, + "schema": { + "type": "string" + }, + "description": "Pagination token" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "posts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "title": {"type": "string"}, + "subreddit": {"type": "string"}, + "created_utc": {"type": "integer"}, + "url": {"type": "string"}, + "body": {"type": "string"}, + "score": {"type": "integer"} + } + } + }, + "after": {"type": "string"}, + "query": {"type": "string"}, + "limit": {"type": "integer"} + } + } + } + } + } + } + } + }, + "/posts/{post_id}": { + "get": { + "summary": "Get post details", + "description": "Get detailed information about a specific post", + "parameters": [ + { + "name": "post_id", + "in": "path", + "required": True, + "schema": { + "type": "string" + }, + "description": "Reddit post ID" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "title": {"type": "string"}, + "subreddit": {"type": "string"}, + "created_utc": {"type": "integer"}, + "url": {"type": "string"}, + "body": {"type": "string"}, + "score": {"type": "integer"}, + "author": {"type": "string"}, + "permalink": {"type": "string"}, + "num_comments": {"type": "integer"} + } + } + } + } + } + } + } + }, + "/posts/{post_id}/comments": { + "get": { + "summary": "Get post comments", + "description": "Get comments for a specific post", + "parameters": [ + { + "name": "post_id", + "in": "path", + "required": True, + "schema": { + "type": "string" + }, + "description": "Reddit post ID" + }, + { + "name": "limit", + "in": "query", + "required": False, + "schema": { + "type": "integer", + "default": 100 + }, + "description": "Maximum number of comments to return" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "post_id": {"type": "string"}, + "comments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "author": {"type": "string"}, + "body": {"type": "string"}, + "score": {"type": "integer"}, + "created_utc": {"type": "integer"} + } + } + }, + "count": {"type": "integer"} + } + } + } + } + } + } + } + }, + "/posts/{post_id}/summary": { + "get": { + "summary": "Get AI summary", + "description": "Get AI-generated summary for a post", + "parameters": [ + { + "name": "post_id", + "in": "path", + "required": True, + "schema": { + "type": "string" + }, + "description": "Reddit post ID" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "post_id": {"type": "string"}, + "summary": {"type": "string"}, + "post_title": {"type": "string"}, + "subreddit": {"type": "string"} + } + } + } + } + } + } + } + }, + "/trending": { + "get": { + "summary": "Get trending posts", + "description": "Get trending Reddit posts", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": False, + "schema": { + "type": "integer", + "default": 15 + }, + "description": "Number of posts to return" + }, + { + "name": "after", + "in": "query", + "required": False, + "schema": { + "type": "string" + }, + "description": "Pagination token" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "posts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "title": {"type": "string"}, + "subreddit": {"type": "string"}, + "created_utc": {"type": "integer"}, + "url": {"type": "string"}, + "body": {"type": "string"}, + "score": {"type": "integer"} + } + } + }, + "after": {"type": "string"}, + "limit": {"type": "integer"} + } + } + } + } + } + } + } + }, + "/health": { + "get": { + "summary": "Health check", + "description": "Check if the service is running", + "responses": { + "200": { + "description": "Service is healthy" + } + } + } + } + } + } + + return jsonify(spec) + + +def main(): + """Main function to start the MCP server""" + port = int(os.environ.get('PORT', 5000)) + logger.info(f"Starting Reddit MCP Server on port {port}") + app.run(host='0.0.0.0', port=port, debug=False) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/test_mcp_server.py b/test_mcp_server.py new file mode 100644 index 0000000..ff0b07f --- /dev/null +++ b/test_mcp_server.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Test script for MCP server functionality +""" + +import os +import sys +import time +import requests + +# Add the src directory to the path so we can import the modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from src.mcp_server import RedditClient, AIClient + +def test_reddit_client(): + """Test Reddit client with retry logic""" + print("Testing Reddit Client...") + + client = RedditClient() + + try: + # Test search with retry logic + print("Searching for 'python programming'...") + data = client.search_posts("python programming", limit=5) + print(f"Found {len(data.get('data', {}).get('children', []))} posts") + print("✓ Reddit client search test passed") + + # Test post details with retry logic + if data.get('data', {}).get('children'): + post_id = data['data']['children'][0]['data']['id'] + print(f"Getting details for post {post_id}...") + post_data = client.get_post_details(post_id) + print(f"Post title: {post_data.get('title', 'No title')}") + print("✓ Reddit client post details test passed") + + # Test comments with retry logic + print(f"Getting comments for post {post_id}...") + comments = client.get_post_comments(post_id, limit=5) + print(f"Found {len(comments)} comments") + print("✓ Reddit client comments test passed") + + except Exception as e: + print(f"✗ Reddit client test failed: {e}") + return False + + return True + +def test_ai_client(): + """Test AI client with retry logic""" + print("\nTesting AI Client...") + + client = AIClient() + + try: + # Test summary generation with retry logic + # We'll use a simple test case + post_body = "This is a test post body for testing the AI summary functionality." + comments = ["This is a test comment.", "Another test comment."] + + print("Generating AI summary...") + summary = client.generate_summary(post_body, comments) + print(f"Summary: {summary[:100]}...") + print("✓ AI client test passed") + + except Exception as e: + print(f"✗ AI client test failed: {e}") + return False + + return True + +def test_server_endpoints(): + """Test server endpoints""" + print("\nTesting Server Endpoints...") + + try: + # Test health check + response = requests.get('http://localhost:5000/health') + if response.status_code == 200: + print("✓ Health check endpoint works") + else: + print(f"✗ Health check failed: {response.status_code}") + return False + + # Test OpenAPI spec + response = requests.get('http://localhost:5000/openapi.json') + if response.status_code == 200: + print("✓ OpenAPI spec endpoint works") + else: + print(f"✗ OpenAPI spec failed: {response.status_code}") + return False + + print("✓ Server endpoints test passed") + + except Exception as e: + print(f"✗ Server endpoints test failed: {e}") + return False + + return True + +def main(): + """Main test function""" + print("Running MCP Server Tests...") + print("=" * 50) + + success = True + + # Test the core components + success &= test_reddit_client() + success &= test_ai_client() + + # Note: We can't easily test the full server endpoints without actually running it, + # but we can test the components that would be used by the server + + print("\n" + "=" * 50) + if success: + print("✓ All tests passed!") + return 0 + else: + print("✗ Some tests failed!") + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file