Spaces:
Sleeping
Sleeping
Initial commit
Browse files- Dockerfile +10 -0
- README.md +139 -14
- __init__.py +0 -0
- app.py +82 -0
- github_client.py +51 -0
- main.py +36 -0
- models.py +20 -0
- requirements.txt +6 -0
Dockerfile
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM python:3.9-slim
|
2 |
+
|
3 |
+
WORKDIR /code
|
4 |
+
|
5 |
+
COPY ./requirements.txt /code/requirements.txt
|
6 |
+
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
7 |
+
|
8 |
+
COPY ./ /code/
|
9 |
+
|
10 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
@@ -1,14 +1,139 @@
|
|
1 |
-
---
|
2 |
-
title:
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
-
colorTo:
|
6 |
-
sdk: gradio
|
7 |
-
sdk_version: 5.33.1
|
8 |
-
app_file: app.py
|
9 |
-
pinned: false
|
10 |
-
|
11 |
-
|
12 |
-
---
|
13 |
-
|
14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
---
|
2 |
+
title: MCP GitHub PR Opportunity Server
|
3 |
+
emoji: 🔍
|
4 |
+
colorFrom: blue
|
5 |
+
colorTo: indigo
|
6 |
+
sdk: gradio
|
7 |
+
sdk_version: 5.33.1
|
8 |
+
app_file: app.py
|
9 |
+
pinned: false
|
10 |
+
tag: mcp-server-track
|
11 |
+
license: mit
|
12 |
+
---
|
13 |
+
|
14 |
+
# MCP GitHub PR Opportunity Server
|
15 |
+
|
16 |
+
A Gradio-based MCP server that searches GitHub repositories for possible PR opportunities (open issues labeled 'good first issue' or 'help wanted'), supporting search by keyword and topic, with authentication.
|
17 |
+
|
18 |
+
## Features
|
19 |
+
- Search GitHub repos by keyword and topic
|
20 |
+
- Find open issues labeled 'good first issue' or 'help wanted'
|
21 |
+
- Supports GitHub authentication via token
|
22 |
+
- Gradio MCP server for LLM integration
|
23 |
+
- Interactive web interface
|
24 |
+
- Compatible with n8n workflows
|
25 |
+
|
26 |
+
## Local Setup
|
27 |
+
|
28 |
+
1. **Clone the repo and install dependencies:**
|
29 |
+
```bash
|
30 |
+
cd mcp_github_server
|
31 |
+
pip install -r requirements.txt
|
32 |
+
```
|
33 |
+
|
34 |
+
2. **Configure GitHub Token:**
|
35 |
+
- Set `GITHUB_TOKEN` in your environment variables, or
|
36 |
+
- Enter the token in the web interface
|
37 |
+
|
38 |
+
3. **Run the server:**
|
39 |
+
```bash
|
40 |
+
python app.py
|
41 |
+
```
|
42 |
+
|
43 |
+
## Hugging Face Spaces Deployment
|
44 |
+
|
45 |
+
1. **Create a new Space:**
|
46 |
+
- Go to [Hugging Face Spaces](https://huggingface.co/spaces)
|
47 |
+
- Click "Create new Space"
|
48 |
+
- Choose "Gradio" as the SDK
|
49 |
+
- Name your space (e.g., "mcp-github-pr-server")
|
50 |
+
|
51 |
+
2. **Configure Environment Variables:**
|
52 |
+
- In your Space settings, add `GITHUB_TOKEN` as a secret
|
53 |
+
- Set your GitHub token as the value
|
54 |
+
|
55 |
+
3. **Deploy:**
|
56 |
+
- Push your code to the Space repository
|
57 |
+
- Hugging Face will automatically build and deploy your application
|
58 |
+
|
59 |
+
## Using with MCP Clients
|
60 |
+
|
61 |
+
Add this configuration to your MCP client (e.g., Claude Desktop, Cursor, or Cline):
|
62 |
+
|
63 |
+
```json
|
64 |
+
{
|
65 |
+
"mcpServers": {
|
66 |
+
"github-pr-finder": {
|
67 |
+
"url": "https://akinyemiar-mcp-github-pr-server.hf.space/gradio_api/mcp/sse"
|
68 |
+
}
|
69 |
+
}
|
70 |
+
}
|
71 |
+
```
|
72 |
+
|
73 |
+
## Using with n8n
|
74 |
+
|
75 |
+
1. **Add HTTP Request Node:**
|
76 |
+
- In your n8n workflow, add an "HTTP Request" node
|
77 |
+
- Set the URL to your Hugging Face Space URL (e.g., `https://akinyemiar-mcp-github-pr-server.hf.space/api/predict`)
|
78 |
+
- Set Method to POST
|
79 |
+
- Add Headers:
|
80 |
+
```
|
81 |
+
Content-Type: application/json
|
82 |
+
```
|
83 |
+
- Set Body to JSON:
|
84 |
+
```json
|
85 |
+
{
|
86 |
+
"data": [
|
87 |
+
"fastapi",
|
88 |
+
"python",
|
89 |
+
5,
|
90 |
+
1,
|
91 |
+
"YOUR_GITHUB_TOKEN"
|
92 |
+
]
|
93 |
+
}
|
94 |
+
```
|
95 |
+
|
96 |
+
2. **Process Response:**
|
97 |
+
- The response will contain opportunities and total_count
|
98 |
+
- Use n8n's "Set" node to process the response data
|
99 |
+
- Add additional nodes to handle the opportunities as needed
|
100 |
+
|
101 |
+
## API Usage
|
102 |
+
|
103 |
+
The server provides both a web interface and an API endpoint at `/api/predict`. The API accepts POST requests with the following parameters:
|
104 |
+
|
105 |
+
```json
|
106 |
+
{
|
107 |
+
"data": [
|
108 |
+
"keyword", // optional
|
109 |
+
"topic", // optional
|
110 |
+
"per_page", // default: 5
|
111 |
+
"page", // default: 1
|
112 |
+
"token" // GitHub token
|
113 |
+
]
|
114 |
+
}
|
115 |
+
```
|
116 |
+
|
117 |
+
Response:
|
118 |
+
```json
|
119 |
+
{
|
120 |
+
"data": [
|
121 |
+
{
|
122 |
+
"opportunities": [
|
123 |
+
{
|
124 |
+
"repo_name": "tiangolo/fastapi",
|
125 |
+
"repo_url": "https://github.com/tiangolo/fastapi",
|
126 |
+
"issue_title": "Add more examples",
|
127 |
+
"issue_url": "https://github.com/tiangolo/fastapi/issues/1234",
|
128 |
+
"issue_labels": ["good first issue"],
|
129 |
+
"issue_body": "Please add more examples to the docs..."
|
130 |
+
}
|
131 |
+
],
|
132 |
+
"total_count": 123
|
133 |
+
}
|
134 |
+
]
|
135 |
+
}
|
136 |
+
```
|
137 |
+
|
138 |
+
## License
|
139 |
+
MIT
|
__init__.py
ADDED
File without changes
|
app.py
ADDED
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gradio as gr
|
2 |
+
from github_client import GitHubClient
|
3 |
+
from models import SearchRequest, SearchResponse
|
4 |
+
import os
|
5 |
+
from typing import Optional, Dict, Any, List
|
6 |
+
|
7 |
+
|
8 |
+
def search_pr_opportunities(
|
9 |
+
keyword: Optional[str] = None,
|
10 |
+
topic: Optional[str] = None,
|
11 |
+
per_page: int = 5,
|
12 |
+
page: int = 1,
|
13 |
+
token: Optional[str] = None
|
14 |
+
) -> Dict[str, Any]:
|
15 |
+
"""Search GitHub for PR opportunities.
|
16 |
+
|
17 |
+
Args:
|
18 |
+
keyword: Keyword to search in repositories (optional)
|
19 |
+
topic: Topic to filter repositories (optional)
|
20 |
+
per_page: Number of results per page (default: 5)
|
21 |
+
page: Page number (default: 1)
|
22 |
+
token: GitHub token for authentication
|
23 |
+
|
24 |
+
Returns:
|
25 |
+
A dictionary containing opportunities and total count
|
26 |
+
"""
|
27 |
+
if not token:
|
28 |
+
token = os.getenv("GITHUB_TOKEN")
|
29 |
+
if not token:
|
30 |
+
raise gr.Error("GitHub token required in Authorization header or .env")
|
31 |
+
|
32 |
+
try:
|
33 |
+
client: GitHubClient = GitHubClient(token)
|
34 |
+
opportunities, total_count = client.find_pr_opportunities(
|
35 |
+
keyword=keyword,
|
36 |
+
topic=topic,
|
37 |
+
per_page=per_page,
|
38 |
+
page=page
|
39 |
+
)
|
40 |
+
|
41 |
+
# The return structure matches a dictionary format, so we type it as Dict[str, Any]
|
42 |
+
response: Dict[str, Any] = {
|
43 |
+
"opportunities": [
|
44 |
+
{
|
45 |
+
"repo_name": opp.repo_name,
|
46 |
+
"repo_url": opp.repo_url,
|
47 |
+
"issue_title": opp.issue_title,
|
48 |
+
"issue_url": opp.issue_url,
|
49 |
+
"issue_labels": opp.issue_labels,
|
50 |
+
"issue_body": opp.issue_body
|
51 |
+
} for opp in opportunities
|
52 |
+
],
|
53 |
+
"total_count": total_count
|
54 |
+
}
|
55 |
+
return response
|
56 |
+
except Exception as e:
|
57 |
+
raise gr.Error(str(e))
|
58 |
+
|
59 |
+
# Create the Gradio interface
|
60 |
+
demo: gr.Interface = gr.Interface(
|
61 |
+
fn=search_pr_opportunities,
|
62 |
+
inputs=[
|
63 |
+
gr.Textbox(label="Keyword", placeholder="e.g., fastapi"),
|
64 |
+
gr.Textbox(label="Topic", placeholder="e.g., python"),
|
65 |
+
gr.Slider(minimum=1, maximum=20, value=5, step=1, label="Results per page"),
|
66 |
+
gr.Slider(minimum=1, maximum=10, value=1, step=1, label="Page number"),
|
67 |
+
gr.Textbox(label="GitHub Token", type="password")
|
68 |
+
],
|
69 |
+
outputs=gr.JSON(label="Search Results"),
|
70 |
+
title="GitHub PR Opportunity Finder",
|
71 |
+
description="Search GitHub repositories for PR opportunities (issues labeled 'good first issue' or 'help wanted')",
|
72 |
+
examples=[
|
73 |
+
["fastapi", "python", 5, 1, ""],
|
74 |
+
["react", "javascript", 3, 1, ""],
|
75 |
+
["machine-learning", None, 2, 1, ""],
|
76 |
+
[None, "web-development", 4, 1, ""]
|
77 |
+
]
|
78 |
+
)
|
79 |
+
|
80 |
+
# Launch with MCP server enabled
|
81 |
+
if __name__ == "__main__":
|
82 |
+
demo.launch(mcp_server=True)
|
github_client.py
ADDED
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from github import Github
|
3 |
+
from typing import List, Optional
|
4 |
+
try:
|
5 |
+
from .models import PROpportunity
|
6 |
+
except ImportError:
|
7 |
+
from models import PROpportunity
|
8 |
+
|
9 |
+
HELP_LABELS = ["good first issue", "help wanted"]
|
10 |
+
|
11 |
+
class GitHubClient:
|
12 |
+
def __init__(self, token: Optional[str] = None):
|
13 |
+
self.token = token or os.getenv("GITHUB_TOKEN")
|
14 |
+
if not self.token:
|
15 |
+
raise ValueError("GitHub token must be provided via argument or GITHUB_TOKEN env var.")
|
16 |
+
self.client = Github(self.token)
|
17 |
+
|
18 |
+
def search_repositories(self, keyword: Optional[str], topic: Optional[str], per_page: int, page: int):
|
19 |
+
query = []
|
20 |
+
if keyword:
|
21 |
+
query.append(keyword)
|
22 |
+
if topic:
|
23 |
+
query.append(f"topic:{topic}")
|
24 |
+
query_str = " ".join(query)
|
25 |
+
return self.client.search_repositories(query_str, sort="stars", order="desc")
|
26 |
+
|
27 |
+
def find_pr_opportunities(self, keyword: Optional[str], topic: Optional[str], per_page: int = 10, page: int = 1) -> List[PROpportunity]:
|
28 |
+
repos = self.search_repositories(keyword, topic, per_page, page)
|
29 |
+
opportunities = []
|
30 |
+
count = 0
|
31 |
+
for repo in repos.get_page(page-1):
|
32 |
+
issues = repo.get_issues(state="open", labels=HELP_LABELS)
|
33 |
+
for issue in issues:
|
34 |
+
if issue.pull_request is not None:
|
35 |
+
continue # skip PRs
|
36 |
+
labels = [l.name for l in issue.labels]
|
37 |
+
if any(l.lower() in HELP_LABELS for l in labels):
|
38 |
+
opportunities.append(PROpportunity(
|
39 |
+
repo_name=repo.full_name,
|
40 |
+
repo_url=repo.html_url,
|
41 |
+
issue_title=issue.title,
|
42 |
+
issue_url=issue.html_url,
|
43 |
+
issue_labels=labels,
|
44 |
+
issue_body=issue.body
|
45 |
+
))
|
46 |
+
count += 1
|
47 |
+
if count >= per_page:
|
48 |
+
break
|
49 |
+
if count >= per_page:
|
50 |
+
break
|
51 |
+
return opportunities, repos.totalCount
|
main.py
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import FastAPI, Depends, HTTPException, Header
|
2 |
+
from typing import Optional
|
3 |
+
try:
|
4 |
+
from .models import SearchRequest, SearchResponse
|
5 |
+
from .github_client import GitHubClient
|
6 |
+
except ImportError:
|
7 |
+
from models import SearchRequest, SearchResponse
|
8 |
+
from github_client import GitHubClient
|
9 |
+
import os
|
10 |
+
|
11 |
+
app = FastAPI(title="MCP GitHub PR Opportunity Server")
|
12 |
+
|
13 |
+
def get_github_token(authorization: Optional[str] = Header(None)) -> str:
|
14 |
+
if authorization and authorization.startswith("token "):
|
15 |
+
return authorization.split(" ", 1)[1]
|
16 |
+
token = os.getenv("GITHUB_TOKEN")
|
17 |
+
if not token:
|
18 |
+
raise HTTPException(status_code=401, detail="GitHub token required in Authorization header or .env")
|
19 |
+
return token
|
20 |
+
|
21 |
+
@app.post("/search_pr_opportunities", response_model=SearchResponse)
|
22 |
+
def search_pr_opportunities(
|
23 |
+
request: SearchRequest,
|
24 |
+
token: str = Depends(get_github_token)
|
25 |
+
):
|
26 |
+
try:
|
27 |
+
client = GitHubClient(token)
|
28 |
+
opportunities, total_count = client.find_pr_opportunities(
|
29 |
+
keyword=request.keyword,
|
30 |
+
topic=request.topic,
|
31 |
+
per_page=request.per_page,
|
32 |
+
page=request.page
|
33 |
+
)
|
34 |
+
return SearchResponse(opportunities=opportunities, total_count=total_count)
|
35 |
+
except Exception as e:
|
36 |
+
raise HTTPException(status_code=500, detail=str(e))
|
models.py
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, Field
|
2 |
+
from typing import List, Optional
|
3 |
+
|
4 |
+
class SearchRequest(BaseModel):
|
5 |
+
keyword: Optional[str] = Field(None, description="Keyword to search in repositories.")
|
6 |
+
topic: Optional[str] = Field(None, description="Topic to filter repositories.")
|
7 |
+
per_page: int = Field(10, description="Results per page.")
|
8 |
+
page: int = Field(1, description="Page number.")
|
9 |
+
|
10 |
+
class PROpportunity(BaseModel):
|
11 |
+
repo_name: str
|
12 |
+
repo_url: str
|
13 |
+
issue_title: str
|
14 |
+
issue_url: str
|
15 |
+
issue_labels: List[str]
|
16 |
+
issue_body: Optional[str]
|
17 |
+
|
18 |
+
class SearchResponse(BaseModel):
|
19 |
+
opportunities: List[PROpportunity]
|
20 |
+
total_count: int
|
requirements.txt
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
gradio[mcp]
|
2 |
+
PyGithub
|
3 |
+
python-dotenv
|
4 |
+
pydantic
|
5 |
+
mcp
|
6 |
+
gradio
|