-
-
Notifications
You must be signed in to change notification settings - Fork 144
Add GitHub MCP microservice for repository queries #131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
e90de9c
60916e0
ea97cbe
cf4c24e
ff48364
28e4e48
c52a4bf
a247ba9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,12 @@ | ||
| import logging | ||
| import os | ||
| from typing import Dict, Any | ||
| from langchain_google_genai import ChatGoogleGenerativeAI | ||
| from langchain_core.messages import HumanMessage | ||
| from app.core.config import settings | ||
| from .prompts.intent_analysis import GITHUB_INTENT_ANALYSIS_PROMPT | ||
| from .tools.search import handle_web_search | ||
| from .tools.github_support import handle_github_supp | ||
| # TODO: Implement all tools | ||
| from .tools.contributor_recommendation import handle_contributor_recommendation | ||
| # from .tools.repository_query import handle_repo_query | ||
|
|
@@ -13,6 +15,16 @@ | |
| from .tools.general_github_help import handle_general_github_help | ||
| logger = logging.getLogger(__name__) | ||
|
|
||
| DEFAULT_ORG = os.getenv("GITHUB_ORG") | ||
|
|
||
| def normalize_org(org_from_user: str = None) -> str: | ||
| """ | ||
| Always fallback to env org if user does not specify one. | ||
| """ | ||
| if org_from_user and org_from_user.strip(): | ||
| return org_from_user.strip() | ||
| return DEFAULT_ORG | ||
|
|
||
|
|
||
| class GitHubToolkit: | ||
| """ | ||
|
|
@@ -32,6 +44,7 @@ def __init__(self): | |
| "web_search", | ||
| "contributor_recommendation", | ||
| "repo_support", | ||
| "github_support", | ||
| "issue_creation", | ||
| "documentation_generation", | ||
| "find_good_first_issues", | ||
|
|
@@ -55,7 +68,25 @@ async def classify_intent(self, user_query: str) -> Dict[str, Any]: | |
| response = await self.llm.ainvoke([HumanMessage(content=prompt)]) | ||
|
|
||
| import json | ||
| result = json.loads(response.content.strip()) | ||
| import re | ||
|
|
||
| content = response.content.strip() | ||
|
|
||
| candidates = [] | ||
| cb = re.search(r'```(?:json)?\s*({[\s\S]*?})\s*```', content, flags=re.IGNORECASE) | ||
| if cb: | ||
| candidates.append(cb.group(1)) | ||
| candidates.extend(m.group(0) for m in re.finditer(r'\{[\s\S]*?\}', content)) | ||
|
|
||
| result = None | ||
| for payload in candidates: | ||
| try: | ||
| result = json.loads(payload) | ||
| break | ||
| except json.JSONDecodeError: | ||
| continue | ||
| if result is None: | ||
| raise json.JSONDecodeError("No valid JSON object found in LLM response", content, 0) | ||
|
||
|
|
||
| classification = result.get("classification") | ||
| if classification not in self.tools: | ||
|
|
@@ -103,6 +134,10 @@ async def execute(self, query: str) -> Dict[str, Any]: | |
|
|
||
| if classification == "contributor_recommendation": | ||
| result = await handle_contributor_recommendation(query) | ||
| elif classification == "github_support": | ||
| org = normalize_org() | ||
| result = await handle_github_supp(query, org=org) | ||
| result["org_used"] = org | ||
| elif classification == "repo_support": | ||
| result = "Not implemented" | ||
| # result = await handle_repo_query(query) | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,105 @@ | ||||||||||||||||
| import logging | ||||||||||||||||
| import os | ||||||||||||||||
| from typing import Dict, Any, Optional, List, Union | ||||||||||||||||
| import aiohttp | ||||||||||||||||
| import asyncio | ||||||||||||||||
|
|
||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||
|
|
||||||||||||||||
| class GitHubMCPClient: | ||||||||||||||||
| """ | ||||||||||||||||
| Client for communicating with the GitHub MCP server. | ||||||||||||||||
| """ | ||||||||||||||||
|
|
||||||||||||||||
| def __init__(self, mcp_server_url: str = "http://localhost:8001"): | ||||||||||||||||
| self.mcp_server_url = mcp_server_url | ||||||||||||||||
| self.session: Optional[aiohttp.ClientSession] = None | ||||||||||||||||
| # Default org pulled from environment | ||||||||||||||||
| self.org = os.getenv("GITHUB_ORG", "Aossie-org") | ||||||||||||||||
|
|
||||||||||||||||
smokeyScraper marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||
| async def __aenter__(self): | ||||||||||||||||
| # Async context manager entry | ||||||||||||||||
| self.session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) | ||||||||||||||||
| return self | ||||||||||||||||
|
|
||||||||||||||||
| async def __aexit__(self, exc_type, exc_val, exc_tb): | ||||||||||||||||
| # Async context manager exit | ||||||||||||||||
| if self.session: | ||||||||||||||||
| await self.session.close() | ||||||||||||||||
|
|
||||||||||||||||
| async def get_github_supp(self, repo: str, owner: Optional[str] = None) -> Dict[str, Any]: | ||||||||||||||||
| """ | ||||||||||||||||
| Fetch metadata for a single repository. | ||||||||||||||||
| Owner defaults to org from environment if not provided. | ||||||||||||||||
| """ | ||||||||||||||||
| if not self.session: | ||||||||||||||||
| raise RuntimeError("Client not initialized. Use async context manager.") | ||||||||||||||||
|
|
||||||||||||||||
| owner = owner or self.org | ||||||||||||||||
|
|
||||||||||||||||
| try: | ||||||||||||||||
| payload = {"owner": owner, "repo": repo} | ||||||||||||||||
|
|
||||||||||||||||
| async with self.session.post( | ||||||||||||||||
| f"{self.mcp_server_url}/github_support", | ||||||||||||||||
| json=payload, | ||||||||||||||||
| headers={"Content-Type": "application/json"}, | ||||||||||||||||
| ) as response: | ||||||||||||||||
| if response.status == 200: | ||||||||||||||||
| result = await response.json() | ||||||||||||||||
| if result.get("status") == "success": | ||||||||||||||||
| return result.get("data", {}) | ||||||||||||||||
| else: | ||||||||||||||||
| return {"error": result.get("error", "Unknown error")} | ||||||||||||||||
| else: | ||||||||||||||||
| logger.error(f"MCP server error: {response.status}") | ||||||||||||||||
| return {"error": f"MCP server error: {response.status}"} | ||||||||||||||||
|
|
||||||||||||||||
| except aiohttp.ClientError as e: | ||||||||||||||||
| logger.exception("Error communicating with MCP server: %s", e) | ||||||||||||||||
| return {"error": f"Communication error: {str(e)}"} | ||||||||||||||||
| except Exception as e: | ||||||||||||||||
| logger.exception("Unexpected error: %s", e) | ||||||||||||||||
| return {"error": f"Unexpected error: {str(e)}"} | ||||||||||||||||
|
|
||||||||||||||||
| async def list_org_repos(self, org: str) -> Union[List[Dict[str, Any]], Dict[str, Any]]: | ||||||||||||||||
| if not self.session: | ||||||||||||||||
| raise RuntimeError("Client not initialized. Use async context manager.") | ||||||||||||||||
|
|
||||||||||||||||
| try: | ||||||||||||||||
| payload = {"org": org} | ||||||||||||||||
| async with self.session.post( | ||||||||||||||||
| f"{self.mcp_server_url}/list_org_repos", | ||||||||||||||||
| json=payload, | ||||||||||||||||
| headers={"Content-Type": "application/json"}, | ||||||||||||||||
| ) as response: | ||||||||||||||||
| if response.status == 200: | ||||||||||||||||
| result = await response.json() | ||||||||||||||||
| if result.get("status") == "success": | ||||||||||||||||
| return result.get("data", []) | ||||||||||||||||
| else: | ||||||||||||||||
| return {"error": result.get("error", "Unknown error")} | ||||||||||||||||
| else: | ||||||||||||||||
| logger.error(f"MCP server error: {response.status}") | ||||||||||||||||
| return {"error": f"MCP server error: {response.status}"} | ||||||||||||||||
| except aiohttp.ClientError as e: | ||||||||||||||||
| logger.error(f"Error communicating with MCP server: {e}") | ||||||||||||||||
| return {"error": f"Communication error: {str(e)}"} | ||||||||||||||||
| except Exception as e: | ||||||||||||||||
| logger.error(f"Unexpected error: {e}") | ||||||||||||||||
| return {"error": f"Unexpected error: {str(e)}"} | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| async def is_server_available(self) -> bool: | ||||||||||||||||
| """ | ||||||||||||||||
| Health check for MCP server. | ||||||||||||||||
| """ | ||||||||||||||||
| if not self.session: | ||||||||||||||||
| return False | ||||||||||||||||
|
|
||||||||||||||||
| try: | ||||||||||||||||
| async with self.session.get(f"{self.mcp_server_url}/health", timeout=5) as response: | ||||||||||||||||
| return response.status == 200 | ||||||||||||||||
|
Comment on lines
+97
to
+98
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Request timeout misuse: aiohttp expects ClientTimeout, not an int. Passing timeout=5 raises TypeError at runtime. Use ClientTimeout or rely on the session default. - async with self.session.get(f"{self.mcp_server_url}/health", timeout=5) as response:
+ async with self.session.get(
+ f"{self.mcp_server_url}/health",
+ timeout=aiohttp.ClientTimeout(total=5),
+ ) as response:
return response.status == 200📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
| except (aiohttp.ClientError, asyncio.TimeoutError) as e: | ||||||||||||||||
| logger.debug(f"Health check failed: {e}") | ||||||||||||||||
| return False | ||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import os | ||
| import logging | ||
| import asyncio | ||
| from dotenv import load_dotenv, find_dotenv | ||
| from fastapi import FastAPI, HTTPException | ||
| from pydantic import BaseModel | ||
| from .github_mcp_service import GitHubMCPService | ||
| from typing import Optional | ||
|
|
||
| dotenv_path = find_dotenv(usecwd=True) | ||
| if dotenv_path: | ||
| load_dotenv(dotenv_path=dotenv_path) | ||
| else: | ||
| load_dotenv() | ||
|
|
||
| logging.basicConfig(level=logging.INFO) | ||
| logger = logging.getLogger(__name__) | ||
|
|
||
| app = FastAPI(title="GitHub MCP Server", version="1.0.0") | ||
|
|
||
| # Load env vars | ||
| GITHUB_ORG = os.getenv("GITHUB_ORG") | ||
| if not GITHUB_ORG: | ||
| logger.warning("GITHUB_ORG not set in .env — defaulting to manual owner input") | ||
|
|
||
| github_service: Optional[GitHubMCPService] = None | ||
| try: | ||
| token = os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN") | ||
| if not token: | ||
| logger.warning("GITHUB_TOKEN/GH_TOKEN not set; GitHub API calls may be rate-limited or fail.") | ||
| github_service = GitHubMCPService(token=token) | ||
| logger.info("GitHub service initialized successfully") | ||
| except Exception as e: | ||
| logger.exception("Failed to initialize GitHub service") | ||
| github_service = None | ||
|
|
||
| class RepoInfoRequest(BaseModel): | ||
| repo: str | ||
| owner: Optional[str] = None | ||
|
|
||
| class RepoInfoResponse(BaseModel): | ||
| status: str | ||
| data: dict | ||
| error: str = None | ||
|
|
||
| @app.get("/health") | ||
| async def health_check(): | ||
| """Health check endpoint""" | ||
| return {"status": "healthy", "service": "github-mcp"} | ||
|
|
||
| class OrgInfoRequest(BaseModel): | ||
| org: str | ||
|
|
||
| @app.post("/list_org_repos") | ||
| async def list_org_repos(request: OrgInfoRequest): | ||
| try: | ||
| if not github_service: | ||
| raise HTTPException(status_code=500, detail="GitHub service not available") | ||
|
|
||
| result = github_service.list_org_repos(request.org) | ||
|
|
||
| if "error" in result: | ||
| return {"status": "error", "data": {}, "error": result["error"]} | ||
|
|
||
| return {"status": "success", "data": result} | ||
|
|
||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| except Exception as e: | ||
| logger.exception("Error listing org repos") | ||
| raise HTTPException(status_code=500, detail=str(e)) | ||
|
|
||
| @app.post("/github_support") | ||
| async def get_github_supp(request: RepoInfoRequest): | ||
| """Get repo details, using fixed org from env""" | ||
| if not github_service: | ||
| raise HTTPException(status_code=503, detail="GitHub service not available") | ||
| owner = request.owner or GITHUB_ORG | ||
| if not owner: | ||
| raise HTTPException(status_code=400, detail="Missing owner; provide 'owner' or set GITHUB_ORG") | ||
|
|
||
| try: | ||
| result = await asyncio.to_thread(github_service.repo_query, owner, request.repo) | ||
| if "error" in result: | ||
| return RepoInfoResponse(status="error", data={}, error=result["error"]) | ||
| return RepoInfoResponse(status="success", data=result) | ||
| except Exception as e: | ||
| logger.exception("Error getting repo info") | ||
| raise HTTPException(status_code=500, detail=str(e)) | ||
|
|
||
| if __name__ == "__main__": | ||
| import uvicorn | ||
| uvicorn.run(app, host="0.0.0.0", port=8001) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DEFAULT_ORG may be None; align env keys and guarantee a string.
Make DEFAULT_ORG robust and consistent with .env.
📝 Committable suggestion
🧰 Tools
🪛 Ruff (0.12.2)
20-20: PEP 484 prohibits implicit
OptionalConvert to
T | None(RUF013)
🤖 Prompt for AI Agents