File size: 13,944 Bytes
a6bfba7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
import asyncio
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import AsyncIterator, List, Optional, Dict, Any
from datetime import datetime

from mcp.server.fastmcp import FastMCP, Context
# Instead of importing Error from mcp.server.fastmcp.tools, we'll define our own Error class
# or we can use standard exceptions for now

from .api import StackExchangeAPI
from .types import (
    SearchByQueryInput,
    SearchByErrorInput,
    GetQuestionInput,
    AdvancedSearchInput,
    SearchResult
)

from .formatter import format_response
from .env import STACK_EXCHANGE_API_KEY

@dataclass
class AppContext:
    api: StackExchangeAPI

@asynccontextmanager
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
    """Manage application lifecycle with the Stack Exchange API client.

    Args:
        server (FastMCP): The FastMCP server instance

    Returns:
        AsyncIterator[AppContext]: Context containing the API client
    """
    
    api = StackExchangeAPI(
        api_key=STACK_EXCHANGE_API_KEY,
    )
    try:
        yield AppContext(api=api)
    finally:
        await api.close()
        
mcp = FastMCP(
    "Stack Overflow MCP",
    lifespan=app_lifespan,
    dependencies=["httpx", "python-dotenv"]
)

@mcp.tool()
async def advanced_search(
    query: Optional[str] = None,
    tags: Optional[List[str]] = None,
    excluded_tags: Optional[List[str]] = None,
    min_score: Optional[int] = None,
    title: Optional[str] = None,
    body: Optional[str] = None,
    answers: Optional[int] = None,
    has_accepted_answer: Optional[bool] = None,
    views: Optional[int] = None,
    url: Optional[str] = None,
    user_id: Optional[int] = None,
    is_closed: Optional[bool] = None,
    is_wiki: Optional[bool] = None,
    is_migrated: Optional[bool] = None,
    has_notice: Optional[bool] = None,
    from_date: Optional[datetime] = None,
    to_date: Optional[datetime] = None,
    sort_by: Optional[str] = "votes",
    include_comments: Optional[bool] = False,
    response_format: Optional[str] = "markdown",
    limit: Optional[int] = 5,
    ctx: Context = None
) -> str:
    """Advanced search for Stack Overflow questions with many filter options.
    
    Args:
        query (Optional[str]): Free-form search query
        tags (Optional[List[str]]): List of tags to filter by
        excluded_tags (Optional[List[str]]): List of tags to exclude
        min_score (Optional[int]): Minimum score threshold
        title (Optional[str]): Text that must appear in the title
        body (Optional[str]): Text that must appear in the body
        answers (Optional[int]): Minimum number of answers
        has_accepted_answer (Optional[bool]): Whether questions must have an accepted answer
        views (Optional[int]): Minimum number of views
        url (Optional[str]): URL that must be contained in the post
        user_id (Optional[int]): ID of the user who must own the questions
        is_closed (Optional[bool]): Whether to return only closed or open questions
        is_wiki (Optional[bool]): Whether to return only community wiki questions
        is_migrated (Optional[bool]): Whether to return only migrated questions
        has_notice (Optional[bool]): Whether to return only questions with post notices
        from_date (Optional[datetime]): Earliest creation date
        to_date (Optional[datetime]): Latest creation date
        sort_by (Optional[str]): Field to sort by (activity, creation, votes, relevance)
        include_comments (Optional[bool]): Whether to include comments in results
        response_format (Optional[str]): Format of response ("json" or "markdown")
        limit (Optional[int]): Maximum number of results to return
        ctx (Context): The context is passed automatically by the MCP
        
    Returns:
        str: Formatted search results
    """
    try:
        api = ctx.request_context.lifespan_context.api
        
        ctx.debug(f"Performing advanced search on Stack Overflow")
        if query:
            ctx.debug(f"Query: {query}")
        if body:
            ctx.debug(f"Body: {body}")
        if tags:
            ctx.debug(f"Tags: {', '.join(tags)}")
        if excluded_tags:
            ctx.debug(f"Excluded tags: {', '.join(excluded_tags)}")
        
        results = await api.advanced_search(
            query=query,
            tags=tags,
            excluded_tags=excluded_tags,
            min_score=min_score,
            title=title,
            body=body,
            answers=answers,
            has_accepted_answer=has_accepted_answer,
            views=views,
            url=url,
            user_id=user_id,
            is_closed=is_closed,
            is_wiki=is_wiki,
            is_migrated=is_migrated,
            has_notice=has_notice,
            from_date=from_date,
            to_date=to_date,
            sort_by=sort_by,
            limit=limit,
            include_comments=include_comments
        )
        
        ctx.debug(f"Found {len(results)} results")
        
        return format_response(results, response_format)
    
    except Exception as e:
        ctx.error(f"Error performing advanced search on Stack Overflow: {str(e)}")
        raise RuntimeError(f"Failed to search Stack Overflow: {str(e)}")

@mcp.tool()
async def search_by_query(
    query: str,
    tags: Optional[List[str]] = None,
    excluded_tags: Optional[List[str]] = None,
    min_score: Optional[int] = None,
    title: Optional[str] = None,
    body: Optional[str] = None,
    has_accepted_answer: Optional[bool] = None,
    answers: Optional[int] = None,
    sort_by: Optional[str] = "votes",
    include_comments: Optional[bool] = False,
    response_format: Optional[str] = "markdown",
    limit: Optional[int] = 5,
    ctx: Context = None 
) -> str:
    """Search Stack Overflow for questions matching a query.

    Args:
        query (str): The search query
        tags (Optional[List[str]]): Optional list of tags to filter by (e.g., ["python", "pandas"])
        excluded_tags (Optional[List[str]]): Optional list of tags to exclude
        min_score (Optional[int]): Minimum score threshold for questions
        title (Optional[str]): Text that must appear in the title
        body (Optional[str]): Text that must appear in the body
        has_accepted_answer (Optional[bool]): Whether questions must have an accepted answer
        answers (Optional[int]): Minimum number of answers
        sort_by (Optional[str]): Field to sort by (activity, creation, votes, relevance)
        include_comments (Optional[bool]): Whether to include comments in results
        response_format (Optional[str]): Format of response ("json" or "markdown")
        limit (Optional[int]): Maximum number of results to return
        ctx (Context): The context is passed automatically by the MCP

    Returns:
        str: Formatted search results
    """
    try:
        api = ctx.request_context.lifespan_context.api
        
        ctx.debug(f"Searching Stack Overflow for: {query}")
        
        if tags: 
            ctx.debug(f"Filtering by tags: {', '.join(tags)}")
        if excluded_tags:
            ctx.debug(f"Excluding tags: {', '.join(excluded_tags)}")
        
        results = await api.search_by_query(
            query=query,
            tags=tags,
            excluded_tags=excluded_tags,
            min_score=min_score,
            title=title,
            body=body,
            has_accepted_answer=has_accepted_answer,
            answers=answers,
            sort_by=sort_by,
            limit=limit,
            include_comments=include_comments
        )
        
        ctx.debug(f"Found {len(results)} results")
        
        return format_response(results, response_format)
    
    except Exception as e:
        ctx.error(f"Error searching Stack Overflow: {str(e)}")
        raise RuntimeError(f"Failed to search Stack Overflow: {str(e)}")


@mcp.tool()
async def search_by_error(
    error_message: str,
    language: Optional[str] = None,
    technologies: Optional[List[str]] = None,
    excluded_tags: Optional[List[str]] = None,
    min_score: Optional[int] = None,
    has_accepted_answer: Optional[bool] = None,
    answers: Optional[int] = None,
    include_comments: Optional[bool] = False,
    response_format: Optional[str] = "markdown",
    limit: Optional[int] = 5,
    ctx: Context = None
) -> str:
    """Search Stack Overflow for solutions to an error message

    Args:
        error_message (str): The error message to search for
        language (Optional[str]): Programming language (e.g., "python", "javascript")
        technologies (Optional[List[str]]): Related technologies (e.g., ["react", "django"])
        excluded_tags (Optional[List[str]]): Optional list of tags to exclude
        min_score (Optional[int]): Minimum score threshold for questions
        has_accepted_answer (Optional[bool]): Whether questions must have an accepted answer
        answers (Optional[int]): Minimum number of answers
        include_comments (Optional[bool]): Whether to include comments in results
        response_format (Optional[str]): Format of response ("json" or "markdown")
        limit (Optional[int]): Maximum number of results to return
        ctx (Context): The context is passed automatically by the MCP

    Returns:
        str: Formatted search results
    """
    try:
        api = ctx.request_context.lifespan_context.api
        
        tags = []
        if language:
            tags.append(language.lower())
        if technologies:
            tags.extend([t.lower() for t in technologies])
            
        ctx.debug(f"Searching Stack Overflow for error: {error_message}")
        
        if tags:
            ctx.debug(f"Using tags: {', '.join(tags)}")
        if excluded_tags:
            ctx.debug(f"Excluding tags: {', '.join(excluded_tags)}")
        
        results = await api.search_by_query(
            query=error_message,
            tags=tags if tags else None,
            excluded_tags=excluded_tags,
            min_score=min_score,
            has_accepted_answer=has_accepted_answer,
            answers=answers,
            limit=limit,
            include_comments=include_comments
        )
        ctx.debug(f"Found {len(results)} results")
        
        return format_response(results, response_format)
    except Exception as e: 
        ctx.error(f"Error searching Stack Overflow: {str(e)}")
        raise RuntimeError(f"Failed to search Stack Overflow: {str(e)}")
    
@mcp.tool()
async def get_question(
    question_id: int,
    include_comments: Optional[bool] = True,
    response_format: Optional[str] = "markdown",
    ctx: Context = None
) -> str:
    """Get a specific Stack Overflow question by ID.

    Args:
        question_id (int): The Stack Overflow question ID
        include_comments (Optional[bool]): Whether to include comments in results
        response_format (Optional[str]): Format of response ("json" or "markdown")
        ctx (Context): The context is passed automatically by the MCP

    Returns:
        str: Formatted question details
    """
    try:
        api = ctx.request_context.lifespan_context.api 
        
        ctx.debug(f"Fetching Stack Overflow question: {question_id}")
        
        result = await api.get_question(
            question_id=question_id,
            include_comments=include_comments
        )
        
        return format_response([result], response_format)
    
    except Exception as e:
        ctx.error(f"Error fetching Stack Overflow question: {str(e)}")
        raise RuntimeError(f"Failed to fetch Stack Overflow question: {str(e)}")

@mcp.tool()
async def analyze_stack_trace(
    stack_trace: str,
    language: str,
    excluded_tags: Optional[List[str]] = None,
    min_score: Optional[int] = None,
    has_accepted_answer: Optional[bool] = None,
    answers: Optional[int] = None,
    include_comments: Optional[bool] = True,
    response_format: Optional[str] = "markdown",
    limit: Optional[int] = 3,
    ctx: Context = None
) -> str:
    """Analyze a stack trace and find relevant solutions on Stack Overflow.
    
    Args:
        stack_trace (str): The stack trace to analyze
        language (str): Programming language of the stack trace
        excluded_tags (Optional[List[str]]): Optional list of tags to exclude
        min_score (Optional[int]): Minimum score threshold for questions
        has_accepted_answer (Optional[bool]): Whether questions must have an accepted answer
        answers (Optional[int]): Minimum number of answers
        include_comments (Optional[bool]): Whether to include comments in results
        response_format (Optional[str]): Format of response ("json" or "markdown")
        limit (Optional[int]): Maximum number of results to return
        ctx (Context): The context is passed automatically by the MCP
        
    Returns:
        str: Formatted search results
    """
    try:
        api = ctx.request_context.lifespan_context.api
        
        error_lines = stack_trace.split("\n")
        error_message = error_lines[0]
        
        ctx.debug(f"Analyzing stack trace: {error_message}")
        ctx.debug(f"Language: {language}")
        
        results = await api.search_by_query(
            query=error_message,
            tags=[language.lower()],
            excluded_tags=excluded_tags,
            min_score=min_score,
            has_accepted_answer=has_accepted_answer,
            answers=answers,
            limit=limit,
            include_comments=include_comments
        )
        
        ctx.debug(f"Found {len(results)} results")
        
        return format_response(results, response_format)
    except Exception as e:
        ctx.error(f"Error analyzing stack trace: {str(e)}")
        raise RuntimeError(f"Failed to analyze stack trace: {str(e)}")

if __name__ == "__main__":
    mcp.run()