Upload folder using huggingface_hub
Browse files- .env.example +7 -0
- .gitignore +17 -0
- .gradio/certificate.pem +31 -0
- FINAL_SUBMISSION.md +198 -0
- HACKATHON_DEMO.md +163 -0
- LICENSE +21 -0
- README.md +325 -7
- README_SPACES.md +52 -0
- api_query.py +238 -0
- app.py +18 -0
- demo_mcp_client.py +193 -0
- gradio_app.py +810 -0
- pyproject.toml +64 -0
- requirements.txt +4 -0
- stackoverflow_mcp/__init__.py +6 -0
- stackoverflow_mcp/__main__.py +10 -0
- stackoverflow_mcp/api.py +477 -0
- stackoverflow_mcp/env.py +10 -0
- stackoverflow_mcp/formatter.py +106 -0
- stackoverflow_mcp/server.py +376 -0
- stackoverflow_mcp/types.py +110 -0
- test_live_demo.py +85 -0
- tests/api/test_search.py +243 -0
- tests/test_formatter.py +109 -0
- tests/test_general_api_health.py +91 -0
- tests/test_server.py +147 -0
- uv.lock +0 -0
.env.example
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stack Exchange API credentials
|
| 2 |
+
STACK_EXCHANGE_API_KEY=your_api_key_here
|
| 3 |
+
|
| 4 |
+
# Rate limiting configuration
|
| 5 |
+
MAX_REQUEST_PER_WINDOW=30
|
| 6 |
+
RATE_LIMIT_WINDOW_MS=60000
|
| 7 |
+
RETRY_AFTER_MS=2000
|
.gitignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python cache directories
|
| 2 |
+
__pycache__/
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
*.py[cod]
|
| 5 |
+
*$py.class
|
| 6 |
+
|
| 7 |
+
# Virtual environment
|
| 8 |
+
.venv/
|
| 9 |
+
|
| 10 |
+
# IDE specific files
|
| 11 |
+
.python-version
|
| 12 |
+
|
| 13 |
+
# Package building artifacts
|
| 14 |
+
*.egg-info/
|
| 15 |
+
|
| 16 |
+
.env
|
| 17 |
+
dist/
|
.gradio/certificate.pem
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-----BEGIN CERTIFICATE-----
|
| 2 |
+
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
| 3 |
+
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
| 4 |
+
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
| 5 |
+
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
| 6 |
+
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
| 7 |
+
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
| 8 |
+
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
| 9 |
+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
| 10 |
+
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
| 11 |
+
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
| 12 |
+
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
| 13 |
+
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
| 14 |
+
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
| 15 |
+
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
| 16 |
+
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
| 17 |
+
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
| 18 |
+
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
| 19 |
+
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
| 20 |
+
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
| 21 |
+
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
| 22 |
+
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
| 23 |
+
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
| 24 |
+
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
| 25 |
+
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
| 26 |
+
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
| 27 |
+
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
| 28 |
+
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
| 29 |
+
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
| 30 |
+
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
| 31 |
+
-----END CERTIFICATE-----
|
FINAL_SUBMISSION.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🏆 Stack Overflow MCP Server - Complete Hackathon Submission
|
| 2 |
+
|
| 3 |
+
## 🎯 **DEMO SUMMARY**
|
| 4 |
+
|
| 5 |
+
**✅ COMPLETED**: A fully functional Stack Overflow MCP Server with beautiful Gradio interface
|
| 6 |
+
|
| 7 |
+
**🌐 LIVE DEMO**: [https://c44b366466c774a9d5.gradio.live](https://c44b366466c774a9d5.gradio.live)
|
| 8 |
+
|
| 9 |
+
**🔗 MCP ENDPOINT**: `https://c44b366466c774a9d5.gradio.live/gradio_api/mcp/sse`
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## 🚀 **WHAT WE BUILT**
|
| 14 |
+
|
| 15 |
+
### 🎨 **Beautiful Web Interface**
|
| 16 |
+
- **5 Specialized Search Tabs**: General Search, Error Search, Question Retrieval, Stack Trace Analysis, Advanced Search
|
| 17 |
+
- **Interactive Examples**: Quick-start buttons with pre-configured searches
|
| 18 |
+
- **Real-time Results**: Formatted markdown/JSON output with syntax highlighting
|
| 19 |
+
- **Modern UI**: Clean Gradio interface with intuitive controls
|
| 20 |
+
|
| 21 |
+
### 🤖 **Full MCP Server Integration**
|
| 22 |
+
- **5 MCP Tools Available**: Complete toolkit for AI assistants
|
| 23 |
+
- **SSE Transport**: Server-Sent Events for real-time communication
|
| 24 |
+
- **Claude Desktop Ready**: Drop-in configuration for immediate use
|
| 25 |
+
- **Standardized Protocol**: Full MCP compliance for interoperability
|
| 26 |
+
|
| 27 |
+
### 🔍 **Comprehensive Search Capabilities**
|
| 28 |
+
- **Smart Query Processing**: Natural language search with tag filtering
|
| 29 |
+
- **Error-Specific Search**: Specialized debugging assistance
|
| 30 |
+
- **Stack Trace Analysis**: Automated error pattern recognition
|
| 31 |
+
- **Advanced Filtering**: Multi-criteria search with 15+ filter options
|
| 32 |
+
- **High-Quality Results**: Score-based filtering and accepted answers
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## 🛠️ **TECHNICAL ARCHITECTURE**
|
| 37 |
+
|
| 38 |
+
```
|
| 39 |
+
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
| 40 |
+
│ Gradio UI │ │ MCP Server │ │ Stack Exchange │
|
| 41 |
+
│ │ │ │ │ API │
|
| 42 |
+
│ • 5 Search Tabs │◄──►│ • 5 MCP Tools │◄──►│ • 300+ Sites │
|
| 43 |
+
│ • Examples │ │ • SSE Transport │ │ • Rate Limited │
|
| 44 |
+
│ • Real-time │ │ • Async Ops │ │ • Rich Content │
|
| 45 |
+
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
**🔧 Tech Stack**:
|
| 49 |
+
- **Frontend**: Gradio 5.33.1 with MCP support
|
| 50 |
+
- **Backend**: FastMCP with AsyncIO
|
| 51 |
+
- **API**: Stack Exchange API v2.3
|
| 52 |
+
- **Transport**: Server-Sent Events (SSE)
|
| 53 |
+
- **Package Manager**: UV for fast dependency management
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## 🎮 **DEMO SCENARIOS**
|
| 58 |
+
|
| 59 |
+
### 1. **👨💻 Developer Searches**
|
| 60 |
+
```
|
| 61 |
+
🔍 "Django pagination best practices" + tags:python,django
|
| 62 |
+
🎯 Result: Top-rated Django pagination solutions with code examples
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
### 2. **🐛 Error Debugging**
|
| 66 |
+
```
|
| 67 |
+
🔍 "TypeError: 'NoneType' object has no attribute" + language:Python
|
| 68 |
+
🎯 Result: Common NoneType error solutions with prevention tips
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
### 3. **📚 Famous Questions**
|
| 72 |
+
```
|
| 73 |
+
🔍 Question ID: 11227809
|
| 74 |
+
🎯 Result: "Why is processing a sorted array faster?" (50K+ votes)
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
### 4. **📊 Stack Trace Analysis**
|
| 78 |
+
```
|
| 79 |
+
🔍 "ReferenceError: useState is not defined" + language:JavaScript
|
| 80 |
+
🎯 Result: React hooks troubleshooting guide
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
### 5. **⚙️ Advanced Filtering**
|
| 84 |
+
```
|
| 85 |
+
🔍 Query:"memory optimization" + tags:c++,performance + min_score:50
|
| 86 |
+
🎯 Result: High-quality C++ performance optimization answers
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
---
|
| 90 |
+
|
| 91 |
+
## 🤖 **MCP CLIENT INTEGRATION**
|
| 92 |
+
|
| 93 |
+
### Claude Desktop Configuration
|
| 94 |
+
```json
|
| 95 |
+
{
|
| 96 |
+
"mcpServers": {
|
| 97 |
+
"stackoverflow": {
|
| 98 |
+
"url": "https://c44b366466c774a9d5.gradio.live/gradio_api/mcp/sse"
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
### Available MCP Tools
|
| 105 |
+
| Tool Name | Description | Use Case |
|
| 106 |
+
|-----------|-------------|----------|
|
| 107 |
+
| `search_by_query_sync` | General search with tags | Find solutions by keywords |
|
| 108 |
+
| `search_by_error_sync` | Error-specific search | Debug error messages |
|
| 109 |
+
| `get_question_sync` | Get question by ID | Retrieve specific Q&A |
|
| 110 |
+
| `analyze_stack_trace_sync` | Parse stack traces | Analyze runtime errors |
|
| 111 |
+
| `advanced_search_sync` | Multi-criteria search | Complex filtering needs |
|
| 112 |
+
|
| 113 |
+
### Example AI Prompts
|
| 114 |
+
```
|
| 115 |
+
🤖 "Search Stack Overflow for Django pagination best practices"
|
| 116 |
+
🤖 "Find solutions for TypeError: NoneType object has no attribute"
|
| 117 |
+
🤖 "Get the famous sorting performance question from Stack Overflow"
|
| 118 |
+
🤖 "Analyze this JavaScript error: ReferenceError: useState is not defined"
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## 🏆 **HACKATHON HIGHLIGHTS**
|
| 124 |
+
|
| 125 |
+
### 🌟 **Innovation Points**
|
| 126 |
+
- **Dual Interface**: Both beautiful web UI and powerful MCP server
|
| 127 |
+
- **Specialized Search**: 5 different search strategies for different use cases
|
| 128 |
+
- **Real-world Utility**: Solves actual developer problems daily
|
| 129 |
+
- **AI-Ready**: Immediate integration with AI assistants
|
| 130 |
+
|
| 131 |
+
### 🎯 **Technical Excellence**
|
| 132 |
+
- **Modern Stack**: Latest Gradio, FastMCP, UV package manager
|
| 133 |
+
- **Async Architecture**: Non-blocking operations for scalability
|
| 134 |
+
- **Rate Limiting**: Responsible API usage with built-in throttling
|
| 135 |
+
- **Error Handling**: Graceful degradation and user feedback
|
| 136 |
+
|
| 137 |
+
### 🚀 **User Experience**
|
| 138 |
+
- **Zero Learning Curve**: Intuitive interface with guided examples
|
| 139 |
+
- **Instant Results**: Fast search with formatted output
|
| 140 |
+
- **Multiple Formats**: Both human-readable and machine-parseable output
|
| 141 |
+
- **Progressive Enhancement**: Works great standalone, amazing with AI
|
| 142 |
+
|
| 143 |
+
### 🔧 **Production Ready**
|
| 144 |
+
- **Public Deployment**: Live demo with sharing links
|
| 145 |
+
- **MCP Compliance**: Full protocol implementation
|
| 146 |
+
- **Extensible Design**: Easy to add new search methods
|
| 147 |
+
- **Documentation**: Comprehensive guides and examples
|
| 148 |
+
|
| 149 |
+
---
|
| 150 |
+
|
| 151 |
+
## 📊 **IMPACT & VALUE**
|
| 152 |
+
|
| 153 |
+
### For Developers
|
| 154 |
+
- **Time Saving**: Quickly find high-quality solutions
|
| 155 |
+
- **Better Search**: Specialized search for different problem types
|
| 156 |
+
- **Quality Filtering**: Focus on accepted answers and high scores
|
| 157 |
+
|
| 158 |
+
### For AI Assistants
|
| 159 |
+
- **Enhanced Capabilities**: Access to Stack Overflow's knowledge base
|
| 160 |
+
- **Context-Aware**: Specialized tools for different coding scenarios
|
| 161 |
+
- **Reliable Source**: Authoritative programming content
|
| 162 |
+
|
| 163 |
+
### For the MCP Ecosystem
|
| 164 |
+
- **Reference Implementation**: Shows best practices for MCP servers
|
| 165 |
+
- **Real-world Use Case**: Practical demonstration of MCP value
|
| 166 |
+
- **Community Contribution**: Open source for others to learn from
|
| 167 |
+
|
| 168 |
+
---
|
| 169 |
+
|
| 170 |
+
## 🎉 **FINAL RESULT**
|
| 171 |
+
|
| 172 |
+
**✅ Working Gradio App**: [https://c44b366466c774a9d5.gradio.live](https://c44b366466c774a9d5.gradio.live)
|
| 173 |
+
|
| 174 |
+
**✅ Live MCP Server**: `https://c44b366466c774a9d5.gradio.live/gradio_api/mcp/sse`
|
| 175 |
+
|
| 176 |
+
**✅ 5 Search Methods**: General, Error, Question, Stack Trace, Advanced
|
| 177 |
+
|
| 178 |
+
**✅ Beautiful Interface**: Modern, intuitive, example-driven
|
| 179 |
+
|
| 180 |
+
**✅ MCP Integration**: Claude Desktop ready with full tool suite
|
| 181 |
+
|
| 182 |
+
**✅ Production Quality**: Error handling, rate limiting, documentation
|
| 183 |
+
|
| 184 |
+
---
|
| 185 |
+
|
| 186 |
+
## 🚀 **TRY IT NOW**
|
| 187 |
+
|
| 188 |
+
1. **Web Interface**: Visit [the live demo](https://c44b366466c774a9d5.gradio.live)
|
| 189 |
+
2. **Click Examples**: Try the quick example buttons
|
| 190 |
+
3. **Test Different Tabs**: Explore all 5 search methods
|
| 191 |
+
4. **MCP Integration**: Add to Claude Desktop with provided config
|
| 192 |
+
5. **AI Prompts**: Use the example prompts with Claude
|
| 193 |
+
|
| 194 |
+
---
|
| 195 |
+
|
| 196 |
+
**🏆 Built with ❤️ for the MCP Hackathon - Demonstrating the power of beautiful interfaces combined with powerful MCP server capabilities!**
|
| 197 |
+
|
| 198 |
+
*The future of developer tools is AI-assisted, and this project shows how MCP makes that seamless.*
|
HACKATHON_DEMO.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🏆 Stack Overflow MCP Server - Hackathon Demo
|
| 2 |
+
|
| 3 |
+
## 🎯 Project Overview
|
| 4 |
+
|
| 5 |
+
This project demonstrates a **Stack Overflow MCP (Model Context Protocol) Server** built with Gradio for a hackathon. It provides both a beautiful web interface and an MCP server that AI assistants like Claude can use to programmatically search and analyze Stack Overflow content.
|
| 6 |
+
|
| 7 |
+
## 🌟 Key Features
|
| 8 |
+
|
| 9 |
+
### 🔍 **Multiple Search Methods**
|
| 10 |
+
- **General Search**: Query-based search with tag filtering
|
| 11 |
+
- **Error Search**: Specialized search for error messages and debugging
|
| 12 |
+
- **Question Retrieval**: Get specific questions by ID
|
| 13 |
+
- **Stack Trace Analysis**: Analyze stack traces to find solutions
|
| 14 |
+
- **Advanced Search**: Comprehensive filtering with multiple criteria
|
| 15 |
+
|
| 16 |
+
### 🎨 **Beautiful Web Interface**
|
| 17 |
+
- Clean, modern Gradio interface
|
| 18 |
+
- Multiple tabs for different search types
|
| 19 |
+
- Quick example buttons for easy testing
|
| 20 |
+
- Real-time search results with formatted output
|
| 21 |
+
- Support for both Markdown and JSON output formats
|
| 22 |
+
|
| 23 |
+
### 🤖 **MCP Server Integration**
|
| 24 |
+
- Full MCP compatibility for AI assistants
|
| 25 |
+
- SSE (Server-Sent Events) endpoint for real-time communication
|
| 26 |
+
- 5 available MCP tools for different search scenarios
|
| 27 |
+
- Easy integration with Claude Desktop and other MCP clients
|
| 28 |
+
|
| 29 |
+
## 🚀 Live Demo
|
| 30 |
+
|
| 31 |
+
**🌐 Web Interface**: [https://c44b366466c774a9d5.gradio.live](https://c44b366466c774a9d5.gradio.live)
|
| 32 |
+
|
| 33 |
+
**🔗 MCP Server Endpoint**: `https://c44b366466c774a9d5.gradio.live/gradio_api/mcp/sse`
|
| 34 |
+
|
| 35 |
+
## 🛠️ Available MCP Tools
|
| 36 |
+
|
| 37 |
+
| Tool | Description | Use Case |
|
| 38 |
+
|------|-------------|----------|
|
| 39 |
+
| `search_by_query_sync` | General Stack Overflow search | Find solutions by keywords and tags |
|
| 40 |
+
| `search_by_error_sync` | Error-specific search | Debug specific error messages |
|
| 41 |
+
| `get_question_sync` | Retrieve specific question by ID | Get detailed question information |
|
| 42 |
+
| `analyze_stack_trace_sync` | Analyze stack traces | Find solutions for runtime errors |
|
| 43 |
+
| `advanced_search_sync` | Advanced search with filters | Complex queries with multiple criteria |
|
| 44 |
+
|
| 45 |
+
## 📊 Demo Scenarios
|
| 46 |
+
|
| 47 |
+
### 1. **General Programming Search**
|
| 48 |
+
```
|
| 49 |
+
Query: "Django pagination best practices"
|
| 50 |
+
Tags: python,django
|
| 51 |
+
Filters: Minimum score 5, Must have accepted answer
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
### 2. **Error Debugging**
|
| 55 |
+
```
|
| 56 |
+
Error: "TypeError: 'NoneType' object has no attribute 'length'"
|
| 57 |
+
Language: Python
|
| 58 |
+
Technologies: flask,sqlalchemy
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
### 3. **Stack Trace Analysis**
|
| 62 |
+
```
|
| 63 |
+
Stack Trace: "ReferenceError: useState is not defined"
|
| 64 |
+
Language: JavaScript
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
### 4. **Advanced Search**
|
| 68 |
+
```
|
| 69 |
+
Query: "memory optimization"
|
| 70 |
+
Include Tags: c++,performance
|
| 71 |
+
Exclude Tags: beginner
|
| 72 |
+
Min Score: 50
|
| 73 |
+
Sort By: votes
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
## 🔧 Technical Architecture
|
| 77 |
+
|
| 78 |
+
### Frontend (Gradio)
|
| 79 |
+
- **Framework**: Gradio 5.33.1 with MCP support
|
| 80 |
+
- **Interface**: Multi-tab design with intuitive controls
|
| 81 |
+
- **Features**: Real-time search, example buttons, format selection
|
| 82 |
+
|
| 83 |
+
### Backend (Stack Overflow MCP Server)
|
| 84 |
+
- **API Integration**: Stack Exchange API with rate limiting
|
| 85 |
+
- **MCP Protocol**: Full MCP compatibility with SSE transport
|
| 86 |
+
- **Search Engine**: Multiple search strategies and filtering options
|
| 87 |
+
- **Response Formatting**: Markdown and JSON output formats
|
| 88 |
+
|
| 89 |
+
### Infrastructure
|
| 90 |
+
- **Package Manager**: UV for fast dependency management
|
| 91 |
+
- **Deployment**: Gradio sharing with public URLs
|
| 92 |
+
- **MCP Server**: Integrated MCP server with Gradio
|
| 93 |
+
|
| 94 |
+
## 🎮 How to Test
|
| 95 |
+
|
| 96 |
+
### Web Interface Testing
|
| 97 |
+
1. Visit the [live demo](https://a6f742bf182e4bae9b.gradio.live)
|
| 98 |
+
2. Try the different tabs:
|
| 99 |
+
- **General Search**: Use example buttons or enter custom queries
|
| 100 |
+
- **Error Search**: Test with common error messages
|
| 101 |
+
- **Get Question**: Try question ID `11227809`
|
| 102 |
+
- **Stack Trace Analysis**: Use the pre-filled JavaScript example
|
| 103 |
+
- **Advanced Search**: Experiment with complex filters
|
| 104 |
+
|
| 105 |
+
### MCP Client Testing
|
| 106 |
+
Configure your MCP client (like Claude Desktop) with:
|
| 107 |
+
```json
|
| 108 |
+
{
|
| 109 |
+
"mcpServers": {
|
| 110 |
+
"stackoverflow": {
|
| 111 |
+
"url": "https://a6f742bf182e4bae9b.gradio.live/gradio_api/mcp/sse"
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
## 🏗️ Local Development
|
| 118 |
+
|
| 119 |
+
```bash
|
| 120 |
+
# Clone and setup
|
| 121 |
+
git clone <repository>
|
| 122 |
+
cd gradio_stack_overflow_client
|
| 123 |
+
|
| 124 |
+
# Install dependencies
|
| 125 |
+
uv add "gradio[mcp]"
|
| 126 |
+
|
| 127 |
+
# Set your Stack Exchange API key
|
| 128 |
+
export STACK_EXCHANGE_API_KEY="your_api_key_here"
|
| 129 |
+
|
| 130 |
+
# Run the application
|
| 131 |
+
uv run python gradio_app.py
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
## 🎯 Hackathon Highlights
|
| 135 |
+
|
| 136 |
+
### Innovation
|
| 137 |
+
- **Dual Interface**: Both web UI and MCP server in one application
|
| 138 |
+
- **Smart Search**: Multiple specialized search strategies
|
| 139 |
+
- **User Experience**: Intuitive interface with quick examples
|
| 140 |
+
|
| 141 |
+
### Technical Excellence
|
| 142 |
+
- **Modern Stack**: UV, Gradio 5.33+, FastMCP integration
|
| 143 |
+
- **Robust API**: Rate limiting, error handling, async operations
|
| 144 |
+
- **MCP Compliance**: Full protocol implementation with SSE transport
|
| 145 |
+
|
| 146 |
+
### Practical Value
|
| 147 |
+
- **Real-world Utility**: Solves actual developer problems
|
| 148 |
+
- **AI Integration**: Ready for AI assistant workflows
|
| 149 |
+
- **Extensible**: Easy to add new search methods and filters
|
| 150 |
+
|
| 151 |
+
## 🚀 Future Enhancements
|
| 152 |
+
|
| 153 |
+
- **Authentication**: User-specific search history
|
| 154 |
+
- **Caching**: Redis caching for faster responses
|
| 155 |
+
- **Analytics**: Search pattern analysis and recommendations
|
| 156 |
+
- **Multi-platform**: GitHub, GitLab, and other developer platforms
|
| 157 |
+
- **AI Features**: Semantic search and intelligent filtering
|
| 158 |
+
|
| 159 |
+
---
|
| 160 |
+
|
| 161 |
+
**Built with ❤️ for the MCP Hackathon**
|
| 162 |
+
|
| 163 |
+
*Demonstrating the power of combining beautiful web interfaces with powerful MCP server capabilities*
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 Veritax
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,12 +1,330 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
|
| 4 |
-
colorFrom: pink
|
| 5 |
-
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 5.33.1
|
| 8 |
-
app_file: app.py
|
| 9 |
-
pinned: false
|
| 10 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Stack_Overflow_MCP_Server
|
| 3 |
+
app_file: app.py
|
|
|
|
|
|
|
| 4 |
sdk: gradio
|
| 5 |
sdk_version: 5.33.1
|
|
|
|
|
|
|
| 6 |
---
|
| 7 |
+
# <div align="center">Stack Overflow MCP Server</div>
|
| 8 |
+
|
| 9 |
+
<div align="center">
|
| 10 |
+
|
| 11 |
+
[![Python Version][python-badge]][python-url]
|
| 12 |
+
[![License][license-badge]][license-url]
|
| 13 |
+
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
This [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server enables AI assistants like Claude to search and access Stack Overflow content through a standardized protocol, providing seamless access to programming solutions, error handling, and technical knowledge.
|
| 17 |
+
|
| 18 |
+
> [!NOTE]
|
| 19 |
+
>
|
| 20 |
+
> The Stack Overflow MCP Server is currently in Beta. We welcome your feedback and encourage you to report any bugs by opening an issue.
|
| 21 |
+
|
| 22 |
+
## Features
|
| 23 |
+
|
| 24 |
+
- 🔍 **Multiple Search Methods**: Search by query, error message, or specific question ID
|
| 25 |
+
- 📊 **Advanced Filtering**: Filter results by tags, score, accepted answers, and more
|
| 26 |
+
- 🧩 **Stack Trace Analysis**: Parse and find solutions for error stack traces
|
| 27 |
+
- 📝 **Rich Formatting**: Get results in Markdown or JSON format
|
| 28 |
+
- 💬 **Comments Support**: Optionally include question and answer comments
|
| 29 |
+
- ⚡ **Rate Limiting**: Built-in protection to respect Stack Exchange API quotas
|
| 30 |
+
|
| 31 |
+
### Example Prompts and Use Cases
|
| 32 |
+
|
| 33 |
+
Here are some example prompts you can use with Claude when the Stack Overflow MCP server is integrated:
|
| 34 |
+
|
| 35 |
+
| Tool | Example Prompt | Description |
|
| 36 |
+
| --------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
|
| 37 |
+
| `search_by_query` | "Search Stack Overflow for Django pagination best practices" | Finds the most relevant questions and answers about Django pagination techniques |
|
| 38 |
+
| `search_by_query` | "Find Python asyncio examples with tags python and asyncio" | Searches for specific code examples filtering by multiple tags |
|
| 39 |
+
| `search_by_error` | "Why am I getting 'TypeError: object of type 'NoneType' has no len()' in Python?" | Finds solutions for a common Python error |
|
| 40 |
+
| `get_question` | "Get Stack Overflow question 53051465 about React hooks" | Retrieves a specific question by ID, including all answers |
|
| 41 |
+
| `analyze_stack_trace` | "Fix this error: ReferenceError: useState is not defined at Component in javascript" | Analyzes JavaScript error to find relevant solutions |
|
| 42 |
+
| `advanced_search` | "Find highly rated answers about memory leaks in C++ with at least 10 upvotes" | Uses advanced filtering to find high-quality answers |
|
| 43 |
+
|
| 44 |
+
## Prerequisites
|
| 45 |
+
|
| 46 |
+
Before using this MCP server, you need to:
|
| 47 |
+
|
| 48 |
+
1. Get a Stack Exchange API key (see below)
|
| 49 |
+
2. Have Python 3.10+ installed
|
| 50 |
+
3. Install [uv](https://docs.astral.sh/uv/getting-started/installation/) (recommended)
|
| 51 |
+
|
| 52 |
+
### Getting a Stack Exchange API Key
|
| 53 |
+
|
| 54 |
+
To use this server effectively, you'll need a Stack Exchange API key:
|
| 55 |
+
|
| 56 |
+
1. Go to [Stack Apps OAuth Registration](https://stackapps.com/apps/oauth/register)
|
| 57 |
+
2. Fill out the form with your application details:
|
| 58 |
+
- Name: "Stack Overflow MCP" (or your preferred name)
|
| 59 |
+
- Description: "MCP server for accessing Stack Overflow"
|
| 60 |
+
- OAuth Domain: "localhost" (for local usage)
|
| 61 |
+
- Application Website: Your website or leave blank
|
| 62 |
+
3. Submit the form
|
| 63 |
+
4. Copy your API Key (shown as "Key" on the next page)
|
| 64 |
+
|
| 65 |
+
This API key is not considered a secret and may be safely embedded in client-side code or distributed binaries. It simply allows you to receive a higher request quota when making requests to the Stack Exchange API.
|
| 66 |
+
|
| 67 |
+
## Installation
|
| 68 |
+
|
| 69 |
+
### Installing from PyPI
|
| 70 |
+
|
| 71 |
+
[Stackoverflow PyPI page](https://pypi.org/project/stackoverflow-mcp/0.1.3/)
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
# Using pip
|
| 75 |
+
pip install stackoverflow-mcp
|
| 76 |
+
|
| 77 |
+
# OR Using uv
|
| 78 |
+
uv venv
|
| 79 |
+
uv pip install stackoverflow-mcp
|
| 80 |
+
|
| 81 |
+
# OR using uv wihtout an venv
|
| 82 |
+
uv pip install stackoverflow-mcp --system
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
### Installing from Source
|
| 86 |
+
|
| 87 |
+
```bash
|
| 88 |
+
# Clone the repository
|
| 89 |
+
git clone https://github.com/yourusername/stackoverflow-mcp-server.git
|
| 90 |
+
cd stackoverflow-mcp-server
|
| 91 |
+
|
| 92 |
+
# Install with uv
|
| 93 |
+
uv venv
|
| 94 |
+
uv pip install -e .
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
### Adding to Claude Desktop
|
| 98 |
+
|
| 99 |
+
To run the Stack Overflow MCP server with Claude Desktop:
|
| 100 |
+
|
| 101 |
+
1. Download [Claude Desktop](https://claude.ai/download).
|
| 102 |
+
|
| 103 |
+
2. Launch Claude and navigate to: Settings > Developer > Edit Config.
|
| 104 |
+
|
| 105 |
+
3. Update your `claude_desktop_config.json` file with the following configuration:
|
| 106 |
+
|
| 107 |
+
```json
|
| 108 |
+
{
|
| 109 |
+
"mcpServers": {
|
| 110 |
+
"stack-overflow": {
|
| 111 |
+
"command": "uv",
|
| 112 |
+
"args": ["run", "-m", "stackoverflow_mcp"],
|
| 113 |
+
"env": {
|
| 114 |
+
"STACK_EXCHANGE_API_KEY": "your_API_key"
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
You can also specify a custom directory:
|
| 122 |
+
|
| 123 |
+
```json
|
| 124 |
+
{
|
| 125 |
+
"mcpServers": {
|
| 126 |
+
"stack-overflow": {
|
| 127 |
+
"command": "uv",
|
| 128 |
+
"args": [
|
| 129 |
+
"--directory",
|
| 130 |
+
"/path/to/stackoverflow-mcp-server",
|
| 131 |
+
"run",
|
| 132 |
+
"main.py"
|
| 133 |
+
],
|
| 134 |
+
"env": {
|
| 135 |
+
"STACK_EXCHANGE_API_KEY": "your_api_key_here"
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
## Configuration
|
| 143 |
+
|
| 144 |
+
### Environment Variables
|
| 145 |
+
|
| 146 |
+
The server can be configured using these environment variables:
|
| 147 |
+
|
| 148 |
+
```bash
|
| 149 |
+
# Required
|
| 150 |
+
STACK_EXCHANGE_API_KEY=your_api_key_here
|
| 151 |
+
|
| 152 |
+
# Optional
|
| 153 |
+
MAX_REQUEST_PER_WINDOW=30 # Maximum requests per rate limit window
|
| 154 |
+
RATE_LIMIT_WINDOW_MS=60000 # Rate limit window in milliseconds (1 minute)
|
| 155 |
+
RETRY_AFTER_MS=2000 # Delay after hitting rate limit
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
### Using a .env File
|
| 159 |
+
|
| 160 |
+
You can create a `.env` file in the project root:
|
| 161 |
+
|
| 162 |
+
```
|
| 163 |
+
STACK_EXCHANGE_API_KEY=your_api_key_here
|
| 164 |
+
MAX_REQUEST_PER_WINDOW=30
|
| 165 |
+
RATE_LIMIT_WINDOW_MS=60000
|
| 166 |
+
RETRY_AFTER_MS=2000
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
## Usage
|
| 170 |
+
|
| 171 |
+
### Available Tools
|
| 172 |
+
|
| 173 |
+
The Stack Overflow MCP server provides the following tools:
|
| 174 |
+
|
| 175 |
+
#### 1. search_by_query
|
| 176 |
+
|
| 177 |
+
Search Stack Overflow for questions matching a query.
|
| 178 |
+
|
| 179 |
+
```
|
| 180 |
+
Parameters:
|
| 181 |
+
- query: The search query
|
| 182 |
+
- tags: Optional list of tags to filter by (e.g., ["python", "pandas"])
|
| 183 |
+
- excluded_tags: Optional list of tags to exclude
|
| 184 |
+
- min_score: Minimum score threshold for questions
|
| 185 |
+
- has_accepted_answer: Whether questions must have an accepted answer
|
| 186 |
+
- include_comments: Whether to include comments in results
|
| 187 |
+
- response_format: Format of response ("json" or "markdown")
|
| 188 |
+
- limit: Maximum number of results to return
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
#### 2. search_by_error
|
| 192 |
+
|
| 193 |
+
Search Stack Overflow for solutions to an error message.
|
| 194 |
+
|
| 195 |
+
```
|
| 196 |
+
Parameters:
|
| 197 |
+
- error_message: The error message to search for
|
| 198 |
+
- language: Programming language (e.g., "python", "javascript")
|
| 199 |
+
- technologies: Related technologies (e.g., ["react", "django"])
|
| 200 |
+
- min_score: Minimum score threshold for questions
|
| 201 |
+
- include_comments: Whether to include comments in results
|
| 202 |
+
- response_format: Format of response ("json" or "markdown")
|
| 203 |
+
- limit: Maximum number of results to return
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
#### 3. get_question
|
| 207 |
+
|
| 208 |
+
Get a specific Stack Overflow question by ID.
|
| 209 |
+
|
| 210 |
+
```
|
| 211 |
+
Parameters:
|
| 212 |
+
- question_id: The Stack Overflow question ID
|
| 213 |
+
- include_comments: Whether to include comments in results
|
| 214 |
+
- response_format: Format of response ("json" or "markdown")
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
#### 4. analyze_stack_trace
|
| 218 |
+
|
| 219 |
+
Analyze a stack trace and find relevant solutions on Stack Overflow.
|
| 220 |
+
|
| 221 |
+
```
|
| 222 |
+
Parameters:
|
| 223 |
+
- stack_trace: The stack trace to analyze
|
| 224 |
+
- language: Programming language of the stack trace
|
| 225 |
+
- include_comments: Whether to include comments in results
|
| 226 |
+
- response_format: Format of response ("json" or "markdown")
|
| 227 |
+
- limit: Maximum number of results to return
|
| 228 |
+
```
|
| 229 |
+
|
| 230 |
+
#### 5. advanced_search
|
| 231 |
+
|
| 232 |
+
Advanced search for Stack Overflow questions with many filter options.
|
| 233 |
+
|
| 234 |
+
```
|
| 235 |
+
Parameters:
|
| 236 |
+
- query: Free-form search query
|
| 237 |
+
- tags: List of tags to filter by
|
| 238 |
+
- excluded_tags: List of tags to exclude
|
| 239 |
+
- min_score: Minimum score threshold
|
| 240 |
+
- title: Text that must appear in the title
|
| 241 |
+
- body: Text that must appear in the body
|
| 242 |
+
- answers: Minimum number of answers
|
| 243 |
+
- has_accepted_answer: Whether questions must have an accepted answer
|
| 244 |
+
- sort_by: Field to sort by (activity, creation, votes, relevance)
|
| 245 |
+
- include_comments: Whether to include comments in results
|
| 246 |
+
- response_format: Format of response ("json" or "markdown")
|
| 247 |
+
- limit: Maximum number of results to return
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
## Development
|
| 251 |
+
|
| 252 |
+
This section is for contributors who want to develop or extend the Stack Overflow MCP server.
|
| 253 |
+
|
| 254 |
+
### Setting Up Development Environment
|
| 255 |
+
|
| 256 |
+
```bash
|
| 257 |
+
# Clone the repository
|
| 258 |
+
git clone https://github.com/yourusername/stackoverflow-mcp-server.git
|
| 259 |
+
cd stackoverflow-mcp-server
|
| 260 |
+
|
| 261 |
+
# Install dev dependencies
|
| 262 |
+
uv pip install -e ".[dev]"
|
| 263 |
+
```
|
| 264 |
+
|
| 265 |
+
### Running Tests
|
| 266 |
+
|
| 267 |
+
```bash
|
| 268 |
+
# Run all tests
|
| 269 |
+
pytest
|
| 270 |
+
|
| 271 |
+
# Run specific test modules
|
| 272 |
+
pytest tests/test_formatter.py
|
| 273 |
+
pytest tests/test_server.py
|
| 274 |
+
|
| 275 |
+
# Run tests with coverage report
|
| 276 |
+
pytest --cov=stackoverflow_mcp
|
| 277 |
+
```
|
| 278 |
+
|
| 279 |
+
### Project Structure
|
| 280 |
+
|
| 281 |
+
```
|
| 282 |
+
stackoverflow-mcp-server/
|
| 283 |
+
├── stackoverflow_mcp/ # Main package
|
| 284 |
+
│ ├── __init__.py
|
| 285 |
+
| |── __main__.py # Entry point
|
| 286 |
+
│ ├── api.py # Stack Exchange API client
|
| 287 |
+
│ ├── env.py # Environment configuration
|
| 288 |
+
│ ├── formatter.py # Response formatting utilities
|
| 289 |
+
│ ├── server.py # MCP server implementation
|
| 290 |
+
│ └── types.py # Data classes
|
| 291 |
+
├── tests/ # Test suite
|
| 292 |
+
│ ├── api/
|
| 293 |
+
│ │ └── test_search.py # API search tests
|
| 294 |
+
│ ├── test_formatter.py # Formatter tests
|
| 295 |
+
│ ├── test_general_api_health.py # API health tests
|
| 296 |
+
│ └── test_server.py # Server tests
|
| 297 |
+
├── pyproject.toml # Package configuration
|
| 298 |
+
├── api_query.py # testing stackexchange outside of MCP context
|
| 299 |
+
├── LICENSE # License file
|
| 300 |
+
└── README.md # This file
|
| 301 |
+
```
|
| 302 |
+
|
| 303 |
+
## Contributing
|
| 304 |
+
|
| 305 |
+
Contributions are welcome! Here's how you can contribute:
|
| 306 |
+
|
| 307 |
+
1. Fork the repository
|
| 308 |
+
2. Create a feature branch: `git checkout -b feature/my-feature`
|
| 309 |
+
3. Commit your changes: `git commit -am 'Add new feature'`
|
| 310 |
+
4. Push to the branch: `git push origin feature/my-feature`
|
| 311 |
+
5. Submit a pull request
|
| 312 |
+
|
| 313 |
+
Please make sure to update tests as appropriate and follow the project's coding style.
|
| 314 |
+
|
| 315 |
+
## License
|
| 316 |
+
|
| 317 |
+
This project is licensed under the MIT License - see the LICENSE file for details.
|
| 318 |
+
|
| 319 |
+
---
|
| 320 |
+
|
| 321 |
+
<p align="center">
|
| 322 |
+
Stack Overflow MCP Server: AI-accessible programming knowledge
|
| 323 |
+
</p>
|
| 324 |
+
|
| 325 |
+
<!-- Badges -->
|
| 326 |
|
| 327 |
+
[python-badge]: https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue.svg
|
| 328 |
+
[python-url]: https://www.python.org/downloads/
|
| 329 |
+
[license-badge]: https://img.shields.io/badge/license-MIT-green.svg
|
| 330 |
+
[license-url]: LICENSE
|
README_SPACES.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Stack Overflow MCP Server
|
| 3 |
+
emoji: 🔍
|
| 4 |
+
colorFrom: orange
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "5.33.1"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Stack Overflow MCP Server 🚀
|
| 14 |
+
|
| 15 |
+
A powerful Gradio app that serves as both a web interface and MCP (Model Context Protocol) server for Stack Overflow search capabilities.
|
| 16 |
+
|
| 17 |
+
## 🌟 Features
|
| 18 |
+
|
| 19 |
+
- **Web Interface**: Interactive search with 5 specialized tabs
|
| 20 |
+
- **MCP Server**: Expose 5 MCP tools for AI assistants
|
| 21 |
+
- **API Key Support**: Add your Stack Exchange API key for higher quotas
|
| 22 |
+
- **Real-time Search**: Fast and accurate Stack Overflow searches
|
| 23 |
+
|
| 24 |
+
## 🔧 MCP Integration
|
| 25 |
+
|
| 26 |
+
This app serves as an MCP server at the `/gradio_api/mcp/sse` endpoint. Connect your AI assistant using:
|
| 27 |
+
|
| 28 |
+
```json
|
| 29 |
+
{
|
| 30 |
+
"mcpServers": {
|
| 31 |
+
"stackoverflow": {
|
| 32 |
+
"url": "https://YOUR_SPACE_URL/gradio_api/mcp/sse"
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## 🎯 Available MCP Tools
|
| 39 |
+
|
| 40 |
+
1. **search_by_query_sync** - General Stack Overflow search
|
| 41 |
+
2. **search_by_error_sync** - Error-specific search
|
| 42 |
+
3. **get_question_sync** - Get specific question by ID
|
| 43 |
+
4. **analyze_stack_trace_sync** - Analyze stack traces
|
| 44 |
+
5. **advanced_search_sync** - Advanced search with filters
|
| 45 |
+
|
| 46 |
+
## 💡 Usage
|
| 47 |
+
|
| 48 |
+
1. **Web Interface**: Use the tabs to search Stack Overflow
|
| 49 |
+
2. **MCP Server**: Connect AI assistants to the MCP endpoint
|
| 50 |
+
3. **API Key**: Add your Stack Exchange API key for 10,000 requests/day
|
| 51 |
+
|
| 52 |
+
Built with ❤️ for the MCP Hackathon
|
api_query.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Stack Exchange API Query Tool
|
| 4 |
+
|
| 5 |
+
This script allows you to directly call the Stack Exchange API with various parameters
|
| 6 |
+
and see the results. It's useful for testing queries and seeing the raw results.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python api_query.py search "python pandas dataframe" --tags python,pandas --min-score 10
|
| 10 |
+
python api_query.py question 12345
|
| 11 |
+
python api_query.py error "TypeError: cannot use a string pattern" --language python
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
import json
|
| 17 |
+
import asyncio
|
| 18 |
+
import argparse
|
| 19 |
+
from dotenv import load_dotenv
|
| 20 |
+
|
| 21 |
+
from stackoverflow_mcp.api import StackExchangeAPI
|
| 22 |
+
from stackoverflow_mcp.formatter import format_response
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def setup_environment():
|
| 26 |
+
"""Load environment variables from .env file"""
|
| 27 |
+
if os.path.exists(".env"):
|
| 28 |
+
load_dotenv(".env")
|
| 29 |
+
elif os.path.exists(".env.test"):
|
| 30 |
+
load_dotenv(".env.test")
|
| 31 |
+
else:
|
| 32 |
+
print("Warning: No .env or .env.test file found. Using default settings.")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def run_search_query(api, args):
|
| 36 |
+
"""Run a search query with the given arguments"""
|
| 37 |
+
tags = args.tags.split(',') if args.tags else None
|
| 38 |
+
|
| 39 |
+
excluded_tags = args.excluded_tags.split(',') if args.excluded_tags else None
|
| 40 |
+
|
| 41 |
+
print(f"\nRunning search query: '{args.query}'")
|
| 42 |
+
if args.title:
|
| 43 |
+
print(f"Running search with title containing: '{args.title}'")
|
| 44 |
+
if args.body:
|
| 45 |
+
print(f"Running search with body containing: '{args.body}'")
|
| 46 |
+
print(f"Tags: {tags}")
|
| 47 |
+
print(f"Excluded tags: {excluded_tags}")
|
| 48 |
+
print(f"Min score: {args.min_score}")
|
| 49 |
+
print(f"Limit: {args.limit}")
|
| 50 |
+
print(f"Include comments: {args.comments}\n")
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
results = await api.search_by_query(
|
| 54 |
+
query=args.query,
|
| 55 |
+
tags=tags,
|
| 56 |
+
title=args.title,
|
| 57 |
+
body=args.body,
|
| 58 |
+
excluded_tags=excluded_tags,
|
| 59 |
+
min_score=args.min_score,
|
| 60 |
+
limit=args.limit,
|
| 61 |
+
include_comments=args.comments
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
print(f"Found {len(results)} results")
|
| 65 |
+
|
| 66 |
+
if args.raw:
|
| 67 |
+
for i, result in enumerate(results):
|
| 68 |
+
print(f"\n--- Result {i+1} ---")
|
| 69 |
+
print(f"Question ID: {result.question.question_id}")
|
| 70 |
+
print(f"Title: {result.question.title}")
|
| 71 |
+
print(f"Score: {result.question.score}")
|
| 72 |
+
print(f"Tags: {result.question.tags}")
|
| 73 |
+
print(f"Link: {result.question.link}")
|
| 74 |
+
print(f"Answers: {len(result.answers)}")
|
| 75 |
+
if result.comments:
|
| 76 |
+
print(f"Question comments: {len(result.comments.question)}")
|
| 77 |
+
else:
|
| 78 |
+
formatted = format_response(results, args.format)
|
| 79 |
+
print(formatted)
|
| 80 |
+
|
| 81 |
+
except Exception as e:
|
| 82 |
+
print(f"Error during search: {str(e)}")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
async def run_question_query(api, args):
|
| 86 |
+
"""Get a specific question by ID"""
|
| 87 |
+
try:
|
| 88 |
+
print(f"\nFetching question ID: {args.question_id}")
|
| 89 |
+
print(f"Include comments: {args.comments}\n")
|
| 90 |
+
|
| 91 |
+
result = await api.get_question(
|
| 92 |
+
question_id=args.question_id,
|
| 93 |
+
include_comments=args.comments
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
if args.raw:
|
| 97 |
+
print(f"Question ID: {result.question.question_id}")
|
| 98 |
+
print(f"Title: {result.question.title}")
|
| 99 |
+
print(f"Score: {result.question.score}")
|
| 100 |
+
print(f"Tags: {result.question.tags}")
|
| 101 |
+
print(f"Link: {result.question.link}")
|
| 102 |
+
print(f"Answers: {len(result.answers)}")
|
| 103 |
+
if result.comments:
|
| 104 |
+
print(f"Question comments: {len(result.comments.question)}")
|
| 105 |
+
else:
|
| 106 |
+
formatted = format_response([result], args.format)
|
| 107 |
+
print(formatted)
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
print(f"Error fetching question: {str(e)}")
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
async def run_error_query(api, args):
|
| 114 |
+
"""Search for an error message with optional language filter"""
|
| 115 |
+
technologies = args.technologies.split(',') if args.technologies else None
|
| 116 |
+
|
| 117 |
+
try:
|
| 118 |
+
print(f"\nSearching for error: '{args.error}'")
|
| 119 |
+
print(f"Language: {args.language}")
|
| 120 |
+
print(f"Technologies: {technologies}")
|
| 121 |
+
if args.title:
|
| 122 |
+
print(f"Title containing: '{args.title}'")
|
| 123 |
+
if args.body:
|
| 124 |
+
print(f"Body containing: '{args.body}'")
|
| 125 |
+
print(f"Min score: {args.min_score}")
|
| 126 |
+
print(f"Limit: {args.limit}")
|
| 127 |
+
print(f"Include comments: {args.comments}\n")
|
| 128 |
+
|
| 129 |
+
tags = []
|
| 130 |
+
if args.language:
|
| 131 |
+
tags.append(args.language.lower())
|
| 132 |
+
if technologies:
|
| 133 |
+
tags.extend([t.lower() for t in technologies])
|
| 134 |
+
|
| 135 |
+
results = await api.search_by_query(
|
| 136 |
+
query=args.error,
|
| 137 |
+
title=args.title,
|
| 138 |
+
body=args.body,
|
| 139 |
+
tags=tags if tags else None,
|
| 140 |
+
min_score=args.min_score,
|
| 141 |
+
limit=args.limit,
|
| 142 |
+
include_comments=args.comments
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
print(f"Found {len(results)} results")
|
| 146 |
+
|
| 147 |
+
if args.raw:
|
| 148 |
+
for i, result in enumerate(results):
|
| 149 |
+
print(f"\n--- Result {i+1} ---")
|
| 150 |
+
print(f"Question ID: {result.question.question_id}")
|
| 151 |
+
print(f"Title: {result.question.title}")
|
| 152 |
+
print(f"Score: {result.question.score}")
|
| 153 |
+
print(f"Tags: {result.question.tags}")
|
| 154 |
+
print(f"Link: {result.question.link}")
|
| 155 |
+
print(f"Answers: {len(result.answers)}")
|
| 156 |
+
if result.comments:
|
| 157 |
+
print(f"Question comments: {len(result.comments.question)}")
|
| 158 |
+
else:
|
| 159 |
+
formatted = format_response(results, args.format)
|
| 160 |
+
print(formatted)
|
| 161 |
+
|
| 162 |
+
except Exception as e:
|
| 163 |
+
print(f"Error searching for error: {str(e)}")
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
async def main():
|
| 167 |
+
"""Parse arguments and run the appropriate query"""
|
| 168 |
+
parser = argparse.ArgumentParser(description="Stack Exchange API Query Tool")
|
| 169 |
+
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
| 170 |
+
|
| 171 |
+
# Search command
|
| 172 |
+
search_parser = subparsers.add_parser("search", help="Search Stack Overflow")
|
| 173 |
+
search_parser.add_argument("query", help="Search query")
|
| 174 |
+
search_parser.add_argument("--tags", help="Comma-separated list of tags")
|
| 175 |
+
search_parser.add_argument("--title", help="Word(s) that must appear in the question title")
|
| 176 |
+
search_parser.add_argument("--body", help="Word(s) that must appear in the body of the question")
|
| 177 |
+
search_parser.add_argument("--excluded-tags", help="Comma-separated list of tags to exclude")
|
| 178 |
+
search_parser.add_argument("--min-score", type=int, default=0, help="Minimum score")
|
| 179 |
+
search_parser.add_argument("--limit", type=int, default=5, help="Maximum number of results")
|
| 180 |
+
search_parser.add_argument("--comments", action="store_true", help="Include comments")
|
| 181 |
+
search_parser.add_argument("--format", choices=["markdown", "json"], default="markdown", help="Output format")
|
| 182 |
+
search_parser.add_argument("--raw", action="store_true", help="Print raw data structure")
|
| 183 |
+
|
| 184 |
+
# Question command
|
| 185 |
+
question_parser = subparsers.add_parser("question", help="Get a specific question")
|
| 186 |
+
question_parser.add_argument("question_id", type=int, help="Question ID")
|
| 187 |
+
question_parser.add_argument("--comments", action="store_true", help="Include comments")
|
| 188 |
+
question_parser.add_argument("--format", choices=["markdown", "json"], default="markdown", help="Output format")
|
| 189 |
+
question_parser.add_argument("--raw", action="store_true", help="Print raw data structure")
|
| 190 |
+
|
| 191 |
+
# Error command
|
| 192 |
+
error_parser = subparsers.add_parser("error", help="Search for an error message")
|
| 193 |
+
error_parser.add_argument("error", help="Error message")
|
| 194 |
+
error_parser.add_argument("--title", help="Word(s) that must appear in the question title")
|
| 195 |
+
error_parser.add_argument("--body", help="Word(s) that must appear in the body of the question")
|
| 196 |
+
error_parser.add_argument("--language", help="Programming language")
|
| 197 |
+
error_parser.add_argument("--technologies", help="Comma-separated list of technologies")
|
| 198 |
+
error_parser.add_argument("--min-score", type=int, default=0, help="Minimum score")
|
| 199 |
+
error_parser.add_argument("--limit", type=int, default=5, help="Maximum number of results")
|
| 200 |
+
error_parser.add_argument("--comments", action="store_true", help="Include comments")
|
| 201 |
+
error_parser.add_argument("--format", choices=["markdown", "json"], default="markdown", help="Output format")
|
| 202 |
+
error_parser.add_argument("--raw", action="store_true", help="Print raw data structure")
|
| 203 |
+
|
| 204 |
+
args = parser.parse_args()
|
| 205 |
+
|
| 206 |
+
if not args.command:
|
| 207 |
+
parser.print_help()
|
| 208 |
+
return 1
|
| 209 |
+
|
| 210 |
+
setup_environment()
|
| 211 |
+
|
| 212 |
+
api_key = os.getenv("STACK_EXCHANGE_API_KEY")
|
| 213 |
+
|
| 214 |
+
if not api_key:
|
| 215 |
+
print("Warning: No API key found. Requests may be rate limited.")
|
| 216 |
+
|
| 217 |
+
api = StackExchangeAPI(api_key=api_key)
|
| 218 |
+
|
| 219 |
+
try:
|
| 220 |
+
if args.command == "search":
|
| 221 |
+
await run_search_query(api, args)
|
| 222 |
+
elif args.command == "question":
|
| 223 |
+
await run_question_query(api, args)
|
| 224 |
+
elif args.command == "error":
|
| 225 |
+
await run_error_query(api, args)
|
| 226 |
+
|
| 227 |
+
except Exception as e:
|
| 228 |
+
print(f"Error: {str(e)}")
|
| 229 |
+
return 1
|
| 230 |
+
|
| 231 |
+
finally:
|
| 232 |
+
await api.close()
|
| 233 |
+
|
| 234 |
+
return 0
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
if __name__ == "__main__":
|
| 238 |
+
sys.exit(asyncio.run(main()))
|
app.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Stack Overflow MCP Server - Hugging Face Spaces App
|
| 4 |
+
A web interface and MCP server that provides Stack Overflow search capabilities.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
# Import everything from the main gradio_app
|
| 8 |
+
from gradio_app import *
|
| 9 |
+
|
| 10 |
+
if __name__ == "__main__":
|
| 11 |
+
# Launch the app for Hugging Face Spaces
|
| 12 |
+
demo.launch(
|
| 13 |
+
mcp_server=True,
|
| 14 |
+
server_name="0.0.0.0",
|
| 15 |
+
server_port=7860,
|
| 16 |
+
show_error=True,
|
| 17 |
+
share=False
|
| 18 |
+
)
|
demo_mcp_client.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
MCP Client Demo for Stack Overflow Server
|
| 4 |
+
Demonstrates how to interact with the Stack Overflow MCP server programmatically.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import json
|
| 9 |
+
from typing import Dict, Any
|
| 10 |
+
|
| 11 |
+
# This is a demonstration of how an MCP client would interact with our server
|
| 12 |
+
# In practice, you would use a proper MCP client library
|
| 13 |
+
|
| 14 |
+
async def demo_mcp_calls():
|
| 15 |
+
"""
|
| 16 |
+
Demonstrate various MCP calls to the Stack Overflow server.
|
| 17 |
+
This simulates what an AI assistant like Claude would do.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
print("🤖 Stack Overflow MCP Server Demo")
|
| 21 |
+
print("=" * 50)
|
| 22 |
+
|
| 23 |
+
# Demo 1: General Search
|
| 24 |
+
print("\n1️⃣ GENERAL SEARCH DEMO")
|
| 25 |
+
print("Query: 'Django pagination best practices'")
|
| 26 |
+
print("Tags: ['python', 'django']")
|
| 27 |
+
print("Expected: High-quality Django pagination solutions")
|
| 28 |
+
|
| 29 |
+
# Demo 2: Error Search
|
| 30 |
+
print("\n2️⃣ ERROR SEARCH DEMO")
|
| 31 |
+
print("Error: 'TypeError: NoneType object has no attribute'")
|
| 32 |
+
print("Language: Python")
|
| 33 |
+
print("Expected: Common solutions for NoneType errors")
|
| 34 |
+
|
| 35 |
+
# Demo 3: Question Retrieval
|
| 36 |
+
print("\n3️⃣ QUESTION RETRIEVAL DEMO")
|
| 37 |
+
print("Question ID: 11227809")
|
| 38 |
+
print("Expected: Famous 'Why is processing a sorted array faster?' question")
|
| 39 |
+
|
| 40 |
+
# Demo 4: Stack Trace Analysis
|
| 41 |
+
print("\n4️⃣ STACK TRACE ANALYSIS DEMO")
|
| 42 |
+
print("Stack Trace: 'ReferenceError: useState is not defined'")
|
| 43 |
+
print("Language: JavaScript")
|
| 44 |
+
print("Expected: React hooks solutions")
|
| 45 |
+
|
| 46 |
+
# Demo 5: Advanced Search
|
| 47 |
+
print("\n5️⃣ ADVANCED SEARCH DEMO")
|
| 48 |
+
print("Query: 'memory optimization'")
|
| 49 |
+
print("Tags: ['c++', 'performance']")
|
| 50 |
+
print("Min Score: 50")
|
| 51 |
+
print("Expected: High-quality C++ performance answers")
|
| 52 |
+
|
| 53 |
+
print("\n" + "=" * 50)
|
| 54 |
+
print("🎯 All demos completed!")
|
| 55 |
+
print("💡 These are the types of searches our MCP server can handle")
|
| 56 |
+
print("🚀 Try them in the web interface or via MCP client!")
|
| 57 |
+
|
| 58 |
+
def demo_gradio_api_calls():
|
| 59 |
+
"""
|
| 60 |
+
Demonstrate how the Gradio API exposes the MCP functionality.
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
print("\n🎨 GRADIO API INTEGRATION")
|
| 64 |
+
print("=" * 50)
|
| 65 |
+
|
| 66 |
+
# Show the available API endpoints
|
| 67 |
+
api_endpoints = {
|
| 68 |
+
"search_by_query_sync": {
|
| 69 |
+
"description": "General Stack Overflow search",
|
| 70 |
+
"inputs": ["query", "tags", "min_score", "has_accepted_answer", "limit", "response_format"],
|
| 71 |
+
"example": {
|
| 72 |
+
"query": "Django pagination best practices",
|
| 73 |
+
"tags": "python,django",
|
| 74 |
+
"min_score": 5,
|
| 75 |
+
"has_accepted_answer": True,
|
| 76 |
+
"limit": 5,
|
| 77 |
+
"response_format": "markdown"
|
| 78 |
+
}
|
| 79 |
+
},
|
| 80 |
+
"search_by_error_sync": {
|
| 81 |
+
"description": "Error-specific search",
|
| 82 |
+
"inputs": ["error_message", "language", "technologies", "min_score", "has_accepted_answer", "limit", "response_format"],
|
| 83 |
+
"example": {
|
| 84 |
+
"error_message": "TypeError: 'NoneType' object has no attribute",
|
| 85 |
+
"language": "python",
|
| 86 |
+
"technologies": "flask,sqlalchemy",
|
| 87 |
+
"min_score": 0,
|
| 88 |
+
"has_accepted_answer": True,
|
| 89 |
+
"limit": 5,
|
| 90 |
+
"response_format": "markdown"
|
| 91 |
+
}
|
| 92 |
+
},
|
| 93 |
+
"get_question_sync": {
|
| 94 |
+
"description": "Get specific question by ID",
|
| 95 |
+
"inputs": ["question_id", "include_comments", "response_format"],
|
| 96 |
+
"example": {
|
| 97 |
+
"question_id": "11227809",
|
| 98 |
+
"include_comments": True,
|
| 99 |
+
"response_format": "markdown"
|
| 100 |
+
}
|
| 101 |
+
},
|
| 102 |
+
"analyze_stack_trace_sync": {
|
| 103 |
+
"description": "Analyze stack traces",
|
| 104 |
+
"inputs": ["stack_trace", "language", "min_score", "has_accepted_answer", "limit", "response_format"],
|
| 105 |
+
"example": {
|
| 106 |
+
"stack_trace": "ReferenceError: useState is not defined\n at Component.render",
|
| 107 |
+
"language": "javascript",
|
| 108 |
+
"min_score": 5,
|
| 109 |
+
"has_accepted_answer": True,
|
| 110 |
+
"limit": 3,
|
| 111 |
+
"response_format": "markdown"
|
| 112 |
+
}
|
| 113 |
+
},
|
| 114 |
+
"advanced_search_sync": {
|
| 115 |
+
"description": "Advanced search with comprehensive filters",
|
| 116 |
+
"inputs": ["query", "tags", "excluded_tags", "min_score", "title", "body", "min_answers", "has_accepted_answer", "min_views", "sort_by", "limit", "response_format"],
|
| 117 |
+
"example": {
|
| 118 |
+
"query": "memory optimization",
|
| 119 |
+
"tags": "c++,performance",
|
| 120 |
+
"excluded_tags": "beginner",
|
| 121 |
+
"min_score": 50,
|
| 122 |
+
"title": "",
|
| 123 |
+
"body": "",
|
| 124 |
+
"min_answers": 1,
|
| 125 |
+
"has_accepted_answer": False,
|
| 126 |
+
"min_views": 1000,
|
| 127 |
+
"sort_by": "votes",
|
| 128 |
+
"limit": 5,
|
| 129 |
+
"response_format": "markdown"
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
for endpoint, info in api_endpoints.items():
|
| 135 |
+
print(f"\n🔧 {endpoint}")
|
| 136 |
+
print(f" 📝 {info['description']}")
|
| 137 |
+
print(f" 📊 Example:")
|
| 138 |
+
for key, value in info['example'].items():
|
| 139 |
+
print(f" {key}: {value}")
|
| 140 |
+
|
| 141 |
+
def demo_mcp_integration():
|
| 142 |
+
"""
|
| 143 |
+
Show how to integrate with MCP clients like Claude Desktop.
|
| 144 |
+
"""
|
| 145 |
+
|
| 146 |
+
print("\n🔗 MCP CLIENT INTEGRATION")
|
| 147 |
+
print("=" * 50)
|
| 148 |
+
{
|
| 149 |
+
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
claude_config = {
|
| 153 |
+
"mcpServers": {
|
| 154 |
+
"stackoverflow": {
|
| 155 |
+
"command": "npx",
|
| 156 |
+
"args": [
|
| 157 |
+
"mcp-remote",
|
| 158 |
+
"https://c44b366466c774a9d5.gradio.live/gradio_api/mcp/sse"
|
| 159 |
+
]
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
print("💻 Claude Desktop Configuration:")
|
| 165 |
+
print(json.dumps(claude_config, indent=2))
|
| 166 |
+
|
| 167 |
+
print("\n🤖 Example AI Assistant Prompts:")
|
| 168 |
+
prompts = [
|
| 169 |
+
"Search Stack Overflow for Django pagination best practices",
|
| 170 |
+
"Find solutions for the error 'TypeError: NoneType object has no attribute'",
|
| 171 |
+
"Get Stack Overflow question 11227809",
|
| 172 |
+
"Analyze this JavaScript error: ReferenceError: useState is not defined",
|
| 173 |
+
"Find high-scored C++ memory optimization questions"
|
| 174 |
+
]
|
| 175 |
+
|
| 176 |
+
for i, prompt in enumerate(prompts, 1):
|
| 177 |
+
print(f" {i}. {prompt}")
|
| 178 |
+
|
| 179 |
+
if __name__ == "__main__":
|
| 180 |
+
print("🚀 Starting Stack Overflow MCP Server Demo...")
|
| 181 |
+
|
| 182 |
+
# Run the async demo
|
| 183 |
+
asyncio.run(demo_mcp_calls())
|
| 184 |
+
|
| 185 |
+
# Show Gradio API integration
|
| 186 |
+
demo_gradio_api_calls()
|
| 187 |
+
|
| 188 |
+
# Show MCP client integration
|
| 189 |
+
demo_mcp_integration()
|
| 190 |
+
|
| 191 |
+
print("\n🎯 Demo completed! Visit the web interface to try it live:")
|
| 192 |
+
print("🌐 https://c44b366466c774a9d5.gradio.live")
|
| 193 |
+
print("🔗 MCP Endpoint: https://c44b366466c774a9d5.gradio.live/gradio_api/mcp/sse")
|
gradio_app.py
ADDED
|
@@ -0,0 +1,810 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Gradio MCP Server for Stack Overflow Search
|
| 4 |
+
A web interface and MCP server that provides Stack Overflow search capabilities.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import os
|
| 9 |
+
from typing import List, Optional, Tuple
|
| 10 |
+
import gradio as gr
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
|
| 13 |
+
from stackoverflow_mcp.api import StackExchangeAPI
|
| 14 |
+
from stackoverflow_mcp.formatter import format_response
|
| 15 |
+
from stackoverflow_mcp.env import STACK_EXCHANGE_API_KEY
|
| 16 |
+
|
| 17 |
+
# Initialize a default API client (can be overridden with user's key)
|
| 18 |
+
default_api = StackExchangeAPI(api_key=STACK_EXCHANGE_API_KEY)
|
| 19 |
+
|
| 20 |
+
def get_api_client(api_key: str = "") -> StackExchangeAPI:
|
| 21 |
+
"""Get API client with user's key or fallback to default."""
|
| 22 |
+
if api_key and api_key.strip():
|
| 23 |
+
return StackExchangeAPI(api_key=api_key.strip())
|
| 24 |
+
return default_api
|
| 25 |
+
|
| 26 |
+
def search_by_query_sync(
|
| 27 |
+
query: str,
|
| 28 |
+
tags: str = "",
|
| 29 |
+
min_score: int = 0,
|
| 30 |
+
has_accepted_answer: bool = False,
|
| 31 |
+
limit: int = 5,
|
| 32 |
+
response_format: str = "markdown",
|
| 33 |
+
api_key: str = ""
|
| 34 |
+
) -> str:
|
| 35 |
+
"""
|
| 36 |
+
Search Stack Overflow for questions matching a query.
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
query (str): The search query
|
| 40 |
+
tags (str): Comma-separated list of tags to filter by (e.g., "python,pandas")
|
| 41 |
+
min_score (int): Minimum score threshold for questions
|
| 42 |
+
has_accepted_answer (bool): Whether questions must have an accepted answer
|
| 43 |
+
limit (int): Maximum number of results to return (1-20)
|
| 44 |
+
response_format (str): Format of response ("json" or "markdown")
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
str: Formatted search results
|
| 48 |
+
"""
|
| 49 |
+
if not query.strip():
|
| 50 |
+
return "❌ Please enter a search query."
|
| 51 |
+
|
| 52 |
+
# Convert tags string to list
|
| 53 |
+
tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()] if tags else None
|
| 54 |
+
|
| 55 |
+
# Limit the range
|
| 56 |
+
limit = max(1, min(limit, 20))
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
# Get API client with user's key
|
| 60 |
+
api = get_api_client(api_key)
|
| 61 |
+
|
| 62 |
+
# Run the async function safely
|
| 63 |
+
try:
|
| 64 |
+
loop = asyncio.get_event_loop()
|
| 65 |
+
if loop.is_closed():
|
| 66 |
+
raise RuntimeError("Event loop is closed")
|
| 67 |
+
except RuntimeError:
|
| 68 |
+
loop = asyncio.new_event_loop()
|
| 69 |
+
asyncio.set_event_loop(loop)
|
| 70 |
+
|
| 71 |
+
results = loop.run_until_complete(
|
| 72 |
+
api.search_by_query(
|
| 73 |
+
query=query,
|
| 74 |
+
tags=tags_list,
|
| 75 |
+
min_score=min_score if min_score > 0 else None,
|
| 76 |
+
has_accepted_answer=has_accepted_answer if has_accepted_answer else None,
|
| 77 |
+
limit=limit
|
| 78 |
+
)
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
if not results:
|
| 82 |
+
return f"🔍 No results found for query: '{query}'"
|
| 83 |
+
|
| 84 |
+
return format_response(results, response_format)
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
return f"❌ Error searching Stack Overflow: {str(e)}"
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def search_by_error_sync(
|
| 91 |
+
error_message: str,
|
| 92 |
+
language: str = "",
|
| 93 |
+
technologies: str = "",
|
| 94 |
+
min_score: int = 0,
|
| 95 |
+
has_accepted_answer: bool = False,
|
| 96 |
+
limit: int = 5,
|
| 97 |
+
response_format: str = "markdown",
|
| 98 |
+
api_key: str = ""
|
| 99 |
+
) -> str:
|
| 100 |
+
"""
|
| 101 |
+
Search Stack Overflow for solutions to an error message.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
error_message (str): The error message to search for
|
| 105 |
+
language (str): Programming language (e.g., "python", "javascript")
|
| 106 |
+
technologies (str): Comma-separated related technologies (e.g., "react,django")
|
| 107 |
+
min_score (int): Minimum score threshold for questions
|
| 108 |
+
has_accepted_answer (bool): Whether questions must have an accepted answer
|
| 109 |
+
limit (int): Maximum number of results to return (1-20)
|
| 110 |
+
response_format (str): Format of response ("json" or "markdown")
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
str: Formatted search results
|
| 114 |
+
"""
|
| 115 |
+
if not error_message.strip():
|
| 116 |
+
return "❌ Please enter an error message."
|
| 117 |
+
|
| 118 |
+
# Build tags list
|
| 119 |
+
tags = []
|
| 120 |
+
if language.strip():
|
| 121 |
+
tags.append(language.strip().lower())
|
| 122 |
+
if technologies.strip():
|
| 123 |
+
tags.extend([tech.strip().lower() for tech in technologies.split(",") if tech.strip()])
|
| 124 |
+
|
| 125 |
+
# Limit the range
|
| 126 |
+
limit = max(1, min(limit, 20))
|
| 127 |
+
|
| 128 |
+
try:
|
| 129 |
+
# Get API client with user's key
|
| 130 |
+
api = get_api_client(api_key)
|
| 131 |
+
|
| 132 |
+
# Run the async function safely
|
| 133 |
+
try:
|
| 134 |
+
loop = asyncio.get_event_loop()
|
| 135 |
+
if loop.is_closed():
|
| 136 |
+
raise RuntimeError("Event loop is closed")
|
| 137 |
+
except RuntimeError:
|
| 138 |
+
loop = asyncio.new_event_loop()
|
| 139 |
+
asyncio.set_event_loop(loop)
|
| 140 |
+
|
| 141 |
+
results = loop.run_until_complete(
|
| 142 |
+
api.search_by_query(
|
| 143 |
+
query=error_message,
|
| 144 |
+
tags=tags if tags else None,
|
| 145 |
+
min_score=min_score if min_score > 0 else None,
|
| 146 |
+
has_accepted_answer=has_accepted_answer if has_accepted_answer else None,
|
| 147 |
+
limit=limit
|
| 148 |
+
)
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
if not results:
|
| 152 |
+
return f"🔍 No results found for error: '{error_message}'"
|
| 153 |
+
|
| 154 |
+
return format_response(results, response_format)
|
| 155 |
+
|
| 156 |
+
except Exception as e:
|
| 157 |
+
return f"❌ Error searching Stack Overflow: {str(e)}"
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def get_question_sync(
|
| 161 |
+
question_id: str,
|
| 162 |
+
include_comments: bool = True,
|
| 163 |
+
response_format: str = "markdown",
|
| 164 |
+
api_key: str = ""
|
| 165 |
+
) -> str:
|
| 166 |
+
"""
|
| 167 |
+
Get a specific Stack Overflow question by ID.
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
question_id (str): The Stack Overflow question ID
|
| 171 |
+
include_comments (bool): Whether to include comments in results
|
| 172 |
+
response_format (str): Format of response ("json" or "markdown")
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
str: Formatted question details
|
| 176 |
+
"""
|
| 177 |
+
if not question_id.strip():
|
| 178 |
+
return "❌ Please enter a question ID."
|
| 179 |
+
|
| 180 |
+
try:
|
| 181 |
+
# Convert to int
|
| 182 |
+
q_id = int(question_id.strip())
|
| 183 |
+
|
| 184 |
+
# Get API client with user's key
|
| 185 |
+
api = get_api_client(api_key)
|
| 186 |
+
|
| 187 |
+
# Run the async function safely
|
| 188 |
+
try:
|
| 189 |
+
loop = asyncio.get_event_loop()
|
| 190 |
+
if loop.is_closed():
|
| 191 |
+
raise RuntimeError("Event loop is closed")
|
| 192 |
+
except RuntimeError:
|
| 193 |
+
loop = asyncio.new_event_loop()
|
| 194 |
+
asyncio.set_event_loop(loop)
|
| 195 |
+
|
| 196 |
+
result = loop.run_until_complete(
|
| 197 |
+
api.get_question(
|
| 198 |
+
question_id=q_id,
|
| 199 |
+
include_comments=include_comments
|
| 200 |
+
)
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
return format_response([result], response_format)
|
| 204 |
+
|
| 205 |
+
except ValueError:
|
| 206 |
+
return "❌ Question ID must be a number."
|
| 207 |
+
except Exception as e:
|
| 208 |
+
return f"❌ Error fetching question: {str(e)}"
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def analyze_stack_trace_sync(
|
| 212 |
+
stack_trace: str,
|
| 213 |
+
language: str,
|
| 214 |
+
min_score: int = 0,
|
| 215 |
+
has_accepted_answer: bool = False,
|
| 216 |
+
limit: int = 3,
|
| 217 |
+
response_format: str = "markdown",
|
| 218 |
+
api_key: str = ""
|
| 219 |
+
) -> str:
|
| 220 |
+
"""
|
| 221 |
+
Analyze a stack trace and find relevant solutions on Stack Overflow.
|
| 222 |
+
|
| 223 |
+
Args:
|
| 224 |
+
stack_trace (str): The stack trace to analyze
|
| 225 |
+
language (str): Programming language of the stack trace
|
| 226 |
+
min_score (int): Minimum score threshold for questions
|
| 227 |
+
has_accepted_answer (bool): Whether questions must have an accepted answer
|
| 228 |
+
limit (int): Maximum number of results to return (1-10)
|
| 229 |
+
response_format (str): Format of response ("json" or "markdown")
|
| 230 |
+
|
| 231 |
+
Returns:
|
| 232 |
+
str: Formatted search results
|
| 233 |
+
"""
|
| 234 |
+
if not stack_trace.strip():
|
| 235 |
+
return "❌ Please enter a stack trace."
|
| 236 |
+
|
| 237 |
+
if not language.strip():
|
| 238 |
+
return "❌ Please specify the programming language."
|
| 239 |
+
|
| 240 |
+
# Limit the range
|
| 241 |
+
limit = max(1, min(limit, 10))
|
| 242 |
+
|
| 243 |
+
# Extract the first line as the main error
|
| 244 |
+
error_lines = stack_trace.strip().split("\n")
|
| 245 |
+
error_message = error_lines[0]
|
| 246 |
+
|
| 247 |
+
try:
|
| 248 |
+
# Get API client with user's key
|
| 249 |
+
api = get_api_client(api_key)
|
| 250 |
+
|
| 251 |
+
# Run the async function safely
|
| 252 |
+
try:
|
| 253 |
+
loop = asyncio.get_event_loop()
|
| 254 |
+
if loop.is_closed():
|
| 255 |
+
raise RuntimeError("Event loop is closed")
|
| 256 |
+
except RuntimeError:
|
| 257 |
+
loop = asyncio.new_event_loop()
|
| 258 |
+
asyncio.set_event_loop(loop)
|
| 259 |
+
|
| 260 |
+
results = loop.run_until_complete(
|
| 261 |
+
api.search_by_query(
|
| 262 |
+
query=error_message,
|
| 263 |
+
tags=[language.strip().lower()],
|
| 264 |
+
min_score=min_score if min_score > 0 else None,
|
| 265 |
+
has_accepted_answer=has_accepted_answer if has_accepted_answer else None,
|
| 266 |
+
limit=limit
|
| 267 |
+
)
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
if not results:
|
| 271 |
+
return f"🔍 No results found for stack trace error: '{error_message}'"
|
| 272 |
+
|
| 273 |
+
return format_response(results, response_format)
|
| 274 |
+
|
| 275 |
+
except Exception as e:
|
| 276 |
+
return f"❌ Error analyzing stack trace: {str(e)}"
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def advanced_search_sync(
|
| 280 |
+
query: str = "",
|
| 281 |
+
tags: str = "",
|
| 282 |
+
excluded_tags: str = "",
|
| 283 |
+
min_score: int = 0,
|
| 284 |
+
title: str = "",
|
| 285 |
+
body: str = "",
|
| 286 |
+
min_answers: int = 0,
|
| 287 |
+
has_accepted_answer: bool = False,
|
| 288 |
+
min_views: int = 0,
|
| 289 |
+
sort_by: str = "votes",
|
| 290 |
+
limit: int = 5,
|
| 291 |
+
response_format: str = "markdown",
|
| 292 |
+
api_key: str = ""
|
| 293 |
+
) -> str:
|
| 294 |
+
"""
|
| 295 |
+
Advanced search for Stack Overflow questions with comprehensive filters.
|
| 296 |
+
|
| 297 |
+
Args:
|
| 298 |
+
query (str): Free-form search query
|
| 299 |
+
tags (str): Comma-separated list of tags to filter by
|
| 300 |
+
excluded_tags (str): Comma-separated list of tags to exclude
|
| 301 |
+
min_score (int): Minimum score threshold
|
| 302 |
+
title (str): Text that must appear in the title
|
| 303 |
+
body (str): Text that must appear in the body
|
| 304 |
+
min_answers (int): Minimum number of answers
|
| 305 |
+
has_accepted_answer (bool): Whether questions must have an accepted answer
|
| 306 |
+
min_views (int): Minimum number of views
|
| 307 |
+
sort_by (str): Field to sort by (activity, creation, votes, relevance)
|
| 308 |
+
limit (int): Maximum number of results to return (1-20)
|
| 309 |
+
response_format (str): Format of response ("json" or "markdown")
|
| 310 |
+
|
| 311 |
+
Returns:
|
| 312 |
+
str: Formatted search results
|
| 313 |
+
"""
|
| 314 |
+
if not query.strip() and not tags.strip() and not title.strip() and not body.strip():
|
| 315 |
+
return "❌ Please provide at least one search criteria (query, tags, title, or body)."
|
| 316 |
+
|
| 317 |
+
# Convert tags strings to lists
|
| 318 |
+
tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()] if tags else None
|
| 319 |
+
excluded_tags_list = [tag.strip() for tag in excluded_tags.split(",") if tag.strip()] if excluded_tags else None
|
| 320 |
+
|
| 321 |
+
# Limit the range
|
| 322 |
+
limit = max(1, min(limit, 20))
|
| 323 |
+
|
| 324 |
+
try:
|
| 325 |
+
# Get API client with user's key
|
| 326 |
+
api = get_api_client(api_key)
|
| 327 |
+
|
| 328 |
+
# Run the async function safely
|
| 329 |
+
try:
|
| 330 |
+
loop = asyncio.get_event_loop()
|
| 331 |
+
if loop.is_closed():
|
| 332 |
+
raise RuntimeError("Event loop is closed")
|
| 333 |
+
except RuntimeError:
|
| 334 |
+
loop = asyncio.new_event_loop()
|
| 335 |
+
asyncio.set_event_loop(loop)
|
| 336 |
+
|
| 337 |
+
results = loop.run_until_complete(
|
| 338 |
+
api.advanced_search(
|
| 339 |
+
query=query.strip() if query.strip() else None,
|
| 340 |
+
tags=tags_list,
|
| 341 |
+
excluded_tags=excluded_tags_list,
|
| 342 |
+
min_score=min_score if min_score > 0 else None,
|
| 343 |
+
title=title.strip() if title.strip() else None,
|
| 344 |
+
body=body.strip() if body.strip() else None,
|
| 345 |
+
answers=min_answers if min_answers > 0 else None,
|
| 346 |
+
has_accepted_answer=has_accepted_answer if has_accepted_answer else None,
|
| 347 |
+
views=min_views if min_views > 0 else None,
|
| 348 |
+
sort_by=sort_by,
|
| 349 |
+
limit=limit
|
| 350 |
+
)
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
if not results:
|
| 354 |
+
return "🔍 No results found with the specified criteria."
|
| 355 |
+
|
| 356 |
+
return format_response(results, response_format)
|
| 357 |
+
|
| 358 |
+
except Exception as e:
|
| 359 |
+
return f"❌ Error performing advanced search: {str(e)}"
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
# Helper functions for example buttons (not exposed as MCP tools)
|
| 363 |
+
def _set_django_example():
|
| 364 |
+
return ("Django pagination best practices", "python,django", 5, True, 5, "markdown")
|
| 365 |
+
|
| 366 |
+
def _set_async_example():
|
| 367 |
+
return ("Python asyncio concurrency patterns", "python,asyncio", 10, True, 5, "markdown")
|
| 368 |
+
|
| 369 |
+
def _set_react_example():
|
| 370 |
+
return ("React hooks useState useEffect", "javascript,reactjs", 15, True, 5, "markdown")
|
| 371 |
+
|
| 372 |
+
def _set_sql_example():
|
| 373 |
+
return ("SQL INNER JOIN vs LEFT JOIN performance", "sql,join", 20, True, 5, "markdown")
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
# Create the Gradio interface with multiple tabs
|
| 377 |
+
with gr.Blocks(
|
| 378 |
+
title="Stack Overflow MCP Server",
|
| 379 |
+
theme=gr.themes.Soft(),
|
| 380 |
+
css="""
|
| 381 |
+
.gradio-container {
|
| 382 |
+
max-width: 1200px !important;
|
| 383 |
+
}
|
| 384 |
+
.tab-nav button {
|
| 385 |
+
font-size: 16px !important;
|
| 386 |
+
}
|
| 387 |
+
"""
|
| 388 |
+
) as demo:
|
| 389 |
+
|
| 390 |
+
gr.Markdown("""
|
| 391 |
+
# 🔍 Stack Overflow MCP Server
|
| 392 |
+
|
| 393 |
+
**A powerful interface to search Stack Overflow and analyze programming errors**
|
| 394 |
+
|
| 395 |
+
This application serves as both a web interface and an MCP (Model Context Protocol) server,
|
| 396 |
+
allowing AI assistants like Claude to search Stack Overflow programmatically.
|
| 397 |
+
|
| 398 |
+
💡 **MCP Server URL**: Use this URL in your MCP client: `{SERVER_URL}/gradio_api/mcp/sse`
|
| 399 |
+
|
| 400 |
+
## 🚀 Quick Start Examples
|
| 401 |
+
|
| 402 |
+
Try these example searches to get started:
|
| 403 |
+
- **General Search**: "Django pagination best practices" with tags "python,django"
|
| 404 |
+
- **Error Search**: "TypeError: 'NoneType' object has no attribute" in Python
|
| 405 |
+
- **Question ID**: 11227809 (famous "Why is processing a sorted array faster?" question)
|
| 406 |
+
- **Stack Trace**: JavaScript TypeError examples
|
| 407 |
+
- **Advanced**: High-scored Python questions with accepted answers
|
| 408 |
+
""")
|
| 409 |
+
|
| 410 |
+
# Global API Key Input
|
| 411 |
+
with gr.Row():
|
| 412 |
+
with gr.Column(scale=3):
|
| 413 |
+
gr.Markdown("### 🔑 Stack Exchange API Key (Optional)")
|
| 414 |
+
gr.Markdown("""
|
| 415 |
+
**Why provide an API key?**
|
| 416 |
+
- Higher request quotas (10,000 vs 300 requests/day)
|
| 417 |
+
- Faster responses and better reliability
|
| 418 |
+
- API keys are **not secret** - safe to share publicly
|
| 419 |
+
|
| 420 |
+
**How to get one:**
|
| 421 |
+
1. Visit [Stack Apps OAuth Registration](https://stackapps.com/apps/oauth/register)
|
| 422 |
+
2. Fill in basic info (name: "Stack Overflow MCP", domain: "localhost")
|
| 423 |
+
3. Copy your API key from the results page
|
| 424 |
+
""")
|
| 425 |
+
|
| 426 |
+
with gr.Column(scale=2):
|
| 427 |
+
api_key_input = gr.Textbox(
|
| 428 |
+
label="Stack Exchange API Key",
|
| 429 |
+
placeholder="Enter your API key here (optional)",
|
| 430 |
+
value="",
|
| 431 |
+
type="password",
|
| 432 |
+
info="Optional: Provides higher quotas and better performance"
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
with gr.Tabs():
|
| 436 |
+
|
| 437 |
+
# Tab 1: General Search
|
| 438 |
+
with gr.Tab("🔍 General Search", id="search"):
|
| 439 |
+
gr.Markdown("### Search Stack Overflow by query and filters")
|
| 440 |
+
|
| 441 |
+
with gr.Row():
|
| 442 |
+
with gr.Column(scale=2):
|
| 443 |
+
query_input = gr.Textbox(
|
| 444 |
+
label="Search Query",
|
| 445 |
+
placeholder="e.g., 'Django pagination best practices'",
|
| 446 |
+
value="python list comprehension"
|
| 447 |
+
)
|
| 448 |
+
|
| 449 |
+
with gr.Column(scale=1):
|
| 450 |
+
tags_input = gr.Textbox(
|
| 451 |
+
label="Tags (comma-separated)",
|
| 452 |
+
placeholder="e.g., python,pandas",
|
| 453 |
+
value=""
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
with gr.Row():
|
| 457 |
+
min_score_input = gr.Slider(
|
| 458 |
+
label="Minimum Score",
|
| 459 |
+
minimum=0,
|
| 460 |
+
maximum=100,
|
| 461 |
+
value=0,
|
| 462 |
+
step=1
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
has_accepted_input = gr.Checkbox(
|
| 466 |
+
label="Must have accepted answer",
|
| 467 |
+
value=False
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
limit_input = gr.Slider(
|
| 471 |
+
label="Number of Results",
|
| 472 |
+
minimum=1,
|
| 473 |
+
maximum=20,
|
| 474 |
+
value=5,
|
| 475 |
+
step=1
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
format_input = gr.Dropdown(
|
| 479 |
+
label="Response Format",
|
| 480 |
+
choices=["markdown", "json"],
|
| 481 |
+
value="markdown"
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
search_btn = gr.Button("🔍 Search", variant="primary", size="lg")
|
| 485 |
+
|
| 486 |
+
# Example buttons
|
| 487 |
+
with gr.Row():
|
| 488 |
+
gr.Markdown("**Quick Examples:**")
|
| 489 |
+
with gr.Row():
|
| 490 |
+
example1_btn = gr.Button("Django Pagination", size="sm")
|
| 491 |
+
example2_btn = gr.Button("Python Async", size="sm")
|
| 492 |
+
example3_btn = gr.Button("React Hooks", size="sm")
|
| 493 |
+
example4_btn = gr.Button("SQL JOIN", size="sm")
|
| 494 |
+
|
| 495 |
+
# Example button click handlers
|
| 496 |
+
example1_btn.click(
|
| 497 |
+
lambda: ("Django pagination best practices", "python,django", 5, True, 5, "markdown"),
|
| 498 |
+
outputs=[query_input, tags_input, min_score_input, has_accepted_input, limit_input, format_input]
|
| 499 |
+
)
|
| 500 |
+
example2_btn.click(
|
| 501 |
+
lambda: ("Python asyncio concurrency patterns", "python,asyncio", 10, True, 5, "markdown"),
|
| 502 |
+
outputs=[query_input, tags_input, min_score_input, has_accepted_input, limit_input, format_input]
|
| 503 |
+
)
|
| 504 |
+
example3_btn.click(
|
| 505 |
+
lambda: ("React hooks useState useEffect", "javascript,reactjs", 15, True, 5, "markdown"),
|
| 506 |
+
outputs=[query_input, tags_input, min_score_input, has_accepted_input, limit_input, format_input]
|
| 507 |
+
)
|
| 508 |
+
example4_btn.click(
|
| 509 |
+
lambda: ("SQL INNER JOIN vs LEFT JOIN performance", "sql,join", 20, True, 5, "markdown"),
|
| 510 |
+
outputs=[query_input, tags_input, min_score_input, has_accepted_input, limit_input, format_input]
|
| 511 |
+
)
|
| 512 |
+
|
| 513 |
+
search_output = gr.Markdown(label="Search Results", height=400)
|
| 514 |
+
|
| 515 |
+
search_btn.click(
|
| 516 |
+
fn=search_by_query_sync,
|
| 517 |
+
inputs=[query_input, tags_input, min_score_input, has_accepted_input, limit_input, format_input, api_key_input],
|
| 518 |
+
outputs=search_output
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
# Tab 2: Error Search
|
| 522 |
+
with gr.Tab("🐛 Error Search", id="error"):
|
| 523 |
+
gr.Markdown("### Find solutions for specific error messages")
|
| 524 |
+
|
| 525 |
+
with gr.Row():
|
| 526 |
+
with gr.Column(scale=2):
|
| 527 |
+
error_input = gr.Textbox(
|
| 528 |
+
label="Error Message",
|
| 529 |
+
placeholder="e.g., 'TypeError: object of type 'NoneType' has no len()'",
|
| 530 |
+
value="TypeError: 'NoneType' object has no attribute"
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
with gr.Column(scale=1):
|
| 534 |
+
language_input = gr.Textbox(
|
| 535 |
+
label="Programming Language",
|
| 536 |
+
placeholder="e.g., python",
|
| 537 |
+
value="python"
|
| 538 |
+
)
|
| 539 |
+
|
| 540 |
+
tech_input = gr.Textbox(
|
| 541 |
+
label="Related Technologies (comma-separated)",
|
| 542 |
+
placeholder="e.g., django,flask",
|
| 543 |
+
value=""
|
| 544 |
+
)
|
| 545 |
+
|
| 546 |
+
with gr.Row():
|
| 547 |
+
error_min_score = gr.Slider(
|
| 548 |
+
label="Minimum Score",
|
| 549 |
+
minimum=0,
|
| 550 |
+
maximum=100,
|
| 551 |
+
value=0,
|
| 552 |
+
step=1
|
| 553 |
+
)
|
| 554 |
+
|
| 555 |
+
error_accepted = gr.Checkbox(
|
| 556 |
+
label="Must have accepted answer",
|
| 557 |
+
value=True
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
+
error_limit = gr.Slider(
|
| 561 |
+
label="Number of Results",
|
| 562 |
+
minimum=1,
|
| 563 |
+
maximum=20,
|
| 564 |
+
value=5,
|
| 565 |
+
step=1
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
error_format = gr.Dropdown(
|
| 569 |
+
label="Response Format",
|
| 570 |
+
choices=["markdown", "json"],
|
| 571 |
+
value="markdown"
|
| 572 |
+
)
|
| 573 |
+
|
| 574 |
+
error_search_btn = gr.Button("🐛 Search for Solutions", variant="primary", size="lg")
|
| 575 |
+
error_output = gr.Markdown(label="Error Solutions", height=400)
|
| 576 |
+
|
| 577 |
+
error_search_btn.click(
|
| 578 |
+
fn=search_by_error_sync,
|
| 579 |
+
inputs=[error_input, language_input, tech_input, error_min_score, error_accepted, error_limit, error_format, api_key_input],
|
| 580 |
+
outputs=error_output
|
| 581 |
+
)
|
| 582 |
+
|
| 583 |
+
# Tab 3: Get Specific Question
|
| 584 |
+
with gr.Tab("📄 Get Question", id="question"):
|
| 585 |
+
gr.Markdown("### Retrieve a specific Stack Overflow question by ID")
|
| 586 |
+
|
| 587 |
+
with gr.Row():
|
| 588 |
+
question_id_input = gr.Textbox(
|
| 589 |
+
label="Question ID",
|
| 590 |
+
placeholder="e.g., 11227809",
|
| 591 |
+
value="11227809"
|
| 592 |
+
)
|
| 593 |
+
|
| 594 |
+
question_comments = gr.Checkbox(
|
| 595 |
+
label="Include Comments",
|
| 596 |
+
value=True
|
| 597 |
+
)
|
| 598 |
+
|
| 599 |
+
question_format = gr.Dropdown(
|
| 600 |
+
label="Response Format",
|
| 601 |
+
choices=["markdown", "json"],
|
| 602 |
+
value="markdown"
|
| 603 |
+
)
|
| 604 |
+
|
| 605 |
+
question_btn = gr.Button("📄 Get Question", variant="primary", size="lg")
|
| 606 |
+
question_output = gr.Markdown(label="Question Details", height=400)
|
| 607 |
+
|
| 608 |
+
question_btn.click(
|
| 609 |
+
fn=get_question_sync,
|
| 610 |
+
inputs=[question_id_input, question_comments, question_format, api_key_input],
|
| 611 |
+
outputs=question_output
|
| 612 |
+
)
|
| 613 |
+
|
| 614 |
+
# Tab 4: Stack Trace Analysis
|
| 615 |
+
with gr.Tab("📊 Stack Trace Analysis", id="trace"):
|
| 616 |
+
gr.Markdown("### Analyze stack traces and find relevant solutions")
|
| 617 |
+
|
| 618 |
+
stack_trace_input = gr.Textbox(
|
| 619 |
+
label="Stack Trace",
|
| 620 |
+
placeholder="Paste your full stack trace here...",
|
| 621 |
+
lines=8,
|
| 622 |
+
value="TypeError: Cannot read property 'length' of undefined\n at Array.map (<anonymous>)\n at Component.render (app.js:42:18)"
|
| 623 |
+
)
|
| 624 |
+
|
| 625 |
+
with gr.Row():
|
| 626 |
+
trace_language = gr.Textbox(
|
| 627 |
+
label="Programming Language",
|
| 628 |
+
placeholder="e.g., javascript",
|
| 629 |
+
value="javascript"
|
| 630 |
+
)
|
| 631 |
+
|
| 632 |
+
trace_min_score = gr.Slider(
|
| 633 |
+
label="Minimum Score",
|
| 634 |
+
minimum=0,
|
| 635 |
+
maximum=100,
|
| 636 |
+
value=5,
|
| 637 |
+
step=1
|
| 638 |
+
)
|
| 639 |
+
|
| 640 |
+
trace_accepted = gr.Checkbox(
|
| 641 |
+
label="Must have accepted answer",
|
| 642 |
+
value=True
|
| 643 |
+
)
|
| 644 |
+
|
| 645 |
+
trace_limit = gr.Slider(
|
| 646 |
+
label="Number of Results",
|
| 647 |
+
minimum=1,
|
| 648 |
+
maximum=10,
|
| 649 |
+
value=3,
|
| 650 |
+
step=1
|
| 651 |
+
)
|
| 652 |
+
|
| 653 |
+
trace_format = gr.Dropdown(
|
| 654 |
+
label="Response Format",
|
| 655 |
+
choices=["markdown", "json"],
|
| 656 |
+
value="markdown"
|
| 657 |
+
)
|
| 658 |
+
|
| 659 |
+
trace_btn = gr.Button("📊 Analyze Stack Trace", variant="primary", size="lg")
|
| 660 |
+
trace_output = gr.Markdown(label="Stack Trace Analysis", height=400)
|
| 661 |
+
|
| 662 |
+
trace_btn.click(
|
| 663 |
+
fn=analyze_stack_trace_sync,
|
| 664 |
+
inputs=[stack_trace_input, trace_language, trace_min_score, trace_accepted, trace_limit, trace_format, api_key_input],
|
| 665 |
+
outputs=trace_output
|
| 666 |
+
)
|
| 667 |
+
|
| 668 |
+
# Tab 5: Advanced Search
|
| 669 |
+
with gr.Tab("⚙️ Advanced Search", id="advanced"):
|
| 670 |
+
gr.Markdown("### Advanced search with comprehensive filtering options")
|
| 671 |
+
|
| 672 |
+
with gr.Row():
|
| 673 |
+
with gr.Column():
|
| 674 |
+
adv_query_input = gr.Textbox(
|
| 675 |
+
label="Search Query (optional)",
|
| 676 |
+
placeholder="e.g., 'memory management'",
|
| 677 |
+
value=""
|
| 678 |
+
)
|
| 679 |
+
|
| 680 |
+
adv_title_input = gr.Textbox(
|
| 681 |
+
label="Title Contains (optional)",
|
| 682 |
+
placeholder="Text that must appear in the title",
|
| 683 |
+
value=""
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
adv_body_input = gr.Textbox(
|
| 687 |
+
label="Body Contains (optional)",
|
| 688 |
+
placeholder="Text that must appear in the body",
|
| 689 |
+
value=""
|
| 690 |
+
)
|
| 691 |
+
|
| 692 |
+
with gr.Column():
|
| 693 |
+
adv_tags_input = gr.Textbox(
|
| 694 |
+
label="Include Tags (comma-separated)",
|
| 695 |
+
placeholder="e.g., python,django,performance",
|
| 696 |
+
value=""
|
| 697 |
+
)
|
| 698 |
+
|
| 699 |
+
adv_excluded_tags_input = gr.Textbox(
|
| 700 |
+
label="Exclude Tags (comma-separated)",
|
| 701 |
+
placeholder="e.g., beginner,homework",
|
| 702 |
+
value=""
|
| 703 |
+
)
|
| 704 |
+
|
| 705 |
+
adv_sort_input = gr.Dropdown(
|
| 706 |
+
label="Sort By",
|
| 707 |
+
choices=["votes", "activity", "creation", "relevance"],
|
| 708 |
+
value="votes"
|
| 709 |
+
)
|
| 710 |
+
|
| 711 |
+
with gr.Row():
|
| 712 |
+
adv_min_score = gr.Slider(
|
| 713 |
+
label="Minimum Score",
|
| 714 |
+
minimum=0,
|
| 715 |
+
maximum=500,
|
| 716 |
+
value=10,
|
| 717 |
+
step=5
|
| 718 |
+
)
|
| 719 |
+
|
| 720 |
+
adv_min_answers = gr.Slider(
|
| 721 |
+
label="Minimum Answers",
|
| 722 |
+
minimum=0,
|
| 723 |
+
maximum=50,
|
| 724 |
+
value=1,
|
| 725 |
+
step=1
|
| 726 |
+
)
|
| 727 |
+
|
| 728 |
+
adv_min_views = gr.Slider(
|
| 729 |
+
label="Minimum Views",
|
| 730 |
+
minimum=0,
|
| 731 |
+
maximum=10000,
|
| 732 |
+
value=0,
|
| 733 |
+
step=100
|
| 734 |
+
)
|
| 735 |
+
|
| 736 |
+
with gr.Row():
|
| 737 |
+
adv_accepted = gr.Checkbox(
|
| 738 |
+
label="Must have accepted answer",
|
| 739 |
+
value=False
|
| 740 |
+
)
|
| 741 |
+
|
| 742 |
+
adv_limit = gr.Slider(
|
| 743 |
+
label="Number of Results",
|
| 744 |
+
minimum=1,
|
| 745 |
+
maximum=20,
|
| 746 |
+
value=5,
|
| 747 |
+
step=1
|
| 748 |
+
)
|
| 749 |
+
|
| 750 |
+
adv_format = gr.Dropdown(
|
| 751 |
+
label="Response Format",
|
| 752 |
+
choices=["markdown", "json"],
|
| 753 |
+
value="markdown"
|
| 754 |
+
)
|
| 755 |
+
|
| 756 |
+
adv_search_btn = gr.Button("⚙️ Advanced Search", variant="primary", size="lg")
|
| 757 |
+
adv_output = gr.Markdown(label="Advanced Search Results", height=400)
|
| 758 |
+
|
| 759 |
+
adv_search_btn.click(
|
| 760 |
+
fn=advanced_search_sync,
|
| 761 |
+
inputs=[
|
| 762 |
+
adv_query_input, adv_tags_input, adv_excluded_tags_input,
|
| 763 |
+
adv_min_score, adv_title_input, adv_body_input, adv_min_answers,
|
| 764 |
+
adv_accepted, adv_min_views, adv_sort_input, adv_limit, adv_format, api_key_input
|
| 765 |
+
],
|
| 766 |
+
outputs=adv_output
|
| 767 |
+
)
|
| 768 |
+
|
| 769 |
+
# Footer with MCP information
|
| 770 |
+
gr.Markdown("""
|
| 771 |
+
---
|
| 772 |
+
|
| 773 |
+
## 🤖 MCP Integration
|
| 774 |
+
|
| 775 |
+
This app also functions as an **MCP (Model Context Protocol) Server**!
|
| 776 |
+
|
| 777 |
+
To use with AI assistants like Claude Desktop, add this configuration:
|
| 778 |
+
|
| 779 |
+
```json
|
| 780 |
+
{
|
| 781 |
+
"mcpServers": {
|
| 782 |
+
"stackoverflow": {
|
| 783 |
+
"url": "YOUR_DEPLOYED_URL/gradio_api/mcp/sse"
|
| 784 |
+
}
|
| 785 |
+
}
|
| 786 |
+
}
|
| 787 |
+
```
|
| 788 |
+
|
| 789 |
+
**Available MCP Tools:**
|
| 790 |
+
- `search_by_query_sync` - General Stack Overflow search
|
| 791 |
+
- `search_by_error_sync` - Error-specific search
|
| 792 |
+
- `get_question_sync` - Get specific question by ID
|
| 793 |
+
- `analyze_stack_trace_sync` - Analyze stack traces
|
| 794 |
+
- `advanced_search_sync` - Advanced search with comprehensive filters
|
| 795 |
+
|
| 796 |
+
**💡 Pro Tip:** Add your Stack Exchange API key above for higher quotas (10,000 vs 300 requests/day)!
|
| 797 |
+
|
| 798 |
+
Built with ❤️ for the MCP Hackathon
|
| 799 |
+
""")
|
| 800 |
+
|
| 801 |
+
|
| 802 |
+
if __name__ == "__main__":
|
| 803 |
+
# Launch with MCP server enabled
|
| 804 |
+
demo.launch(
|
| 805 |
+
mcp_server=True,
|
| 806 |
+
share=True, # Create a public link for testing
|
| 807 |
+
server_name="0.0.0.0", # Allow external connections
|
| 808 |
+
server_port=7860, # Standard Gradio port
|
| 809 |
+
show_error=True
|
| 810 |
+
)
|
pyproject.toml
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["hatchling>=1.0.0"]
|
| 3 |
+
build-backend = "hatchling.build"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "stackoverflow-mcp"
|
| 7 |
+
version = "0.1.3"
|
| 8 |
+
description = "Stack Overflow MCP server for LLM applications"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.10"
|
| 11 |
+
license = "MIT"
|
| 12 |
+
license-files = ["LICEN[CS]E*"]
|
| 13 |
+
authors = [
|
| 14 |
+
{name = "Mark Nawar", email = "[email protected]"},
|
| 15 |
+
]
|
| 16 |
+
classifiers = [
|
| 17 |
+
"Programming Language :: Python :: 3.10",
|
| 18 |
+
"Programming Language :: Python :: 3.11",
|
| 19 |
+
"Programming Language :: Python :: 3.12",
|
| 20 |
+
"License :: OSI Approved :: MIT License",
|
| 21 |
+
"Operating System :: OS Independent",
|
| 22 |
+
]
|
| 23 |
+
dependencies = [
|
| 24 |
+
"httpx>=0.24.0",
|
| 25 |
+
"python-dotenv>=1.0.0",
|
| 26 |
+
"mcp>=0.7.0",
|
| 27 |
+
"gradio[mcp]>=5.33.1",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
[project.optional-dependencies]
|
| 31 |
+
dev = [
|
| 32 |
+
"pytest>=7.0.0",
|
| 33 |
+
"pytest-asyncio>=0.21.0",
|
| 34 |
+
"pytest-cov>=4.0.0",
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
[project.urls]
|
| 38 |
+
Homepage = "https://github.com/CodexVeritax/stackoverflow-mcp-server"
|
| 39 |
+
Issues = "https://github.com/CodexVeritax/stackoverflow-mcp-server/issues"
|
| 40 |
+
|
| 41 |
+
[tool.hatch.build.targets.wheel]
|
| 42 |
+
packages = ["stackoverflow_mcp"]
|
| 43 |
+
|
| 44 |
+
[tool.hatch.build.targets.sdist]
|
| 45 |
+
include = [
|
| 46 |
+
"stackoverflow_mcp",
|
| 47 |
+
"LICENSE",
|
| 48 |
+
"README.md",
|
| 49 |
+
"pyproject.toml",
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
[project.scripts]
|
| 53 |
+
stackoverflow-mcp = "stackoverflow_mcp.__main__:main"
|
| 54 |
+
|
| 55 |
+
[tool.mypy]
|
| 56 |
+
python_version = "3.8"
|
| 57 |
+
warn_return_any = true
|
| 58 |
+
warn_unused_configs = true
|
| 59 |
+
disallow_untyped_defs = true
|
| 60 |
+
disallow_incomplete_defs = true
|
| 61 |
+
|
| 62 |
+
[tool.pytest.ini_options]
|
| 63 |
+
testpaths = ["tests"]
|
| 64 |
+
python_files = "test_*.py"
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
httpx>=0.24.0
|
| 2 |
+
python-dotenv>=1.0.0
|
| 3 |
+
mcp>=0.7.0
|
| 4 |
+
gradio[mcp]>=5.33.1
|
stackoverflow_mcp/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Veritax Stackoverflow MCP Server.
|
| 2 |
+
|
| 3 |
+
A Model Context Protocol (MCP) server for accessing Stackoverflow question.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
__version__ = "0.1.3"
|
stackoverflow_mcp/__main__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
from stackoverflow_mcp.server import mcp
|
| 3 |
+
|
| 4 |
+
def main():
|
| 5 |
+
"""Entry point for the Stack Overflow MCP server."""
|
| 6 |
+
print("Starting Stack Overflow MCP Server...")
|
| 7 |
+
mcp.run()
|
| 8 |
+
|
| 9 |
+
if __name__ == "__main__":
|
| 10 |
+
main()
|
stackoverflow_mcp/api.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import time
|
| 3 |
+
from typing import Dict, List, Optional, Any, Union
|
| 4 |
+
import json
|
| 5 |
+
from dataclasses import asdict
|
| 6 |
+
import asyncio
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from itertools import islice
|
| 9 |
+
|
| 10 |
+
from .types import (
|
| 11 |
+
StackOverflowQuestion,
|
| 12 |
+
StackOverflowAnswer,
|
| 13 |
+
StackOverflowComment,
|
| 14 |
+
SearchResult,
|
| 15 |
+
SearchResultComments
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
from .env import (
|
| 19 |
+
MAX_REQUEST_PER_WINDOW,
|
| 20 |
+
RATE_LIMIT_WINDOW_MS,
|
| 21 |
+
RETRY_AFTER_MS
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
STACKOVERFLOW_API = "https://api.stackexchange.com/2.3"
|
| 25 |
+
BATCH_SIZE = 100 # API limit for batch requests
|
| 26 |
+
|
| 27 |
+
class StackExchangeAPI:
|
| 28 |
+
def __init__(self, api_key: Optional[str] = None):
|
| 29 |
+
self.api_key = api_key
|
| 30 |
+
self.request_timestamps = []
|
| 31 |
+
self.client = httpx.AsyncClient(timeout=30.0)
|
| 32 |
+
|
| 33 |
+
async def close(self):
|
| 34 |
+
await self.client.aclose()
|
| 35 |
+
|
| 36 |
+
def _check_rate_limit(self) -> bool:
|
| 37 |
+
now = time.time() * 1000
|
| 38 |
+
|
| 39 |
+
self.request_timestamps = [
|
| 40 |
+
ts for ts in self.request_timestamps
|
| 41 |
+
if now - ts < RATE_LIMIT_WINDOW_MS
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
if len(self.request_timestamps) >= MAX_REQUEST_PER_WINDOW:
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
self.request_timestamps.append(now)
|
| 48 |
+
return True
|
| 49 |
+
|
| 50 |
+
async def _with_rate_limit(self, func, *args, retries=3, attempts=10, **kwargs):
|
| 51 |
+
"""Execute a function with rate limiting.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
func (_type_): Function to execute with rate limiting
|
| 55 |
+
retries (int, optional): Number of retries after API rate limit error. Defaults to 3.
|
| 56 |
+
attempts (int, optional): Number of times to retry after hitting local rate limit. Defaults to 10.
|
| 57 |
+
|
| 58 |
+
Raises:
|
| 59 |
+
Exception: When maximum rate limiting attempts are exceeded
|
| 60 |
+
e: Original error if retries are exhausted
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
Any: Result from the function
|
| 64 |
+
"""
|
| 65 |
+
if retries is None:
|
| 66 |
+
retries = 3
|
| 67 |
+
|
| 68 |
+
if attempts <= 0:
|
| 69 |
+
raise Exception("Maximum rate limiting attempts exceeded")
|
| 70 |
+
|
| 71 |
+
if not self._check_rate_limit():
|
| 72 |
+
print("Rate limit exceeded, waiting before retry")
|
| 73 |
+
await asyncio.sleep(RETRY_AFTER_MS / 1000)
|
| 74 |
+
return await self._with_rate_limit(func, *args, retries=retries, attempts=attempts-1, **kwargs)
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
return await func(*args, **kwargs)
|
| 78 |
+
except httpx.HTTPStatusError as e:
|
| 79 |
+
if retries > 0 and e.response.status_code == 429:
|
| 80 |
+
print("Rate limit hit (429), retrying after delay...")
|
| 81 |
+
await asyncio.sleep(RETRY_AFTER_MS/1000)
|
| 82 |
+
return await self._with_rate_limit(func, *args, retries=retries-1, attempts=attempts, **kwargs)
|
| 83 |
+
raise e
|
| 84 |
+
|
| 85 |
+
async def fetch_batch_answers(self, question_ids: List[int]) -> Dict[int, List[StackOverflowAnswer]]:
|
| 86 |
+
"""Fetch answers for multiple questions in a single API call.
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
question_ids (List[int]): List of Stack Overflow question IDs
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
Dict[int, List[StackOverflowAnswer]]: Dictionary mapping question IDs to their answers
|
| 93 |
+
"""
|
| 94 |
+
if not question_ids:
|
| 95 |
+
return {}
|
| 96 |
+
|
| 97 |
+
result = {}
|
| 98 |
+
|
| 99 |
+
# Process in batches of BATCH_SIZE (API limit)
|
| 100 |
+
for i in range(0, len(question_ids), BATCH_SIZE):
|
| 101 |
+
batch = question_ids[i:i+BATCH_SIZE]
|
| 102 |
+
ids_string = ";".join(str(qid) for qid in batch)
|
| 103 |
+
|
| 104 |
+
params = {
|
| 105 |
+
"site": "stackoverflow",
|
| 106 |
+
"sort": "votes",
|
| 107 |
+
"order": "desc",
|
| 108 |
+
"filter": "withbody",
|
| 109 |
+
"pagesize": "100"
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
if self.api_key:
|
| 113 |
+
params["key"] = self.api_key
|
| 114 |
+
|
| 115 |
+
async def _do_fetch():
|
| 116 |
+
response = await self.client.get(
|
| 117 |
+
f"{STACKOVERFLOW_API}/questions/{ids_string}/answers",
|
| 118 |
+
params=params
|
| 119 |
+
)
|
| 120 |
+
response.raise_for_status()
|
| 121 |
+
return response.json()
|
| 122 |
+
|
| 123 |
+
data = await self._with_rate_limit(_do_fetch)
|
| 124 |
+
|
| 125 |
+
for answer_data in data.get("items", []):
|
| 126 |
+
question_id = answer_data.get("question_id")
|
| 127 |
+
if question_id not in result:
|
| 128 |
+
result[question_id] = []
|
| 129 |
+
|
| 130 |
+
answer = StackOverflowAnswer(
|
| 131 |
+
answer_id=answer_data.get("answer_id"),
|
| 132 |
+
question_id=question_id,
|
| 133 |
+
score=answer_data.get("score", 0),
|
| 134 |
+
is_accepted=answer_data.get("is_accepted", False),
|
| 135 |
+
body=answer_data.get("body", ""),
|
| 136 |
+
creation_date=answer_data.get("creation_date", 0),
|
| 137 |
+
last_activity_date=answer_data.get("last_activity_date", 0),
|
| 138 |
+
link=answer_data.get("link", ""),
|
| 139 |
+
owner=answer_data.get("owner")
|
| 140 |
+
)
|
| 141 |
+
result[question_id].append(answer)
|
| 142 |
+
|
| 143 |
+
return result
|
| 144 |
+
|
| 145 |
+
async def fetch_batch_comments(self, post_ids: List[int]) -> Dict[int, List[StackOverflowComment]]:
|
| 146 |
+
"""Fetch comments for multiple posts in a single API call.
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
post_ids (List[int]): List of Stack Overflow post IDs (questions or answers)
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
Dict[int, List[StackOverflowComment]]: Dictionary mapping post IDs to their comments
|
| 153 |
+
"""
|
| 154 |
+
if not post_ids:
|
| 155 |
+
return {}
|
| 156 |
+
|
| 157 |
+
result = {}
|
| 158 |
+
|
| 159 |
+
# Process in batches of BATCH_SIZE (API limit)
|
| 160 |
+
for i in range(0, len(post_ids), BATCH_SIZE):
|
| 161 |
+
batch = post_ids[i:i+BATCH_SIZE]
|
| 162 |
+
ids_string = ";".join(str(pid) for pid in batch)
|
| 163 |
+
|
| 164 |
+
params = {
|
| 165 |
+
"site": "stackoverflow",
|
| 166 |
+
"sort": "votes",
|
| 167 |
+
"order": "desc",
|
| 168 |
+
"filter": "withbody",
|
| 169 |
+
"pagesize": "100"
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
if self.api_key:
|
| 173 |
+
params["key"] = self.api_key
|
| 174 |
+
|
| 175 |
+
async def _do_fetch():
|
| 176 |
+
response = await self.client.get(
|
| 177 |
+
f"{STACKOVERFLOW_API}/posts/{ids_string}/comments",
|
| 178 |
+
params=params
|
| 179 |
+
)
|
| 180 |
+
response.raise_for_status()
|
| 181 |
+
return response.json()
|
| 182 |
+
|
| 183 |
+
data = await self._with_rate_limit(_do_fetch)
|
| 184 |
+
|
| 185 |
+
for comment_data in data.get("items", []):
|
| 186 |
+
post_id = comment_data.get("post_id")
|
| 187 |
+
if post_id not in result:
|
| 188 |
+
result[post_id] = []
|
| 189 |
+
|
| 190 |
+
comment = StackOverflowComment(
|
| 191 |
+
comment_id=comment_data.get("comment_id"),
|
| 192 |
+
post_id=post_id,
|
| 193 |
+
score=comment_data.get("score", 0),
|
| 194 |
+
body=comment_data.get("body", ""),
|
| 195 |
+
creation_date=comment_data.get("creation_date", 0),
|
| 196 |
+
owner=comment_data.get("owner")
|
| 197 |
+
)
|
| 198 |
+
result[post_id].append(comment)
|
| 199 |
+
|
| 200 |
+
return result
|
| 201 |
+
|
| 202 |
+
async def advanced_search(
|
| 203 |
+
self,
|
| 204 |
+
query: Optional[str] = None,
|
| 205 |
+
tags: Optional[List[str]] = None,
|
| 206 |
+
excluded_tags: Optional[List[str]] = None,
|
| 207 |
+
min_score: Optional[int] = None,
|
| 208 |
+
title: Optional[str] = None,
|
| 209 |
+
body: Optional[str] = None,
|
| 210 |
+
answers: Optional[int] = None,
|
| 211 |
+
has_accepted_answer: Optional[bool] = None,
|
| 212 |
+
views: Optional[int] = None,
|
| 213 |
+
url: Optional[str] = None,
|
| 214 |
+
user_id: Optional[int] = None,
|
| 215 |
+
is_closed: Optional[bool] = None,
|
| 216 |
+
is_wiki: Optional[bool] = None,
|
| 217 |
+
is_migrated: Optional[bool] = None,
|
| 218 |
+
has_notice: Optional[bool] = None,
|
| 219 |
+
from_date: Optional[datetime] = None,
|
| 220 |
+
to_date: Optional[datetime] = None,
|
| 221 |
+
sort_by: Optional[str] = "votes",
|
| 222 |
+
limit: Optional[int] = 5,
|
| 223 |
+
include_comments: bool = False,
|
| 224 |
+
retries: Optional[int] = 3
|
| 225 |
+
) -> List[SearchResult]:
|
| 226 |
+
"""Advanced search for Stack Overflow questions with many filter options."""
|
| 227 |
+
params = {
|
| 228 |
+
"site": "stackoverflow",
|
| 229 |
+
"sort": sort_by,
|
| 230 |
+
"order": "desc",
|
| 231 |
+
"filter": "withbody"
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
if query:
|
| 235 |
+
params["q"] = query
|
| 236 |
+
|
| 237 |
+
if tags:
|
| 238 |
+
params["tagged"] = ";".join(tags)
|
| 239 |
+
|
| 240 |
+
if excluded_tags:
|
| 241 |
+
params["nottagged"] = ";".join(excluded_tags)
|
| 242 |
+
|
| 243 |
+
if title:
|
| 244 |
+
params["title"] = title
|
| 245 |
+
|
| 246 |
+
if body:
|
| 247 |
+
params["body"] = body
|
| 248 |
+
|
| 249 |
+
if answers is not None:
|
| 250 |
+
params["answers"] = str(answers)
|
| 251 |
+
|
| 252 |
+
if has_accepted_answer is not None:
|
| 253 |
+
params["accepted"] = "true" if has_accepted_answer else "false"
|
| 254 |
+
|
| 255 |
+
if views is not None:
|
| 256 |
+
params["views"] = str(views)
|
| 257 |
+
|
| 258 |
+
if url:
|
| 259 |
+
params["url"] = url
|
| 260 |
+
|
| 261 |
+
if user_id is not None:
|
| 262 |
+
params["user"] = str(user_id)
|
| 263 |
+
|
| 264 |
+
if is_closed is not None:
|
| 265 |
+
params["closed"] = "true" if is_closed else "false"
|
| 266 |
+
|
| 267 |
+
if is_wiki is not None:
|
| 268 |
+
params["wiki"] = "true" if is_wiki else "false"
|
| 269 |
+
|
| 270 |
+
if is_migrated is not None:
|
| 271 |
+
params["migrated"] = "true" if is_migrated else "false"
|
| 272 |
+
|
| 273 |
+
if has_notice is not None:
|
| 274 |
+
params["notice"] = "true" if has_notice else "false"
|
| 275 |
+
|
| 276 |
+
if from_date:
|
| 277 |
+
params["fromdate"] = str(int(from_date.timestamp()))
|
| 278 |
+
|
| 279 |
+
if to_date:
|
| 280 |
+
params["todate"] = str(int(to_date.timestamp()))
|
| 281 |
+
|
| 282 |
+
if limit:
|
| 283 |
+
params["pagesize"] = str(limit)
|
| 284 |
+
|
| 285 |
+
if self.api_key:
|
| 286 |
+
params["key"] = self.api_key
|
| 287 |
+
|
| 288 |
+
async def _do_search():
|
| 289 |
+
response = await self.client.get(f"{STACKOVERFLOW_API}/search/advanced", params=params)
|
| 290 |
+
response.raise_for_status()
|
| 291 |
+
return response.json()
|
| 292 |
+
|
| 293 |
+
data = await self._with_rate_limit(_do_search, retries=retries)
|
| 294 |
+
|
| 295 |
+
questions = []
|
| 296 |
+
question_ids = []
|
| 297 |
+
|
| 298 |
+
for question_data in data.get("items", []):
|
| 299 |
+
if min_score is not None and question_data.get("score", 0) < min_score:
|
| 300 |
+
continue
|
| 301 |
+
|
| 302 |
+
question = StackOverflowQuestion(
|
| 303 |
+
question_id=question_data.get("question_id"),
|
| 304 |
+
title=question_data.get("title", ""),
|
| 305 |
+
body=question_data.get("body", ""),
|
| 306 |
+
score=question_data.get("score", 0),
|
| 307 |
+
answer_count=question_data.get("answer_count", 0),
|
| 308 |
+
is_answered=question_data.get("is_answered", False),
|
| 309 |
+
accepted_answer_id=question_data.get("accepted_answer_id"),
|
| 310 |
+
creation_date=question_data.get("creation_date", 0),
|
| 311 |
+
last_activity_date=question_data.get("last_activity_date", 0),
|
| 312 |
+
view_count=question_data.get("view_count", 0),
|
| 313 |
+
tags=question_data.get("tags", []),
|
| 314 |
+
link=question_data.get("link", ""),
|
| 315 |
+
is_closed=question_data.get("closed_date") is not None,
|
| 316 |
+
owner=question_data.get("owner")
|
| 317 |
+
)
|
| 318 |
+
questions.append(question)
|
| 319 |
+
question_ids.append(question.question_id)
|
| 320 |
+
|
| 321 |
+
answers_by_question = await self.fetch_batch_answers(question_ids)
|
| 322 |
+
|
| 323 |
+
results = []
|
| 324 |
+
|
| 325 |
+
if include_comments:
|
| 326 |
+
all_post_ids = question_ids.copy()
|
| 327 |
+
for qid, answers in answers_by_question.items():
|
| 328 |
+
all_post_ids.extend([a.answer_id for a in answers])
|
| 329 |
+
|
| 330 |
+
# Batch fetch all comments
|
| 331 |
+
all_comments = await self.fetch_batch_comments(all_post_ids)
|
| 332 |
+
|
| 333 |
+
# Construct results with comments
|
| 334 |
+
for question in questions:
|
| 335 |
+
question_answers = answers_by_question.get(question.question_id, [])
|
| 336 |
+
|
| 337 |
+
# Create comment structure
|
| 338 |
+
question_comments = all_comments.get(question.question_id, [])
|
| 339 |
+
answer_comments = {}
|
| 340 |
+
|
| 341 |
+
for answer in question_answers:
|
| 342 |
+
answer_comments[answer.answer_id] = all_comments.get(answer.answer_id, [])
|
| 343 |
+
|
| 344 |
+
comments = SearchResultComments(
|
| 345 |
+
question=question_comments,
|
| 346 |
+
answers=answer_comments
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
results.append(SearchResult(
|
| 350 |
+
question=question,
|
| 351 |
+
answers=question_answers,
|
| 352 |
+
comments=comments
|
| 353 |
+
))
|
| 354 |
+
else:
|
| 355 |
+
for question in questions:
|
| 356 |
+
question_answers = answers_by_question.get(question.question_id, [])
|
| 357 |
+
results.append(SearchResult(
|
| 358 |
+
question=question,
|
| 359 |
+
answers=question_answers,
|
| 360 |
+
comments=None
|
| 361 |
+
))
|
| 362 |
+
|
| 363 |
+
return results
|
| 364 |
+
|
| 365 |
+
async def search_by_query(
|
| 366 |
+
self,
|
| 367 |
+
query: str,
|
| 368 |
+
tags: Optional[List[str]] = None,
|
| 369 |
+
excluded_tags: Optional[List[str]] = None,
|
| 370 |
+
min_score: Optional[int] = None,
|
| 371 |
+
title: Optional[str] = None,
|
| 372 |
+
body: Optional[str] = None,
|
| 373 |
+
has_accepted_answer: Optional[bool] = None,
|
| 374 |
+
answers: Optional[int] = None,
|
| 375 |
+
sort_by: Optional[str] = "votes",
|
| 376 |
+
limit: Optional[int] = 5,
|
| 377 |
+
include_comments: bool = False,
|
| 378 |
+
retries: Optional[int] = 3
|
| 379 |
+
) -> List[SearchResult]:
|
| 380 |
+
"""Search Stack Overflow for questions matching a query with additional filters."""
|
| 381 |
+
return await self.advanced_search(
|
| 382 |
+
query=query,
|
| 383 |
+
tags=tags,
|
| 384 |
+
excluded_tags=excluded_tags,
|
| 385 |
+
min_score=min_score,
|
| 386 |
+
title=title,
|
| 387 |
+
body=body,
|
| 388 |
+
has_accepted_answer=has_accepted_answer,
|
| 389 |
+
answers=answers,
|
| 390 |
+
sort_by=sort_by,
|
| 391 |
+
limit=limit,
|
| 392 |
+
include_comments=include_comments,
|
| 393 |
+
retries=retries
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
async def fetch_answers(self, question_id: int) -> List[StackOverflowAnswer]:
|
| 397 |
+
"""Fetch answers for a specific question.
|
| 398 |
+
|
| 399 |
+
Note: This is kept for backward compatibility, but new code should
|
| 400 |
+
use fetch_batch_answers for better performance.
|
| 401 |
+
"""
|
| 402 |
+
answers_dict = await self.fetch_batch_answers([question_id])
|
| 403 |
+
return answers_dict.get(question_id, [])
|
| 404 |
+
|
| 405 |
+
async def fetch_comments(self, post_id: int) -> List[StackOverflowComment]:
|
| 406 |
+
"""Fetch comments for a specific post.
|
| 407 |
+
|
| 408 |
+
Note: This is kept for backward compatibility, but new code should
|
| 409 |
+
use fetch_batch_comments for better performance.
|
| 410 |
+
"""
|
| 411 |
+
comments_dict = await self.fetch_batch_comments([post_id])
|
| 412 |
+
return comments_dict.get(post_id, [])
|
| 413 |
+
|
| 414 |
+
async def get_question(self, question_id: int, include_comments: bool = True) -> SearchResult:
|
| 415 |
+
"""Get a specific question by ID."""
|
| 416 |
+
params = {
|
| 417 |
+
"site": "stackoverflow",
|
| 418 |
+
"filter": "withbody"
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
if self.api_key:
|
| 422 |
+
params["key"] = self.api_key
|
| 423 |
+
|
| 424 |
+
async def _do_fetch():
|
| 425 |
+
response = await self.client.get(
|
| 426 |
+
f"{STACKOVERFLOW_API}/questions/{question_id}",
|
| 427 |
+
params=params
|
| 428 |
+
)
|
| 429 |
+
response.raise_for_status()
|
| 430 |
+
return response.json()
|
| 431 |
+
|
| 432 |
+
data = await self._with_rate_limit(_do_fetch)
|
| 433 |
+
|
| 434 |
+
if not data.get("items"):
|
| 435 |
+
raise ValueError(f"Question with ID {question_id} not found")
|
| 436 |
+
|
| 437 |
+
question_data = data["items"][0]
|
| 438 |
+
question = StackOverflowQuestion(
|
| 439 |
+
question_id=question_data.get("question_id"),
|
| 440 |
+
title=question_data.get("title", ""),
|
| 441 |
+
body=question_data.get("body", ""),
|
| 442 |
+
score=question_data.get("score", 0),
|
| 443 |
+
answer_count=question_data.get("answer_count", 0),
|
| 444 |
+
is_answered=question_data.get("is_answered", False),
|
| 445 |
+
accepted_answer_id=question_data.get("accepted_answer_id"),
|
| 446 |
+
creation_date=question_data.get("creation_date", 0),
|
| 447 |
+
last_activity_date=question_data.get("last_activity_date", 0),
|
| 448 |
+
view_count=question_data.get("view_count", 0),
|
| 449 |
+
tags=question_data.get("tags", []),
|
| 450 |
+
link=question_data.get("link", ""),
|
| 451 |
+
is_closed=question_data.get("closed_date") is not None,
|
| 452 |
+
owner=question_data.get("owner")
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
answers = await self.fetch_answers(question.question_id)
|
| 456 |
+
|
| 457 |
+
comments = None
|
| 458 |
+
if include_comments:
|
| 459 |
+
post_ids = [question.question_id] + [answer.answer_id for answer in answers]
|
| 460 |
+
all_comments = await self.fetch_batch_comments(post_ids)
|
| 461 |
+
|
| 462 |
+
question_comments = all_comments.get(question.question_id, [])
|
| 463 |
+
answer_comments = {}
|
| 464 |
+
|
| 465 |
+
for answer in answers:
|
| 466 |
+
answer_comments[answer.answer_id] = all_comments.get(answer.answer_id, [])
|
| 467 |
+
|
| 468 |
+
comments = SearchResultComments(
|
| 469 |
+
question=question_comments,
|
| 470 |
+
answers=answer_comments
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
return SearchResult(
|
| 474 |
+
question=question,
|
| 475 |
+
answers=answers,
|
| 476 |
+
comments=comments
|
| 477 |
+
)
|
stackoverflow_mcp/env.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
load_dotenv()
|
| 5 |
+
|
| 6 |
+
STACK_EXCHANGE_API_KEY = os.getenv("STACK_EXCHANGE_API_KEY")
|
| 7 |
+
|
| 8 |
+
MAX_REQUEST_PER_WINDOW = int(os.getenv("MAX_REQUEST_PER_WINDOW" , "30"))
|
| 9 |
+
RATE_LIMIT_WINDOW_MS = int(os.getenv("RATE_LIMIT_WINDOW_MS" , "60000"))
|
| 10 |
+
RETRY_AFTER_MS = int(os.getenv("RETRY_AFTER_MS" , "2000"))
|
stackoverflow_mcp/formatter.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import List
|
| 3 |
+
from dataclasses import asdict
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
from .types import SearchResult, StackOverflowAnswer, StackOverflowComment
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def format_response(results: List[SearchResult], format_type: str = "markdown") -> str:
|
| 10 |
+
"""Format search results as either JSON or Markdown.
|
| 11 |
+
|
| 12 |
+
Args:
|
| 13 |
+
results (List[SearchResult]): List of search results to format
|
| 14 |
+
format_type (str, optional): Output format type - either "json" or "markdown". Defaults to "markdown".
|
| 15 |
+
|
| 16 |
+
Returns:
|
| 17 |
+
str: Formatted string representation of the search results
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
if format_type == "json":
|
| 21 |
+
def _convert_to_dict(obj):
|
| 22 |
+
if hasattr(obj, "__dataclass_fields__"):
|
| 23 |
+
return asdict(obj)
|
| 24 |
+
return obj
|
| 25 |
+
|
| 26 |
+
class DataClassJSONEncoder(json.JSONEncoder):
|
| 27 |
+
def default(self, obj):
|
| 28 |
+
if hasattr(obj, "__dataclass_fields__"):
|
| 29 |
+
return asdict(obj)
|
| 30 |
+
return super().default(obj)
|
| 31 |
+
|
| 32 |
+
return json.dumps(results, cls=DataClassJSONEncoder, indent=2)
|
| 33 |
+
|
| 34 |
+
if not results:
|
| 35 |
+
return "No results found."
|
| 36 |
+
|
| 37 |
+
markdown = ""
|
| 38 |
+
|
| 39 |
+
for result in results:
|
| 40 |
+
markdown += f"# {result.question.title}\n\n"
|
| 41 |
+
markdown += f"**Score:** {result.question.score} | **Answers:** {result.question.answer_count}\n\n"
|
| 42 |
+
|
| 43 |
+
question_body = clean_html(result.question.body)
|
| 44 |
+
markdown += f"## Question\n\n{question_body}\n\n"
|
| 45 |
+
|
| 46 |
+
if result.comments and result.comments.question:
|
| 47 |
+
markdown += "### Question Comments\n\n"
|
| 48 |
+
for comment in result.comments.question:
|
| 49 |
+
markdown += f"- {clean_html(comment.body)} *(Score: {comment.score})*\n"
|
| 50 |
+
markdown += "\n"
|
| 51 |
+
|
| 52 |
+
markdown += "## Answers\n\n"
|
| 53 |
+
for answer in result.answers:
|
| 54 |
+
markdown += f"### {'✓ ' if answer.is_accepted else ''}Answer (Score: {answer.score})\n\n"
|
| 55 |
+
answer_body = clean_html(answer.body)
|
| 56 |
+
markdown += f"{answer_body}\n\n"
|
| 57 |
+
|
| 58 |
+
if (result.comments and
|
| 59 |
+
result.comments.answers and
|
| 60 |
+
answer.answer_id in result.comments.answers and
|
| 61 |
+
result.comments.answers[answer.answer_id]
|
| 62 |
+
):
|
| 63 |
+
markdown += "#### Answer Comments\n\n"
|
| 64 |
+
for comment in result.comments.answers[answer.answer_id]:
|
| 65 |
+
markdown += f"- {clean_html(comment.body)} *(Score: {comment.score})*\n"
|
| 66 |
+
|
| 67 |
+
markdown += "/n"
|
| 68 |
+
|
| 69 |
+
markdown += f"---\n\n[View on Stack Overflow]({result.question.link})\n\n"
|
| 70 |
+
|
| 71 |
+
return markdown
|
| 72 |
+
|
| 73 |
+
def clean_html(html_text: str) -> str:
|
| 74 |
+
"""Clean HTML tags from text while preserving code blocks.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
html_text (str): HTML text to be cleaned
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
str: Cleaned text with HTML tags removed and code blocks preserved
|
| 81 |
+
"""
|
| 82 |
+
|
| 83 |
+
code_blocks = []
|
| 84 |
+
|
| 85 |
+
def replace_code_block(match):
|
| 86 |
+
code = match.group(1) or match.group(2)
|
| 87 |
+
code_blocks.append(code)
|
| 88 |
+
return f"CODE_BLOCK_{len(code_blocks)-1}"
|
| 89 |
+
|
| 90 |
+
html_without_code = re.sub(r'<pre><code>(.*?)</code></pre>|<code>(.*?)</code>', replace_code_block, html_text, flags=re.DOTALL)
|
| 91 |
+
|
| 92 |
+
text_without_html = re.sub(r'<[^>]+>', '', html_without_code)
|
| 93 |
+
|
| 94 |
+
for i, code in enumerate(code_blocks):
|
| 95 |
+
if '\n' in code or len(code) > 80:
|
| 96 |
+
text_without_html = text_without_html.replace(f"CODE_BLOCK_{i}", f"```\n{code}\n```")
|
| 97 |
+
else:
|
| 98 |
+
text_without_html = text_without_html.replace(f"CODE_BLOCK_{i}", f"`{code}`")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
text_without_html = text_without_html.replace("<", "<")
|
| 102 |
+
text_without_html = text_without_html.replace(">", ">")
|
| 103 |
+
text_without_html = text_without_html.replace("&", "&")
|
| 104 |
+
text_without_html = text_without_html.replace(""", "\"")
|
| 105 |
+
|
| 106 |
+
return text_without_html
|
stackoverflow_mcp/server.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import AsyncIterator, List, Optional, Dict, Any
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
from mcp.server.fastmcp import FastMCP, Context
|
| 8 |
+
# Instead of importing Error from mcp.server.fastmcp.tools, we'll define our own Error class
|
| 9 |
+
# or we can use standard exceptions for now
|
| 10 |
+
|
| 11 |
+
from .api import StackExchangeAPI
|
| 12 |
+
from .types import (
|
| 13 |
+
SearchByQueryInput,
|
| 14 |
+
SearchByErrorInput,
|
| 15 |
+
GetQuestionInput,
|
| 16 |
+
AdvancedSearchInput,
|
| 17 |
+
SearchResult
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
from .formatter import format_response
|
| 21 |
+
from .env import STACK_EXCHANGE_API_KEY
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class AppContext:
|
| 25 |
+
api: StackExchangeAPI
|
| 26 |
+
|
| 27 |
+
@asynccontextmanager
|
| 28 |
+
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
| 29 |
+
"""Manage application lifecycle with the Stack Exchange API client.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
server (FastMCP): The FastMCP server instance
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
AsyncIterator[AppContext]: Context containing the API client
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
api = StackExchangeAPI(
|
| 39 |
+
api_key=STACK_EXCHANGE_API_KEY,
|
| 40 |
+
)
|
| 41 |
+
try:
|
| 42 |
+
yield AppContext(api=api)
|
| 43 |
+
finally:
|
| 44 |
+
await api.close()
|
| 45 |
+
|
| 46 |
+
mcp = FastMCP(
|
| 47 |
+
"Stack Overflow MCP",
|
| 48 |
+
lifespan=app_lifespan,
|
| 49 |
+
dependencies=["httpx", "python-dotenv"]
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
@mcp.tool()
|
| 53 |
+
async def advanced_search(
|
| 54 |
+
query: Optional[str] = None,
|
| 55 |
+
tags: Optional[List[str]] = None,
|
| 56 |
+
excluded_tags: Optional[List[str]] = None,
|
| 57 |
+
min_score: Optional[int] = None,
|
| 58 |
+
title: Optional[str] = None,
|
| 59 |
+
body: Optional[str] = None,
|
| 60 |
+
answers: Optional[int] = None,
|
| 61 |
+
has_accepted_answer: Optional[bool] = None,
|
| 62 |
+
views: Optional[int] = None,
|
| 63 |
+
url: Optional[str] = None,
|
| 64 |
+
user_id: Optional[int] = None,
|
| 65 |
+
is_closed: Optional[bool] = None,
|
| 66 |
+
is_wiki: Optional[bool] = None,
|
| 67 |
+
is_migrated: Optional[bool] = None,
|
| 68 |
+
has_notice: Optional[bool] = None,
|
| 69 |
+
from_date: Optional[datetime] = None,
|
| 70 |
+
to_date: Optional[datetime] = None,
|
| 71 |
+
sort_by: Optional[str] = "votes",
|
| 72 |
+
include_comments: Optional[bool] = False,
|
| 73 |
+
response_format: Optional[str] = "markdown",
|
| 74 |
+
limit: Optional[int] = 5,
|
| 75 |
+
ctx: Context = None
|
| 76 |
+
) -> str:
|
| 77 |
+
"""Advanced search for Stack Overflow questions with many filter options.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
query (Optional[str]): Free-form search query
|
| 81 |
+
tags (Optional[List[str]]): List of tags to filter by
|
| 82 |
+
excluded_tags (Optional[List[str]]): List of tags to exclude
|
| 83 |
+
min_score (Optional[int]): Minimum score threshold
|
| 84 |
+
title (Optional[str]): Text that must appear in the title
|
| 85 |
+
body (Optional[str]): Text that must appear in the body
|
| 86 |
+
answers (Optional[int]): Minimum number of answers
|
| 87 |
+
has_accepted_answer (Optional[bool]): Whether questions must have an accepted answer
|
| 88 |
+
views (Optional[int]): Minimum number of views
|
| 89 |
+
url (Optional[str]): URL that must be contained in the post
|
| 90 |
+
user_id (Optional[int]): ID of the user who must own the questions
|
| 91 |
+
is_closed (Optional[bool]): Whether to return only closed or open questions
|
| 92 |
+
is_wiki (Optional[bool]): Whether to return only community wiki questions
|
| 93 |
+
is_migrated (Optional[bool]): Whether to return only migrated questions
|
| 94 |
+
has_notice (Optional[bool]): Whether to return only questions with post notices
|
| 95 |
+
from_date (Optional[datetime]): Earliest creation date
|
| 96 |
+
to_date (Optional[datetime]): Latest creation date
|
| 97 |
+
sort_by (Optional[str]): Field to sort by (activity, creation, votes, relevance)
|
| 98 |
+
include_comments (Optional[bool]): Whether to include comments in results
|
| 99 |
+
response_format (Optional[str]): Format of response ("json" or "markdown")
|
| 100 |
+
limit (Optional[int]): Maximum number of results to return
|
| 101 |
+
ctx (Context): The context is passed automatically by the MCP
|
| 102 |
+
|
| 103 |
+
Returns:
|
| 104 |
+
str: Formatted search results
|
| 105 |
+
"""
|
| 106 |
+
try:
|
| 107 |
+
api = ctx.request_context.lifespan_context.api
|
| 108 |
+
|
| 109 |
+
ctx.debug(f"Performing advanced search on Stack Overflow")
|
| 110 |
+
if query:
|
| 111 |
+
ctx.debug(f"Query: {query}")
|
| 112 |
+
if body:
|
| 113 |
+
ctx.debug(f"Body: {body}")
|
| 114 |
+
if tags:
|
| 115 |
+
ctx.debug(f"Tags: {', '.join(tags)}")
|
| 116 |
+
if excluded_tags:
|
| 117 |
+
ctx.debug(f"Excluded tags: {', '.join(excluded_tags)}")
|
| 118 |
+
|
| 119 |
+
results = await api.advanced_search(
|
| 120 |
+
query=query,
|
| 121 |
+
tags=tags,
|
| 122 |
+
excluded_tags=excluded_tags,
|
| 123 |
+
min_score=min_score,
|
| 124 |
+
title=title,
|
| 125 |
+
body=body,
|
| 126 |
+
answers=answers,
|
| 127 |
+
has_accepted_answer=has_accepted_answer,
|
| 128 |
+
views=views,
|
| 129 |
+
url=url,
|
| 130 |
+
user_id=user_id,
|
| 131 |
+
is_closed=is_closed,
|
| 132 |
+
is_wiki=is_wiki,
|
| 133 |
+
is_migrated=is_migrated,
|
| 134 |
+
has_notice=has_notice,
|
| 135 |
+
from_date=from_date,
|
| 136 |
+
to_date=to_date,
|
| 137 |
+
sort_by=sort_by,
|
| 138 |
+
limit=limit,
|
| 139 |
+
include_comments=include_comments
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
ctx.debug(f"Found {len(results)} results")
|
| 143 |
+
|
| 144 |
+
return format_response(results, response_format)
|
| 145 |
+
|
| 146 |
+
except Exception as e:
|
| 147 |
+
ctx.error(f"Error performing advanced search on Stack Overflow: {str(e)}")
|
| 148 |
+
raise RuntimeError(f"Failed to search Stack Overflow: {str(e)}")
|
| 149 |
+
|
| 150 |
+
@mcp.tool()
|
| 151 |
+
async def search_by_query(
|
| 152 |
+
query: str,
|
| 153 |
+
tags: Optional[List[str]] = None,
|
| 154 |
+
excluded_tags: Optional[List[str]] = None,
|
| 155 |
+
min_score: Optional[int] = None,
|
| 156 |
+
title: Optional[str] = None,
|
| 157 |
+
body: Optional[str] = None,
|
| 158 |
+
has_accepted_answer: Optional[bool] = None,
|
| 159 |
+
answers: Optional[int] = None,
|
| 160 |
+
sort_by: Optional[str] = "votes",
|
| 161 |
+
include_comments: Optional[bool] = False,
|
| 162 |
+
response_format: Optional[str] = "markdown",
|
| 163 |
+
limit: Optional[int] = 5,
|
| 164 |
+
ctx: Context = None
|
| 165 |
+
) -> str:
|
| 166 |
+
"""Search Stack Overflow for questions matching a query.
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
query (str): The search query
|
| 170 |
+
tags (Optional[List[str]]): Optional list of tags to filter by (e.g., ["python", "pandas"])
|
| 171 |
+
excluded_tags (Optional[List[str]]): Optional list of tags to exclude
|
| 172 |
+
min_score (Optional[int]): Minimum score threshold for questions
|
| 173 |
+
title (Optional[str]): Text that must appear in the title
|
| 174 |
+
body (Optional[str]): Text that must appear in the body
|
| 175 |
+
has_accepted_answer (Optional[bool]): Whether questions must have an accepted answer
|
| 176 |
+
answers (Optional[int]): Minimum number of answers
|
| 177 |
+
sort_by (Optional[str]): Field to sort by (activity, creation, votes, relevance)
|
| 178 |
+
include_comments (Optional[bool]): Whether to include comments in results
|
| 179 |
+
response_format (Optional[str]): Format of response ("json" or "markdown")
|
| 180 |
+
limit (Optional[int]): Maximum number of results to return
|
| 181 |
+
ctx (Context): The context is passed automatically by the MCP
|
| 182 |
+
|
| 183 |
+
Returns:
|
| 184 |
+
str: Formatted search results
|
| 185 |
+
"""
|
| 186 |
+
try:
|
| 187 |
+
api = ctx.request_context.lifespan_context.api
|
| 188 |
+
|
| 189 |
+
ctx.debug(f"Searching Stack Overflow for: {query}")
|
| 190 |
+
|
| 191 |
+
if tags:
|
| 192 |
+
ctx.debug(f"Filtering by tags: {', '.join(tags)}")
|
| 193 |
+
if excluded_tags:
|
| 194 |
+
ctx.debug(f"Excluding tags: {', '.join(excluded_tags)}")
|
| 195 |
+
|
| 196 |
+
results = await api.search_by_query(
|
| 197 |
+
query=query,
|
| 198 |
+
tags=tags,
|
| 199 |
+
excluded_tags=excluded_tags,
|
| 200 |
+
min_score=min_score,
|
| 201 |
+
title=title,
|
| 202 |
+
body=body,
|
| 203 |
+
has_accepted_answer=has_accepted_answer,
|
| 204 |
+
answers=answers,
|
| 205 |
+
sort_by=sort_by,
|
| 206 |
+
limit=limit,
|
| 207 |
+
include_comments=include_comments
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
ctx.debug(f"Found {len(results)} results")
|
| 211 |
+
|
| 212 |
+
return format_response(results, response_format)
|
| 213 |
+
|
| 214 |
+
except Exception as e:
|
| 215 |
+
ctx.error(f"Error searching Stack Overflow: {str(e)}")
|
| 216 |
+
raise RuntimeError(f"Failed to search Stack Overflow: {str(e)}")
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@mcp.tool()
|
| 220 |
+
async def search_by_error(
|
| 221 |
+
error_message: str,
|
| 222 |
+
language: Optional[str] = None,
|
| 223 |
+
technologies: Optional[List[str]] = None,
|
| 224 |
+
excluded_tags: Optional[List[str]] = None,
|
| 225 |
+
min_score: Optional[int] = None,
|
| 226 |
+
has_accepted_answer: Optional[bool] = None,
|
| 227 |
+
answers: Optional[int] = None,
|
| 228 |
+
include_comments: Optional[bool] = False,
|
| 229 |
+
response_format: Optional[str] = "markdown",
|
| 230 |
+
limit: Optional[int] = 5,
|
| 231 |
+
ctx: Context = None
|
| 232 |
+
) -> str:
|
| 233 |
+
"""Search Stack Overflow for solutions to an error message
|
| 234 |
+
|
| 235 |
+
Args:
|
| 236 |
+
error_message (str): The error message to search for
|
| 237 |
+
language (Optional[str]): Programming language (e.g., "python", "javascript")
|
| 238 |
+
technologies (Optional[List[str]]): Related technologies (e.g., ["react", "django"])
|
| 239 |
+
excluded_tags (Optional[List[str]]): Optional list of tags to exclude
|
| 240 |
+
min_score (Optional[int]): Minimum score threshold for questions
|
| 241 |
+
has_accepted_answer (Optional[bool]): Whether questions must have an accepted answer
|
| 242 |
+
answers (Optional[int]): Minimum number of answers
|
| 243 |
+
include_comments (Optional[bool]): Whether to include comments in results
|
| 244 |
+
response_format (Optional[str]): Format of response ("json" or "markdown")
|
| 245 |
+
limit (Optional[int]): Maximum number of results to return
|
| 246 |
+
ctx (Context): The context is passed automatically by the MCP
|
| 247 |
+
|
| 248 |
+
Returns:
|
| 249 |
+
str: Formatted search results
|
| 250 |
+
"""
|
| 251 |
+
try:
|
| 252 |
+
api = ctx.request_context.lifespan_context.api
|
| 253 |
+
|
| 254 |
+
tags = []
|
| 255 |
+
if language:
|
| 256 |
+
tags.append(language.lower())
|
| 257 |
+
if technologies:
|
| 258 |
+
tags.extend([t.lower() for t in technologies])
|
| 259 |
+
|
| 260 |
+
ctx.debug(f"Searching Stack Overflow for error: {error_message}")
|
| 261 |
+
|
| 262 |
+
if tags:
|
| 263 |
+
ctx.debug(f"Using tags: {', '.join(tags)}")
|
| 264 |
+
if excluded_tags:
|
| 265 |
+
ctx.debug(f"Excluding tags: {', '.join(excluded_tags)}")
|
| 266 |
+
|
| 267 |
+
results = await api.search_by_query(
|
| 268 |
+
query=error_message,
|
| 269 |
+
tags=tags if tags else None,
|
| 270 |
+
excluded_tags=excluded_tags,
|
| 271 |
+
min_score=min_score,
|
| 272 |
+
has_accepted_answer=has_accepted_answer,
|
| 273 |
+
answers=answers,
|
| 274 |
+
limit=limit,
|
| 275 |
+
include_comments=include_comments
|
| 276 |
+
)
|
| 277 |
+
ctx.debug(f"Found {len(results)} results")
|
| 278 |
+
|
| 279 |
+
return format_response(results, response_format)
|
| 280 |
+
except Exception as e:
|
| 281 |
+
ctx.error(f"Error searching Stack Overflow: {str(e)}")
|
| 282 |
+
raise RuntimeError(f"Failed to search Stack Overflow: {str(e)}")
|
| 283 |
+
|
| 284 |
+
@mcp.tool()
|
| 285 |
+
async def get_question(
|
| 286 |
+
question_id: int,
|
| 287 |
+
include_comments: Optional[bool] = True,
|
| 288 |
+
response_format: Optional[str] = "markdown",
|
| 289 |
+
ctx: Context = None
|
| 290 |
+
) -> str:
|
| 291 |
+
"""Get a specific Stack Overflow question by ID.
|
| 292 |
+
|
| 293 |
+
Args:
|
| 294 |
+
question_id (int): The Stack Overflow question ID
|
| 295 |
+
include_comments (Optional[bool]): Whether to include comments in results
|
| 296 |
+
response_format (Optional[str]): Format of response ("json" or "markdown")
|
| 297 |
+
ctx (Context): The context is passed automatically by the MCP
|
| 298 |
+
|
| 299 |
+
Returns:
|
| 300 |
+
str: Formatted question details
|
| 301 |
+
"""
|
| 302 |
+
try:
|
| 303 |
+
api = ctx.request_context.lifespan_context.api
|
| 304 |
+
|
| 305 |
+
ctx.debug(f"Fetching Stack Overflow question: {question_id}")
|
| 306 |
+
|
| 307 |
+
result = await api.get_question(
|
| 308 |
+
question_id=question_id,
|
| 309 |
+
include_comments=include_comments
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
return format_response([result], response_format)
|
| 313 |
+
|
| 314 |
+
except Exception as e:
|
| 315 |
+
ctx.error(f"Error fetching Stack Overflow question: {str(e)}")
|
| 316 |
+
raise RuntimeError(f"Failed to fetch Stack Overflow question: {str(e)}")
|
| 317 |
+
|
| 318 |
+
@mcp.tool()
|
| 319 |
+
async def analyze_stack_trace(
|
| 320 |
+
stack_trace: str,
|
| 321 |
+
language: str,
|
| 322 |
+
excluded_tags: Optional[List[str]] = None,
|
| 323 |
+
min_score: Optional[int] = None,
|
| 324 |
+
has_accepted_answer: Optional[bool] = None,
|
| 325 |
+
answers: Optional[int] = None,
|
| 326 |
+
include_comments: Optional[bool] = True,
|
| 327 |
+
response_format: Optional[str] = "markdown",
|
| 328 |
+
limit: Optional[int] = 3,
|
| 329 |
+
ctx: Context = None
|
| 330 |
+
) -> str:
|
| 331 |
+
"""Analyze a stack trace and find relevant solutions on Stack Overflow.
|
| 332 |
+
|
| 333 |
+
Args:
|
| 334 |
+
stack_trace (str): The stack trace to analyze
|
| 335 |
+
language (str): Programming language of the stack trace
|
| 336 |
+
excluded_tags (Optional[List[str]]): Optional list of tags to exclude
|
| 337 |
+
min_score (Optional[int]): Minimum score threshold for questions
|
| 338 |
+
has_accepted_answer (Optional[bool]): Whether questions must have an accepted answer
|
| 339 |
+
answers (Optional[int]): Minimum number of answers
|
| 340 |
+
include_comments (Optional[bool]): Whether to include comments in results
|
| 341 |
+
response_format (Optional[str]): Format of response ("json" or "markdown")
|
| 342 |
+
limit (Optional[int]): Maximum number of results to return
|
| 343 |
+
ctx (Context): The context is passed automatically by the MCP
|
| 344 |
+
|
| 345 |
+
Returns:
|
| 346 |
+
str: Formatted search results
|
| 347 |
+
"""
|
| 348 |
+
try:
|
| 349 |
+
api = ctx.request_context.lifespan_context.api
|
| 350 |
+
|
| 351 |
+
error_lines = stack_trace.split("\n")
|
| 352 |
+
error_message = error_lines[0]
|
| 353 |
+
|
| 354 |
+
ctx.debug(f"Analyzing stack trace: {error_message}")
|
| 355 |
+
ctx.debug(f"Language: {language}")
|
| 356 |
+
|
| 357 |
+
results = await api.search_by_query(
|
| 358 |
+
query=error_message,
|
| 359 |
+
tags=[language.lower()],
|
| 360 |
+
excluded_tags=excluded_tags,
|
| 361 |
+
min_score=min_score,
|
| 362 |
+
has_accepted_answer=has_accepted_answer,
|
| 363 |
+
answers=answers,
|
| 364 |
+
limit=limit,
|
| 365 |
+
include_comments=include_comments
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
ctx.debug(f"Found {len(results)} results")
|
| 369 |
+
|
| 370 |
+
return format_response(results, response_format)
|
| 371 |
+
except Exception as e:
|
| 372 |
+
ctx.error(f"Error analyzing stack trace: {str(e)}")
|
| 373 |
+
raise RuntimeError(f"Failed to analyze stack trace: {str(e)}")
|
| 374 |
+
|
| 375 |
+
if __name__ == "__main__":
|
| 376 |
+
mcp.run()
|
stackoverflow_mcp/types.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from typing import List, Dict, Optional, Union, Literal
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
@dataclass
|
| 6 |
+
class AdvancedSearchInput:
|
| 7 |
+
query: Optional[str] = None
|
| 8 |
+
tags: Optional[List[str]] = None
|
| 9 |
+
excluded_tags: Optional[List[str]] = None
|
| 10 |
+
min_score: Optional[int] = None
|
| 11 |
+
title: Optional[str] = None
|
| 12 |
+
body: Optional[str] = None
|
| 13 |
+
answers: Optional[int] = None
|
| 14 |
+
has_accepted_answer: Optional[bool] = None
|
| 15 |
+
views: Optional[int] = None
|
| 16 |
+
url: Optional[str] = None
|
| 17 |
+
user_id: Optional[int] = None
|
| 18 |
+
is_closed: Optional[bool] = None
|
| 19 |
+
is_wiki: Optional[bool] = None
|
| 20 |
+
is_migrated: Optional[bool] = None
|
| 21 |
+
has_notice: Optional[bool] = None
|
| 22 |
+
from_date: Optional[datetime] = None
|
| 23 |
+
to_date: Optional[datetime] = None
|
| 24 |
+
sort_by: Optional[Literal["activity", "creation", "votes", "relevance"]] = "activity"
|
| 25 |
+
include_comments: Optional[bool] = False
|
| 26 |
+
response_format: Optional[Literal["json", "markdown"]] = "markdown"
|
| 27 |
+
limit: Optional[int] = 5
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class SearchByQueryInput:
|
| 31 |
+
query: str
|
| 32 |
+
tags: Optional[List[str]] = None
|
| 33 |
+
excluded_tags: Optional[List[str]] = None
|
| 34 |
+
min_score: Optional[int] = None
|
| 35 |
+
title: Optional[str] = None
|
| 36 |
+
body: Optional[str] = None
|
| 37 |
+
has_accepted_answer: Optional[bool] = None
|
| 38 |
+
answers: Optional[int] = None
|
| 39 |
+
sort_by: Optional[Literal["activity", "creation", "votes", "relevance"]] = "votes"
|
| 40 |
+
include_comments: Optional[bool] = False
|
| 41 |
+
response_format: Optional[Literal["json","markdown"]] = "markdown"
|
| 42 |
+
limit: Optional[int] = 5
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class SearchByErrorInput:
|
| 46 |
+
error_message: str
|
| 47 |
+
language: Optional[str] = None
|
| 48 |
+
technologies: Optional[List[str]] = None
|
| 49 |
+
excluded_tags: Optional[List[str]] = None
|
| 50 |
+
min_score: Optional[int] = None
|
| 51 |
+
has_accepted_answer: Optional[bool] = None
|
| 52 |
+
answers: Optional[int] = None
|
| 53 |
+
include_comments: Optional[bool] = False
|
| 54 |
+
response_format: Optional[Literal["json", "markdown"]] = "markdown"
|
| 55 |
+
limit: Optional[int] = 5
|
| 56 |
+
|
| 57 |
+
@dataclass
|
| 58 |
+
class GetQuestionInput:
|
| 59 |
+
question_id: int
|
| 60 |
+
include_comments: Optional[bool] = True
|
| 61 |
+
response_format: Optional[Literal["json", "markdown"]] = "markdown"
|
| 62 |
+
|
| 63 |
+
@dataclass
|
| 64 |
+
class StackOverflowQuestion:
|
| 65 |
+
question_id: int
|
| 66 |
+
title: str
|
| 67 |
+
body: str
|
| 68 |
+
score: int
|
| 69 |
+
answer_count: int
|
| 70 |
+
is_answered: bool
|
| 71 |
+
accepted_answer_id: Optional[int] = None
|
| 72 |
+
creation_date: int = 0
|
| 73 |
+
last_activity_date: int = 0
|
| 74 |
+
view_count: int = 0
|
| 75 |
+
tags: List[str] = None
|
| 76 |
+
link: str = ""
|
| 77 |
+
is_closed: bool = False
|
| 78 |
+
owner: Optional[Dict] = None
|
| 79 |
+
|
| 80 |
+
@dataclass
|
| 81 |
+
class StackOverflowAnswer:
|
| 82 |
+
answer_id: int
|
| 83 |
+
question_id: int
|
| 84 |
+
score: int
|
| 85 |
+
is_accepted: bool
|
| 86 |
+
body: str
|
| 87 |
+
creation_date: int = 0
|
| 88 |
+
last_activity_date: int = 0
|
| 89 |
+
link: str = ""
|
| 90 |
+
owner: Optional[Dict] = None
|
| 91 |
+
|
| 92 |
+
@dataclass
|
| 93 |
+
class StackOverflowComment:
|
| 94 |
+
comment_id: int
|
| 95 |
+
post_id: int
|
| 96 |
+
score: int
|
| 97 |
+
body: str
|
| 98 |
+
creation_date: int = 0
|
| 99 |
+
owner: Optional[Dict] = None
|
| 100 |
+
|
| 101 |
+
@dataclass
|
| 102 |
+
class SearchResultComments:
|
| 103 |
+
question: List[StackOverflowComment]
|
| 104 |
+
answers: Dict[int, List[StackOverflowComment]]
|
| 105 |
+
|
| 106 |
+
@dataclass
|
| 107 |
+
class SearchResult:
|
| 108 |
+
question: StackOverflowQuestion
|
| 109 |
+
answers: List[StackOverflowAnswer]
|
| 110 |
+
comments: Optional[SearchResultComments] = None
|
test_live_demo.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Live Demo Test - Stack Overflow MCP Server
|
| 4 |
+
This script makes actual API calls to demonstrate the working functionality.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import os
|
| 9 |
+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
| 10 |
+
|
| 11 |
+
from gradio_app import search_by_query_sync, search_by_error_sync, get_question_sync
|
| 12 |
+
|
| 13 |
+
def test_live_functionality():
|
| 14 |
+
"""Test the actual functionality of our Stack Overflow MCP server."""
|
| 15 |
+
|
| 16 |
+
print("🧪 LIVE FUNCTIONALITY TEST")
|
| 17 |
+
print("=" * 60)
|
| 18 |
+
|
| 19 |
+
# Test 1: General Search
|
| 20 |
+
print("\n1️⃣ Testing General Search...")
|
| 21 |
+
print("Query: 'python list comprehension'")
|
| 22 |
+
print("Tags: 'python'")
|
| 23 |
+
print("-" * 40)
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
result = search_by_query_sync(
|
| 27 |
+
query="python list comprehension",
|
| 28 |
+
tags="python",
|
| 29 |
+
min_score=10,
|
| 30 |
+
has_accepted_answer=True,
|
| 31 |
+
limit=2,
|
| 32 |
+
response_format="markdown"
|
| 33 |
+
)
|
| 34 |
+
print("✅ SUCCESS:")
|
| 35 |
+
print(result[:500] + "..." if len(result) > 500 else result)
|
| 36 |
+
except Exception as e:
|
| 37 |
+
print(f"❌ ERROR: {e}")
|
| 38 |
+
|
| 39 |
+
print("\n" + "=" * 60)
|
| 40 |
+
|
| 41 |
+
# Test 2: Error Search
|
| 42 |
+
print("\n2️⃣ Testing Error Search...")
|
| 43 |
+
print("Error: 'TypeError: NoneType'")
|
| 44 |
+
print("Language: 'python'")
|
| 45 |
+
print("-" * 40)
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
result = search_by_error_sync(
|
| 49 |
+
error_message="TypeError: NoneType object has no attribute",
|
| 50 |
+
language="python",
|
| 51 |
+
technologies="",
|
| 52 |
+
min_score=5,
|
| 53 |
+
has_accepted_answer=True,
|
| 54 |
+
limit=2,
|
| 55 |
+
response_format="markdown"
|
| 56 |
+
)
|
| 57 |
+
print("✅ SUCCESS:")
|
| 58 |
+
print(result[:500] + "..." if len(result) > 500 else result)
|
| 59 |
+
except Exception as e:
|
| 60 |
+
print(f"❌ ERROR: {e}")
|
| 61 |
+
|
| 62 |
+
print("\n" + "=" * 60)
|
| 63 |
+
|
| 64 |
+
# Test 3: Get Question
|
| 65 |
+
print("\n3️⃣ Testing Get Question...")
|
| 66 |
+
print("Question ID: 11227809 (Famous sorting question)")
|
| 67 |
+
print("-" * 40)
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
result = get_question_sync(
|
| 71 |
+
question_id="11227809",
|
| 72 |
+
include_comments=False,
|
| 73 |
+
response_format="markdown"
|
| 74 |
+
)
|
| 75 |
+
print("✅ SUCCESS:")
|
| 76 |
+
print(result[:500] + "..." if len(result) > 500 else result)
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f"❌ ERROR: {e}")
|
| 79 |
+
|
| 80 |
+
print("\n" + "=" * 60)
|
| 81 |
+
print("🎯 Live functionality test completed!")
|
| 82 |
+
print("🚀 All functions are working and connected to Stack Overflow API")
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
test_live_functionality()
|
tests/api/test_search.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import pytest
|
| 3 |
+
import asyncio
|
| 4 |
+
import httpx
|
| 5 |
+
from unittest.mock import patch, MagicMock, AsyncMock
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
from stackoverflow_mcp.api import StackExchangeAPI
|
| 9 |
+
from stackoverflow_mcp.types import SearchResult
|
| 10 |
+
|
| 11 |
+
# Load test environment variables
|
| 12 |
+
load_dotenv(".env.test")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@pytest.fixture
|
| 16 |
+
def api_key():
|
| 17 |
+
"""Return API key from environment or None."""
|
| 18 |
+
return os.getenv("STACK_EXCHANGE_API_KEY")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture
|
| 22 |
+
def api(api_key):
|
| 23 |
+
"""Create a StackExchangeAPI instance for testing."""
|
| 24 |
+
api = StackExchangeAPI(api_key=api_key)
|
| 25 |
+
yield api
|
| 26 |
+
# Clean up
|
| 27 |
+
asyncio.run(api.close())
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@pytest.fixture
|
| 31 |
+
def mock_search_response():
|
| 32 |
+
"""Create a mock search response."""
|
| 33 |
+
response = MagicMock()
|
| 34 |
+
response.raise_for_status = MagicMock()
|
| 35 |
+
response.json = MagicMock(return_value={
|
| 36 |
+
"items": [
|
| 37 |
+
{
|
| 38 |
+
"question_id": 12345,
|
| 39 |
+
"title": "How to unittest a Flask application?",
|
| 40 |
+
"body": "<p>I'm trying to test my Flask application with unittest.</p>",
|
| 41 |
+
"score": 25,
|
| 42 |
+
"answer_count": 3,
|
| 43 |
+
"is_answered": True,
|
| 44 |
+
"accepted_answer_id": 54321,
|
| 45 |
+
"creation_date": 1609459200,
|
| 46 |
+
"last_activity_date": 1609459200,
|
| 47 |
+
"view_count": 1000,
|
| 48 |
+
"tags": ["python", "flask", "testing", "unittest"],
|
| 49 |
+
"link": "https://stackoverflow.com/q/12345",
|
| 50 |
+
"closed_date": None,
|
| 51 |
+
"owner": {
|
| 52 |
+
"user_id": 101,
|
| 53 |
+
"display_name": "Test User",
|
| 54 |
+
"reputation": 1000
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
],
|
| 58 |
+
"has_more": False,
|
| 59 |
+
"quota_max": 300,
|
| 60 |
+
"quota_remaining": 299
|
| 61 |
+
})
|
| 62 |
+
return response
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# REAL API TESTS
|
| 66 |
+
|
| 67 |
+
@pytest.mark.asyncio
|
| 68 |
+
@pytest.mark.real_api
|
| 69 |
+
async def test_search_by_query_real(api):
|
| 70 |
+
"""Test searching by query using real API."""
|
| 71 |
+
# Skip if no API key
|
| 72 |
+
if not os.getenv("STACK_EXCHANGE_API_KEY"):
|
| 73 |
+
pytest.skip("API key required for real API tests")
|
| 74 |
+
|
| 75 |
+
results = await api.search_by_query(
|
| 76 |
+
query="python unittest flask",
|
| 77 |
+
tags=["python", "flask"],
|
| 78 |
+
limit=3
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# Basic validation
|
| 82 |
+
assert isinstance(results, list)
|
| 83 |
+
if results: # May be empty if no results match
|
| 84 |
+
assert isinstance(results[0], SearchResult)
|
| 85 |
+
assert results[0].question.title is not None
|
| 86 |
+
assert "python" in [tag.lower() for tag in results[0].question.tags]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@pytest.mark.asyncio
|
| 90 |
+
@pytest.mark.real_api
|
| 91 |
+
async def test_advanced_search_real(api):
|
| 92 |
+
"""Test advanced search using real API."""
|
| 93 |
+
# Skip if no API key
|
| 94 |
+
if not os.getenv("STACK_EXCHANGE_API_KEY"):
|
| 95 |
+
pytest.skip("API key required for real API tests")
|
| 96 |
+
|
| 97 |
+
results = await api.advanced_search(
|
| 98 |
+
query="database connection",
|
| 99 |
+
tags=["python"],
|
| 100 |
+
min_score=10,
|
| 101 |
+
has_accepted_answer=True,
|
| 102 |
+
limit=2
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
# Basic validation
|
| 106 |
+
assert isinstance(results, list)
|
| 107 |
+
if results: # May be empty if no results match
|
| 108 |
+
assert isinstance(results[0], SearchResult)
|
| 109 |
+
assert results[0].question.score >= 10
|
| 110 |
+
assert "python" in [tag.lower() for tag in results[0].question.tags]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# MOCK TESTS
|
| 114 |
+
|
| 115 |
+
@pytest.mark.asyncio
|
| 116 |
+
async def test_search_by_query_mock(api, mock_search_response):
|
| 117 |
+
"""Test searching by query with mocked response."""
|
| 118 |
+
with patch.object(httpx.AsyncClient, 'get', return_value=mock_search_response):
|
| 119 |
+
results = await api.search_by_query(
|
| 120 |
+
query="flask unittest",
|
| 121 |
+
tags=["python", "flask"],
|
| 122 |
+
min_score=10,
|
| 123 |
+
limit=5
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
assert len(results) == 1
|
| 127 |
+
assert results[0].question.question_id == 12345
|
| 128 |
+
assert results[0].question.title == "How to unittest a Flask application?"
|
| 129 |
+
assert "python" in results[0].question.tags
|
| 130 |
+
assert "flask" in results[0].question.tags
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@pytest.mark.asyncio
|
| 134 |
+
async def test_empty_search_results(api):
|
| 135 |
+
"""Test empty search results handling."""
|
| 136 |
+
empty_response = MagicMock()
|
| 137 |
+
empty_response.raise_for_status = MagicMock()
|
| 138 |
+
empty_response.json = MagicMock(return_value={"items": []})
|
| 139 |
+
|
| 140 |
+
with patch.object(httpx.AsyncClient, 'get', return_value=empty_response):
|
| 141 |
+
results = await api.search_by_query(
|
| 142 |
+
query="definitely will not find anything 89797979",
|
| 143 |
+
limit=1
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
assert isinstance(results, list)
|
| 147 |
+
assert len(results) == 0
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@pytest.mark.asyncio
|
| 151 |
+
async def test_search_with_min_score_filtering(api, mock_search_response):
|
| 152 |
+
"""Test that min_score parameter properly filters results."""
|
| 153 |
+
with patch.object(httpx.AsyncClient, 'get', return_value=mock_search_response):
|
| 154 |
+
# Should return results (mock score is 25)
|
| 155 |
+
results_included = await api.search_by_query(
|
| 156 |
+
query="flask unittest",
|
| 157 |
+
min_score=20,
|
| 158 |
+
limit=5
|
| 159 |
+
)
|
| 160 |
+
assert len(results_included) == 1
|
| 161 |
+
|
| 162 |
+
# Should filter out results
|
| 163 |
+
results_filtered = await api.search_by_query(
|
| 164 |
+
query="flask unittest",
|
| 165 |
+
min_score=30,
|
| 166 |
+
limit=5
|
| 167 |
+
)
|
| 168 |
+
assert len(results_filtered) == 0
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@pytest.mark.asyncio
|
| 172 |
+
async def test_search_with_multiple_tags(api, mock_search_response):
|
| 173 |
+
"""Test searching with multiple tags."""
|
| 174 |
+
with patch.object(httpx.AsyncClient, 'get', return_value=mock_search_response):
|
| 175 |
+
results = await api.search_by_query(
|
| 176 |
+
query="test",
|
| 177 |
+
tags=["python", "flask", "django"],
|
| 178 |
+
limit=5
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
# Verify the tags were properly passed to the request
|
| 182 |
+
call_args = httpx.AsyncClient.get.call_args
|
| 183 |
+
params = call_args[1]['params']
|
| 184 |
+
assert 'tagged' in params
|
| 185 |
+
assert params['tagged'] == "python;flask;django"
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
@pytest.mark.asyncio
|
| 189 |
+
async def test_search_with_excluded_tags(api, mock_search_response):
|
| 190 |
+
"""Test searching with excluded tags."""
|
| 191 |
+
with patch.object(httpx.AsyncClient, 'get', return_value=mock_search_response):
|
| 192 |
+
results = await api.search_by_query(
|
| 193 |
+
query="test",
|
| 194 |
+
excluded_tags=["javascript", "c#"],
|
| 195 |
+
limit=5
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# Verify the excluded tags were properly passed to the request
|
| 199 |
+
call_args = httpx.AsyncClient.get.call_args
|
| 200 |
+
params = call_args[1]['params']
|
| 201 |
+
assert 'nottagged' in params
|
| 202 |
+
assert params['nottagged'] == "javascript;c#"
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
@pytest.mark.asyncio
|
| 206 |
+
async def test_search_with_advanced_parameters(api, mock_search_response):
|
| 207 |
+
"""Test advanced search with multiple parameters."""
|
| 208 |
+
with patch.object(httpx.AsyncClient, 'get', return_value=mock_search_response):
|
| 209 |
+
results = await api.advanced_search(
|
| 210 |
+
query="flask test",
|
| 211 |
+
tags=["python"],
|
| 212 |
+
title="unittest",
|
| 213 |
+
has_accepted_answer=True,
|
| 214 |
+
sort_by="relevance",
|
| 215 |
+
limit=5
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
# Verify parameters were properly passed
|
| 219 |
+
call_args = httpx.AsyncClient.get.call_args
|
| 220 |
+
params = call_args[1]['params']
|
| 221 |
+
assert params['q'] == "flask test"
|
| 222 |
+
assert params['tagged'] == "python"
|
| 223 |
+
assert params['title'] == "unittest"
|
| 224 |
+
assert params['accepted'] == "true"
|
| 225 |
+
assert params['sort'] == "relevance"
|
| 226 |
+
assert params['pagesize'] == "5"
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@pytest.mark.asyncio
|
| 230 |
+
async def test_search_api_error(api):
|
| 231 |
+
"""Test handling of API errors."""
|
| 232 |
+
error_response = MagicMock()
|
| 233 |
+
error_response.raise_for_status = MagicMock(
|
| 234 |
+
side_effect=httpx.HTTPStatusError(
|
| 235 |
+
"500 Internal Server Error",
|
| 236 |
+
request=MagicMock(),
|
| 237 |
+
response=MagicMock(status_code=500)
|
| 238 |
+
)
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
with patch.object(httpx.AsyncClient, 'get', return_value=error_response):
|
| 242 |
+
with pytest.raises(httpx.HTTPStatusError):
|
| 243 |
+
await api.search_by_query("test query")
|
tests/test_formatter.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from stackoverflow_mcp.formatter import format_response, clean_html
|
| 3 |
+
from stackoverflow_mcp.types import (
|
| 4 |
+
SearchResult,
|
| 5 |
+
StackOverflowQuestion,
|
| 6 |
+
StackOverflowAnswer,
|
| 7 |
+
StackOverflowComment,
|
| 8 |
+
SearchResultComments
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
@pytest.fixture
|
| 12 |
+
def sample_result():
|
| 13 |
+
"""Create a sample search result for testing."""
|
| 14 |
+
question = StackOverflowQuestion(
|
| 15 |
+
question_id=12345,
|
| 16 |
+
title="How to test Python code?",
|
| 17 |
+
body="<p>I'm trying to test my <code>Python</code> code.</p><pre><code>def test():\n pass</code></pre>",
|
| 18 |
+
score=10,
|
| 19 |
+
answer_count=2,
|
| 20 |
+
is_answered=True,
|
| 21 |
+
accepted_answer_id=54321,
|
| 22 |
+
tags=["python", "testing"],
|
| 23 |
+
link="https://stackoverflow.com/q/12345"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
answers = [
|
| 27 |
+
StackOverflowAnswer(
|
| 28 |
+
answer_id=54321,
|
| 29 |
+
question_id=12345,
|
| 30 |
+
score=5,
|
| 31 |
+
is_accepted=True,
|
| 32 |
+
body="<p>You should use <code>pytest</code>.</p>",
|
| 33 |
+
link="https://stackoverflow.com/a/54321"
|
| 34 |
+
),
|
| 35 |
+
StackOverflowAnswer(
|
| 36 |
+
answer_id=67890,
|
| 37 |
+
question_id=12345,
|
| 38 |
+
score=3,
|
| 39 |
+
is_accepted=False,
|
| 40 |
+
body="<p>Another option is <code>unittest</code>.</p>",
|
| 41 |
+
link="https://stackoverflow.com/a/67890"
|
| 42 |
+
)
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
comments = SearchResultComments(
|
| 46 |
+
question=[
|
| 47 |
+
StackOverflowComment(
|
| 48 |
+
comment_id=111,
|
| 49 |
+
post_id=12345,
|
| 50 |
+
score=2,
|
| 51 |
+
body="Have you tried pytest?"
|
| 52 |
+
)
|
| 53 |
+
],
|
| 54 |
+
answers={
|
| 55 |
+
54321: [
|
| 56 |
+
StackOverflowComment(
|
| 57 |
+
comment_id=222,
|
| 58 |
+
post_id=54321,
|
| 59 |
+
score=1,
|
| 60 |
+
body="Great answer!"
|
| 61 |
+
)
|
| 62 |
+
],
|
| 63 |
+
67890: []
|
| 64 |
+
}
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
return SearchResult(
|
| 68 |
+
question=question,
|
| 69 |
+
answers=answers,
|
| 70 |
+
comments=comments
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
def test_clean_html():
|
| 74 |
+
"""Test HTML cleaning."""
|
| 75 |
+
html = "<p>This is <b>bold</b> and <i>italic</i>.</p><code>inline code</code><pre><code>def function():\n return True</code></pre>"
|
| 76 |
+
cleaned = clean_html(html)
|
| 77 |
+
|
| 78 |
+
assert "<p>" not in cleaned
|
| 79 |
+
assert "<b>" not in cleaned
|
| 80 |
+
assert "<i>" not in cleaned
|
| 81 |
+
assert "This is bold and italic." in cleaned
|
| 82 |
+
assert "`inline code`" in cleaned
|
| 83 |
+
assert "```\ndef function():\n return True\n```" in cleaned
|
| 84 |
+
|
| 85 |
+
def test_format_response_markdown(sample_result):
|
| 86 |
+
"""Test formatting as Markdown."""
|
| 87 |
+
markdown = format_response([sample_result], "markdown")
|
| 88 |
+
|
| 89 |
+
assert "# How to test Python code?" in markdown
|
| 90 |
+
assert "**Score:** 10" in markdown
|
| 91 |
+
assert "## Question" in markdown
|
| 92 |
+
assert "I'm trying to test my `Python` code." in markdown
|
| 93 |
+
assert "```\ndef test():\n pass\n```" in markdown
|
| 94 |
+
assert "### Question Comments" in markdown
|
| 95 |
+
assert "- Have you tried pytest?" in markdown
|
| 96 |
+
assert "### ✓ Answer (Score: 5)" in markdown
|
| 97 |
+
assert "You should use `pytest`." in markdown
|
| 98 |
+
assert "### Answer (Score: 3)" in markdown
|
| 99 |
+
assert "Another option is `unittest`." in markdown
|
| 100 |
+
assert "[View on Stack Overflow](https://stackoverflow.com/q/12345)" in markdown
|
| 101 |
+
|
| 102 |
+
def test_format_response_json(sample_result):
|
| 103 |
+
"""Test formatting as JSON."""
|
| 104 |
+
json_str = format_response([sample_result], "json")
|
| 105 |
+
|
| 106 |
+
assert "How to test Python code?" in json_str
|
| 107 |
+
assert "question_id" in json_str
|
| 108 |
+
assert "answers" in json_str
|
| 109 |
+
assert "comments" in json_str
|
tests/test_general_api_health.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import pytest
|
| 3 |
+
import asyncio
|
| 4 |
+
import httpx
|
| 5 |
+
from unittest.mock import patch, MagicMock
|
| 6 |
+
from stackoverflow_mcp.api import StackExchangeAPI
|
| 7 |
+
from stackoverflow_mcp.types import StackOverflowQuestion, StackOverflowAnswer, SearchResult
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture
|
| 11 |
+
def api():
|
| 12 |
+
"""Create A StackExchangeAPI instance for testing
|
| 13 |
+
"""
|
| 14 |
+
return StackExchangeAPI(api_key="test_key")
|
| 15 |
+
|
| 16 |
+
@pytest.fixture
|
| 17 |
+
def mock_response():
|
| 18 |
+
"""Create a mock response for httpx."""
|
| 19 |
+
|
| 20 |
+
response = MagicMock()
|
| 21 |
+
response.raise_for_status = MagicMock()
|
| 22 |
+
response.json = MagicMock(return_value={
|
| 23 |
+
"items" : [
|
| 24 |
+
{
|
| 25 |
+
"question_id": 12345,
|
| 26 |
+
"title": "Test Question",
|
| 27 |
+
"body": "Test body",
|
| 28 |
+
"score": 10,
|
| 29 |
+
"answer_count": 2,
|
| 30 |
+
"is_answered": True,
|
| 31 |
+
"accepted_answer_id": 54321,
|
| 32 |
+
"creation_date": 1609459200,
|
| 33 |
+
"tags": ["python", "testing"],
|
| 34 |
+
"link": "https://stackoverflow.com/q/12345"
|
| 35 |
+
}
|
| 36 |
+
]
|
| 37 |
+
})
|
| 38 |
+
return response
|
| 39 |
+
|
| 40 |
+
@pytest.mark.asyncio
|
| 41 |
+
async def test_search_by_query(api, mock_response):
|
| 42 |
+
"""Test searching by query."""
|
| 43 |
+
with patch.object(httpx.AsyncClient, 'get', return_value=mock_response):
|
| 44 |
+
results = await api.search_by_query("test query")
|
| 45 |
+
|
| 46 |
+
assert len(results) == 1
|
| 47 |
+
assert results[0].question.question_id == 12345
|
| 48 |
+
assert results[0].question.title == "Test Question"
|
| 49 |
+
assert isinstance(results[0], SearchResult)
|
| 50 |
+
|
| 51 |
+
@pytest.mark.asyncio
|
| 52 |
+
async def test_get_question(api, mock_response):
|
| 53 |
+
"""Test getting a specific question."""
|
| 54 |
+
with patch.object(httpx.AsyncClient, 'get', return_value=mock_response):
|
| 55 |
+
result = await api.get_question(12345)
|
| 56 |
+
|
| 57 |
+
assert result.question.question_id == 12345
|
| 58 |
+
assert result.question.title == "Test Question"
|
| 59 |
+
assert isinstance(result, SearchResult)
|
| 60 |
+
|
| 61 |
+
@pytest.mark.asyncio
|
| 62 |
+
async def test_rate_limiting(api):
|
| 63 |
+
"""Test rate limiting mechanism."""
|
| 64 |
+
mock_resp = MagicMock()
|
| 65 |
+
mock_resp.raise_for_status = MagicMock()
|
| 66 |
+
mock_resp.json = MagicMock(return_value={"items": []})
|
| 67 |
+
|
| 68 |
+
with patch.object(httpx.AsyncClient, 'get', return_value=mock_resp):
|
| 69 |
+
api.request_timestamps = [time.time() * 1000 * 1000 for _ in range(30)]
|
| 70 |
+
|
| 71 |
+
with patch('asyncio.sleep') as mock_sleep:
|
| 72 |
+
try:
|
| 73 |
+
await api.search_by_query("test")
|
| 74 |
+
except Exception as e:
|
| 75 |
+
assert str(e) == "Maximum rate limiting attempts exceeded"
|
| 76 |
+
mock_sleep.assert_called()
|
| 77 |
+
|
| 78 |
+
@pytest.mark.asyncio
|
| 79 |
+
async def test_retry_after_429(api):
|
| 80 |
+
"""Test retry behavior after hitting rate limit."""
|
| 81 |
+
error_resp = MagicMock()
|
| 82 |
+
error_resp.raise_for_status.side_effect = httpx.HTTPStatusError("Rate limited", request=MagicMock(), response=MagicMock(status_code=429))
|
| 83 |
+
|
| 84 |
+
success_resp = MagicMock()
|
| 85 |
+
success_resp.raise_for_status = MagicMock()
|
| 86 |
+
success_resp.json = MagicMock(return_value={"items": []})
|
| 87 |
+
|
| 88 |
+
with patch.object(httpx.AsyncClient, 'get', side_effect=[error_resp, success_resp]):
|
| 89 |
+
with patch('asyncio.sleep') as mock_sleep:
|
| 90 |
+
await api.search_by_query("test", retries=1)
|
| 91 |
+
mock_sleep.assert_called_once()
|
tests/test_server.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
import asyncio
|
| 3 |
+
from unittest.mock import patch, MagicMock, AsyncMock
|
| 4 |
+
|
| 5 |
+
from stackoverflow_mcp.server import mcp, search_by_query, search_by_error, get_question, analyze_stack_trace
|
| 6 |
+
from stackoverflow_mcp.types import StackOverflowQuestion, StackOverflowAnswer, SearchResult
|
| 7 |
+
from mcp.server.fastmcp import Context
|
| 8 |
+
|
| 9 |
+
@pytest.fixture
|
| 10 |
+
def mock_context():
|
| 11 |
+
"""Create a mock context for testing"""
|
| 12 |
+
context = MagicMock(spec=Context)
|
| 13 |
+
|
| 14 |
+
context.debug = MagicMock()
|
| 15 |
+
context.info = MagicMock()
|
| 16 |
+
context.error = MagicMock()
|
| 17 |
+
context.request_context.lifespan_context.api = AsyncMock()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
return context
|
| 21 |
+
|
| 22 |
+
@pytest.fixture
|
| 23 |
+
def mock_search_result():
|
| 24 |
+
"""Create a mock search result for testing"""
|
| 25 |
+
question = StackOverflowQuestion(
|
| 26 |
+
question_id=12345,
|
| 27 |
+
title="Test Question",
|
| 28 |
+
body="Test body",
|
| 29 |
+
score=10,
|
| 30 |
+
answer_count=2,
|
| 31 |
+
is_answered=True,
|
| 32 |
+
accepted_answer_id=54321,
|
| 33 |
+
creation_date=1609459200,
|
| 34 |
+
tags=["python", "testing"],
|
| 35 |
+
link="https://stackoverflow.com/q/12345"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
answer = StackOverflowAnswer(
|
| 39 |
+
answer_id=54321,
|
| 40 |
+
question_id=12345,
|
| 41 |
+
score=5,
|
| 42 |
+
is_accepted=True,
|
| 43 |
+
body="Test answer",
|
| 44 |
+
creation_date=1609459300,
|
| 45 |
+
link="https://stackoverflow.com/a/54321"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
return SearchResult(
|
| 49 |
+
question=question,
|
| 50 |
+
answers=[answer],
|
| 51 |
+
comments=None
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
@pytest.mark.asyncio
|
| 55 |
+
async def test_search_by_query(mock_context, mock_search_result):
|
| 56 |
+
"""Test search by query function"""
|
| 57 |
+
mock_context.request_context.lifespan_context.api.search_by_query.return_value = [mock_search_result]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
result = await search_by_query(
|
| 61 |
+
query="test query",
|
| 62 |
+
tags=["python"],
|
| 63 |
+
min_score=5,
|
| 64 |
+
include_comments=False,
|
| 65 |
+
response_format="markdown",
|
| 66 |
+
limit=5,
|
| 67 |
+
ctx=mock_context
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
mock_context.request_context.lifespan_context.api.search_by_query.assert_called_once_with(
|
| 71 |
+
query="test query",
|
| 72 |
+
tags=["python"],
|
| 73 |
+
min_score=5,
|
| 74 |
+
limit=5,
|
| 75 |
+
include_comments=False
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
assert "Test Question" in result
|
| 79 |
+
|
| 80 |
+
@pytest.mark.asyncio
|
| 81 |
+
async def test_search_by_error(mock_context, mock_search_result):
|
| 82 |
+
"""Test search by error function"""
|
| 83 |
+
mock_context.request_context.lifespan_context.api.search_by_query.return_value = [mock_search_result]
|
| 84 |
+
|
| 85 |
+
result = await search_by_error(
|
| 86 |
+
error_message="test error",
|
| 87 |
+
language="python",
|
| 88 |
+
technologies=["django"],
|
| 89 |
+
min_score=5,
|
| 90 |
+
include_comments=False,
|
| 91 |
+
response_format="markdown",
|
| 92 |
+
limit=5,
|
| 93 |
+
ctx=mock_context
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
mock_context.request_context.lifespan_context.api.search_by_query.assert_called_once_with(
|
| 97 |
+
query="test error",
|
| 98 |
+
tags=["python", "django"],
|
| 99 |
+
min_score=5,
|
| 100 |
+
limit=5,
|
| 101 |
+
include_comments=False
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
assert "Test Question" in result
|
| 105 |
+
|
| 106 |
+
@pytest.mark.asyncio
|
| 107 |
+
async def test_get_question(mock_context, mock_search_result):
|
| 108 |
+
"""Test get question function"""
|
| 109 |
+
mock_context.request_context.lifespan_context.api.get_question.return_value = mock_search_result
|
| 110 |
+
|
| 111 |
+
result = await get_question(
|
| 112 |
+
question_id=12345,
|
| 113 |
+
include_comments=True,
|
| 114 |
+
response_format="markdown",
|
| 115 |
+
ctx=mock_context
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
mock_context.request_context.lifespan_context.api.get_question.assert_called_once_with(
|
| 119 |
+
question_id=12345,
|
| 120 |
+
include_comments=True
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
assert "Test Question" in result
|
| 124 |
+
|
| 125 |
+
@pytest.mark.asyncio
|
| 126 |
+
async def test_analyze_stack_trace(mock_context, mock_search_result):
|
| 127 |
+
"""Test analyze stack trace function"""
|
| 128 |
+
mock_context.request_context.lifespan_context.api.search_by_query.return_value = [mock_search_result]
|
| 129 |
+
|
| 130 |
+
result = await analyze_stack_trace(
|
| 131 |
+
stack_trace="Error: Something went wrong\n at Function.Module._resolveFilename",
|
| 132 |
+
language="javascript",
|
| 133 |
+
include_comments=True,
|
| 134 |
+
response_format="markdown",
|
| 135 |
+
limit=3,
|
| 136 |
+
ctx=mock_context
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
mock_context.request_context.lifespan_context.api.search_by_query.assert_called_once_with(
|
| 140 |
+
query="Error: Something went wrong",
|
| 141 |
+
tags=["javascript"],
|
| 142 |
+
min_score=0,
|
| 143 |
+
limit=3,
|
| 144 |
+
include_comments=True
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
assert "Test Question" in result
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|