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
|
|