feat: Add MCP server with resilience and Docker support for Reddit API access
This commit is contained in:
parent
e3166a9c50
commit
0b57167791
32
.dockerignore
Normal file
32
.dockerignore
Normal file
@ -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
|
||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@ -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"]
|
||||
57
README.md
57
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
|
||||
|
||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
45
run_mcp_server.sh
Executable file
45
run_mcp_server.sh
Executable file
@ -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."
|
||||
775
src/mcp_server.py
Normal file
775
src/mcp_server.py
Normal file
@ -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/<post_id>', 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/<post_id>/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/<post_id>/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()
|
||||
124
test_mcp_server.py
Normal file
124
test_mcp_server.py
Normal file
@ -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())
|
||||
Loading…
x
Reference in New Issue
Block a user