Compare commits
10 Commits
b3db83cbdd
...
4eb9d08d29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eb9d08d29 | ||
|
|
b2474da02f | ||
|
|
824585d0b7 | ||
|
|
a4f5d96c93 | ||
|
|
1b6cf6e6e1 | ||
|
|
079d3dd899 | ||
|
|
e3bd8f66ac | ||
|
|
0b57167791 | ||
|
|
e3166a9c50 | ||
|
|
bea4d6797b |
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
|
||||||
143
.gitea/workflows/ci.yml
Normal file
143
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: https://git.home.ms
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: gitea-job-image
|
||||||
|
steps:
|
||||||
|
- name: Clone repo
|
||||||
|
run: |
|
||||||
|
rm -rf $GITHUB_WORKSPACE/*
|
||||||
|
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||||
|
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Run ruff (Python lint)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f pyproject.toml ]]; then
|
||||||
|
pip3 install ruff
|
||||||
|
ruff check .
|
||||||
|
else
|
||||||
|
echo "No Python project detected, skipping ruff"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run npm lint (JS/TS)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f package.json ]]; then
|
||||||
|
npm ci
|
||||||
|
npm run lint --if-present || true
|
||||||
|
else
|
||||||
|
echo "No Node.js project detected, skipping npm lint"
|
||||||
|
fi
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: gitea-job-image
|
||||||
|
steps:
|
||||||
|
- name: Clone repo
|
||||||
|
run: |
|
||||||
|
rm -rf $GITHUB_WORKSPACE/*
|
||||||
|
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||||
|
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Run pytest (Python)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f pyproject.toml ]]; then
|
||||||
|
python3 -m pip install --upgrade pip
|
||||||
|
pip3 install -e ".[dev]" 2>/dev/null || pip3 install -e . 2>/dev/null || true
|
||||||
|
pip3 install pytest
|
||||||
|
pytest tests/ -v --tb=short 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo "No Python project detected, skipping pytest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run npm test (JS/TS)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f package.json ]]; then
|
||||||
|
npm ci
|
||||||
|
npm run test --if-present || true
|
||||||
|
else
|
||||||
|
echo "No Node.js project detected, skipping npm test"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run Go tests
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f go.mod ]]; then
|
||||||
|
go test ./...
|
||||||
|
else
|
||||||
|
echo "No Go project detected, skipping go test"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Clone repo
|
||||||
|
run: |
|
||||||
|
rm -rf $GITHUB_WORKSPACE/*
|
||||||
|
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||||
|
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f Dockerfile ]]; then
|
||||||
|
docker build -t $GITHUB_REPOSITORY:test .
|
||||||
|
else
|
||||||
|
echo "No Dockerfile found, skipping docker build"
|
||||||
|
fi
|
||||||
|
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: gitea-job-image
|
||||||
|
steps:
|
||||||
|
- name: Clone repo
|
||||||
|
run: |
|
||||||
|
rm -rf $GITHUB_WORKSPACE/*
|
||||||
|
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||||
|
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Run bandit (Python SAST)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f pyproject.toml ]]; then
|
||||||
|
pip3 install bandit
|
||||||
|
bandit -r . --severity-level high --confidence-level high --exclude tests/,test_*
|
||||||
|
else
|
||||||
|
echo "No Python project detected, skipping bandit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run npm audit (JS/TS)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [[ -f package.json ]]; then
|
||||||
|
npm ci
|
||||||
|
npm audit --audit-level=high 2>/dev/null || echo "npm audit: vulnerabilities found (non-blocking)"
|
||||||
|
else
|
||||||
|
echo "No Node.js project detected, skipping npm audit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-result:
|
||||||
|
needs: [lint, test, docker-build, security]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: gitea-job-image
|
||||||
|
if: always()
|
||||||
|
steps:
|
||||||
|
- name: Summary
|
||||||
|
run: echo "All CI checks completed"
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,3 +5,5 @@ lib/
|
|||||||
target/
|
target/
|
||||||
|
|
||||||
pyvenv.cfg
|
pyvenv.cfg
|
||||||
|
|
||||||
|
.aider*
|
||||||
|
|||||||
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"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Jarian Cottingham
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
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
|
- **Comments Viewer**: Cycle through comments using 'c' key
|
||||||
- **AI Integration**: Get AI-generated summaries using Ollama API
|
- **AI Integration**: Get AI-generated summaries using Ollama API
|
||||||
- **Pagination**: Navigate between pages of results ('n' key)
|
- **Pagination**: Navigate between pages of results ('n' key)
|
||||||
|
- **MCP Server**: REST API endpoints for Reddit querying with resilience and retry logic
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@ -22,7 +23,7 @@ reddit-cli/
|
|||||||
├── src/
|
├── src/
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── main.py # Main application entrypoint
|
│ ├── 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
|
│ └── ai_client.py # Ollama AI integration
|
||||||
├── tests/
|
├── tests/
|
||||||
│ └── test_cli.py # Unit tests
|
│ └── test_cli.py # Unit tests
|
||||||
@ -53,6 +54,7 @@ pip install -r requirements.txt
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
### Running the CLI Interface
|
||||||
Run the CLI interface with a search query:
|
Run the CLI interface with a search query:
|
||||||
```bash
|
```bash
|
||||||
python -m src.main "python programming"
|
python -m src.main "python programming"
|
||||||
@ -63,6 +65,51 @@ Or run without arguments to enter interactive mode:
|
|||||||
python -m src.main
|
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
|
## Keyboard Controls
|
||||||
|
|
||||||
- `n` - Go to next page of results
|
- `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_BASE_URL` - Ollama server address (default: http://192.168.8.223:11434)
|
||||||
- `OLLAMA_MODEL` - AI model to use (default: gpt-oss:20b)
|
- `OLLAMA_MODEL` - AI model to use (default: gpt-oss:20b)
|
||||||
|
- `PORT` - MCP server port (default: 5000)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@ -84,6 +132,8 @@ The application uses environment variables for configuration:
|
|||||||
- requests
|
- requests
|
||||||
- rich
|
- rich
|
||||||
- pyyaml
|
- pyyaml
|
||||||
|
- flask
|
||||||
|
- flask-cors
|
||||||
- pytest
|
- pytest
|
||||||
- pytest-cov
|
- pytest-cov
|
||||||
|
|
||||||
@ -99,6 +149,11 @@ Or run basic functionality checks:
|
|||||||
python test_cli.py
|
python test_cli.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run MCP server tests:
|
||||||
|
```bash
|
||||||
|
python test_mcp_server.py
|
||||||
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
|
|||||||
77
TESTING.md
Normal file
77
TESTING.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# MCP Server Testing
|
||||||
|
|
||||||
|
The MCP server is now running at `http://home.ms:5000`. Here are the curl commands to test all endpoints:
|
||||||
|
|
||||||
|
## 1. Health Check
|
||||||
|
```bash
|
||||||
|
curl -X GET http://home.ms:5000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Search Posts
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://home.ms:5000/search?q=python&limit=5"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Get Post Details
|
||||||
|
```bash
|
||||||
|
curl -X GET http://home.ms:5000/posts/xyz123
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Get Post Comments
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://home.ms:5000/posts/xyz123/comments?limit=10"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Get AI Summary
|
||||||
|
```bash
|
||||||
|
curl -X GET http://home.ms:5000/posts/xyz123/summary
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Get Trending Posts
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://home.ms:5000/trending?limit=5"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. OpenAPI Specification
|
||||||
|
```bash
|
||||||
|
curl -X GET http://home.ms:5000/openapi.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Response Format
|
||||||
|
|
||||||
|
### Search Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"id": "xyz123",
|
||||||
|
"title": "Example Post Title",
|
||||||
|
"subreddit": "example",
|
||||||
|
"created_utc": 1634567890,
|
||||||
|
"url": "http://reddit.com/r/example/comments/xyz123",
|
||||||
|
"body": "Post content here...",
|
||||||
|
"score": 123
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": "next_token",
|
||||||
|
"query": "python",
|
||||||
|
"limit": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI Summary Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"post_id": "xyz123",
|
||||||
|
"summary": "AI-generated summary of the post and comments...",
|
||||||
|
"post_title": "Example Post Title",
|
||||||
|
"subreddit": "example"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
All AI-related endpoints require the API key in the Authorization header:
|
||||||
|
```bash
|
||||||
|
curl -X GET http://home.ms:5000/posts/xyz123/summary \
|
||||||
|
-H "Authorization: Bearer 111"
|
||||||
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
|
requests>=2.20.0
|
||||||
rich>=10.0.0
|
rich>=10.0.0
|
||||||
pyyaml>=5.4.0
|
pyyaml>=5.4.0
|
||||||
|
flask>=2.0.0
|
||||||
|
flask-cors>=3.0.0
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
pytest>=6.0.0
|
pytest>=6.0.0
|
||||||
|
|||||||
36
run_mcp_server.sh
Executable file
36
run_mcp_server.sh
Executable file
@ -0,0 +1,36 @@
|
|||||||
|
#!/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..."
|
||||||
|
|
||||||
|
# Set default environment variables if not already set
|
||||||
|
export OLLAMA_BASE_URL="${OLLAMA_BASE_URL:-http://host.docker.internal: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 directly with python3
|
||||||
|
python3 src/mcp_server.py
|
||||||
|
|
||||||
|
echo "Server stopped."
|
||||||
46
src/main.py
46
src/main.py
@ -228,11 +228,22 @@ class RedditCLI:
|
|||||||
# Show welcome screen
|
# Show welcome screen
|
||||||
self.show_welcome()
|
self.show_welcome()
|
||||||
# Get search query from user
|
# Get search query from user
|
||||||
query = input("\nEnter search query: ").strip()
|
while True:
|
||||||
if not query:
|
query = input("\nEnter search query: ").strip()
|
||||||
self.console.print("[red]No query provided. Exiting.[/red]")
|
if not query:
|
||||||
return
|
self.console.print(
|
||||||
self.search(query)
|
"[red]No query provided. Please try again.[/red]"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
self.search(query)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
if "User requested new search" in str(e):
|
||||||
|
# Continue the loop to get a new search query
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
else:
|
else:
|
||||||
# Process the provided query
|
# Process the provided query
|
||||||
self.search(args.query)
|
self.search(args.query)
|
||||||
@ -295,7 +306,30 @@ class RedditCLI:
|
|||||||
|
|
||||||
if not posts:
|
if not posts:
|
||||||
self.console.print("[yellow]No results found[/yellow]")
|
self.console.print("[yellow]No results found[/yellow]")
|
||||||
return
|
# Instead of returning, ask user if they want to search again
|
||||||
|
while True:
|
||||||
|
choice = (
|
||||||
|
input("\nWould you like to perform a new search? (y/n): ")
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
|
if choice in ["y", "yes"]:
|
||||||
|
new_query = input("Enter new search query: ").strip()
|
||||||
|
if new_query:
|
||||||
|
self.search(new_query)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self.console.print(
|
||||||
|
"[yellow]No query provided.[/yellow]"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
elif choice in ["n", "no"]:
|
||||||
|
# Return to main menu by raising an exception that gets caught
|
||||||
|
raise Exception("User requested new search")
|
||||||
|
else:
|
||||||
|
self.console.print(
|
||||||
|
"[red]Please enter 'y' for yes or 'n' for no[/red]"
|
||||||
|
)
|
||||||
|
|
||||||
self.search_results = posts
|
self.search_results = posts
|
||||||
self.current_page = 0
|
self.current_page = 0
|
||||||
|
|||||||
779
src/mcp_server.py
Normal file
779
src/mcp_server.py
Normal file
@ -0,0 +1,779 @@
|
|||||||
|
#!/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://home.ms:4000")
|
||||||
|
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 OpenAPI AI API with retry logic"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = OLLAMA_BASE_URL
|
||||||
|
self.model = OLLAMA_MODEL
|
||||||
|
self.session = requests.Session()
|
||||||
|
# Set the API key for the new endpoint
|
||||||
|
self.api_key = "111" # As specified in the feedback
|
||||||
|
|
||||||
|
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:
|
||||||
|
# Add API key to Authorization header as Bearer token
|
||||||
|
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||||
|
response = self.session.post(url, json=json_data, headers=headers, 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