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