Skip to content

Commit 94e799b

Browse files
authored
feat: add GitHub resources, version tool, PAT priority (#4)
* feat: add manage_issues tool with bulk operations and template support * feat: add GitHub resources, version tool, PAT priority - Add MCP resources for GitHub (github://repositories, github://issue-templates) - Add get_mcp_version tool to check server version - Change auth priority: PAT first, GitHub App fallback - Add module-level client caching for GitHub - Version now reads from pyproject.toml (single source of truth) - Bump versions: MCP 0.3.4, plugin 0.6.3
1 parent 8e6c532 commit 94e799b

10 files changed

Lines changed: 523 additions & 38 deletions

File tree

mcp_server/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,10 @@
33
GitHub integration tools for AI assistant
44
"""
55

6-
__version__ = "0.1.8"
6+
from importlib.metadata import version, PackageNotFoundError
7+
8+
try:
9+
__version__ = version("quickcall-integrations")
10+
except PackageNotFoundError:
11+
# Package not installed (development mode)
12+
__version__ = "0.0.0-dev"

mcp_server/api_clients/github_client.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,114 @@ def list_branches(
578578

579579
return branches
580580

581+
# ========================================================================
582+
# Issue Operations
583+
# ========================================================================
584+
585+
def _issue_to_dict(self, issue) -> Dict[str, Any]:
586+
"""Convert PyGithub Issue to dict."""
587+
return {
588+
"number": issue.number,
589+
"title": issue.title,
590+
"body": issue.body,
591+
"state": issue.state,
592+
"html_url": issue.html_url,
593+
"labels": [label.name for label in issue.labels],
594+
"assignees": [a.login for a in issue.assignees],
595+
"created_at": issue.created_at.isoformat(),
596+
}
597+
598+
def create_issue(
599+
self,
600+
title: str,
601+
body: Optional[str] = None,
602+
labels: Optional[List[str]] = None,
603+
assignees: Optional[List[str]] = None,
604+
owner: Optional[str] = None,
605+
repo: Optional[str] = None,
606+
) -> Dict[str, Any]:
607+
"""Create a GitHub issue."""
608+
gh_repo = self._get_repo(owner, repo)
609+
issue = gh_repo.create_issue(
610+
title=title,
611+
body=body or "",
612+
labels=labels or [],
613+
assignees=assignees or [],
614+
)
615+
return self._issue_to_dict(issue)
616+
617+
def update_issue(
618+
self,
619+
issue_number: int,
620+
title: Optional[str] = None,
621+
body: Optional[str] = None,
622+
labels: Optional[List[str]] = None,
623+
assignees: Optional[List[str]] = None,
624+
owner: Optional[str] = None,
625+
repo: Optional[str] = None,
626+
) -> Dict[str, Any]:
627+
"""Update a GitHub issue."""
628+
gh_repo = self._get_repo(owner, repo)
629+
issue = gh_repo.get_issue(issue_number)
630+
631+
kwargs = {}
632+
if title is not None:
633+
kwargs["title"] = title
634+
if body is not None:
635+
kwargs["body"] = body
636+
if labels is not None:
637+
kwargs["labels"] = labels
638+
if assignees is not None:
639+
kwargs["assignees"] = assignees
640+
641+
if kwargs:
642+
issue.edit(**kwargs)
643+
644+
return self._issue_to_dict(issue)
645+
646+
def close_issue(
647+
self,
648+
issue_number: int,
649+
owner: Optional[str] = None,
650+
repo: Optional[str] = None,
651+
) -> Dict[str, Any]:
652+
"""Close a GitHub issue."""
653+
gh_repo = self._get_repo(owner, repo)
654+
issue = gh_repo.get_issue(issue_number)
655+
issue.edit(state="closed")
656+
return self._issue_to_dict(issue)
657+
658+
def reopen_issue(
659+
self,
660+
issue_number: int,
661+
owner: Optional[str] = None,
662+
repo: Optional[str] = None,
663+
) -> Dict[str, Any]:
664+
"""Reopen a GitHub issue."""
665+
gh_repo = self._get_repo(owner, repo)
666+
issue = gh_repo.get_issue(issue_number)
667+
issue.edit(state="open")
668+
return self._issue_to_dict(issue)
669+
670+
def comment_on_issue(
671+
self,
672+
issue_number: int,
673+
body: str,
674+
owner: Optional[str] = None,
675+
repo: Optional[str] = None,
676+
) -> Dict[str, Any]:
677+
"""Add a comment to a GitHub issue."""
678+
gh_repo = self._get_repo(owner, repo)
679+
issue = gh_repo.get_issue(issue_number)
680+
comment = issue.create_comment(body)
681+
return {
682+
"id": comment.id,
683+
"body": comment.body,
684+
"html_url": comment.html_url,
685+
"created_at": comment.created_at.isoformat(),
686+
"issue_number": issue_number,
687+
}
688+
581689
# ========================================================================
582690
# Search Operations (for Appraisals)
583691
# ========================================================================

mcp_server/auth/credentials.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,9 +353,9 @@ def get_status(self) -> Dict[str, Any]:
353353
api_creds = self.get_api_credentials(force_refresh=True)
354354
result["github"] = {
355355
"connected": api_creds.github_connected if api_creds else False,
356-
"mode": "github_app"
357-
if (api_creds and api_creds.github_connected)
358-
else None,
356+
"mode": (
357+
"github_app" if (api_creds and api_creds.github_connected) else None
358+
),
359359
"username": api_creds.github_username if api_creds else None,
360360
}
361361
result["slack"] = {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""
2+
GitHub MCP Resources - Exposes GitHub data for Claude's context.
3+
4+
Resources are automatically available in Claude's context when connected.
5+
"""
6+
7+
import logging
8+
from typing import Any, Dict, Optional
9+
10+
import yaml
11+
from fastmcp import FastMCP
12+
13+
from mcp_server.auth import get_credential_store, get_github_pat
14+
from mcp_server.auth.credentials import _find_project_root, _parse_env_file
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
def _load_issue_templates_config() -> Optional[Dict[str, Any]]:
20+
"""
21+
Load issue templates from ISSUE_TEMPLATE_PATH in .quickcall.env.
22+
Returns None if not configured or file doesn't exist.
23+
"""
24+
import os
25+
from pathlib import Path
26+
27+
template_path = os.getenv("ISSUE_TEMPLATE_PATH")
28+
29+
# Check .quickcall.env in project root
30+
if not template_path:
31+
project_root = _find_project_root()
32+
if project_root:
33+
config_path = project_root / ".quickcall.env"
34+
if config_path.exists():
35+
env_vars = _parse_env_file(config_path)
36+
if "ISSUE_TEMPLATE_PATH" in env_vars:
37+
template_path = env_vars["ISSUE_TEMPLATE_PATH"]
38+
if not Path(template_path).is_absolute():
39+
template_path = str(project_root / template_path)
40+
41+
if not template_path:
42+
return None
43+
44+
try:
45+
with open(template_path) as f:
46+
return yaml.safe_load(f) or {}
47+
except Exception as e:
48+
logger.warning(f"Failed to load issue templates: {e}")
49+
return None
50+
51+
52+
def create_github_resources(mcp: FastMCP) -> None:
53+
"""Add GitHub resources to the MCP server."""
54+
55+
@mcp.resource("github://repositories")
56+
def get_github_repositories() -> str:
57+
"""
58+
List of GitHub repositories the user has access to.
59+
60+
Use these when working with GitHub operations.
61+
"""
62+
store = get_credential_store()
63+
64+
# Check if authenticated via PAT or QuickCall
65+
pat_token, pat_source = get_github_pat()
66+
has_pat = pat_token is not None
67+
68+
if not has_pat and not store.is_authenticated():
69+
return "GitHub not connected. Options:\n- Run connect_github_via_pat with a Personal Access Token\n- Run connect_quickcall to use QuickCall"
70+
71+
# Check QuickCall GitHub App connection
72+
has_app = False
73+
if store.is_authenticated():
74+
creds = store.get_api_credentials()
75+
if creds and creds.github_connected and creds.github_token:
76+
has_app = True
77+
78+
if not has_pat and not has_app:
79+
return "GitHub not connected. Connect at quickcall.dev/assistant or use connect_github_via_pat."
80+
81+
try:
82+
# Import here to avoid circular imports
83+
from mcp_server.tools.github_tools import _get_client
84+
85+
client = _get_client()
86+
repos = client.list_repos(limit=50)
87+
88+
# Determine auth mode for display
89+
auth_mode = "PAT" if has_pat else "GitHub App"
90+
91+
lines = [f"GitHub Repositories (via {auth_mode}):", ""]
92+
for repo in repos:
93+
visibility = "private" if repo.private else "public"
94+
lines.append(f"- {repo.full_name} ({visibility})")
95+
96+
if len(repos) >= 50:
97+
lines.append("")
98+
lines.append("(Showing first 50 repos)")
99+
100+
return "\n".join(lines)
101+
except Exception as e:
102+
logger.error(f"Failed to fetch GitHub repositories: {e}")
103+
return f"Error fetching repositories: {str(e)}"
104+
105+
@mcp.resource("github://issue-templates")
106+
def get_issue_templates() -> str:
107+
"""
108+
Available issue templates from project configuration.
109+
110+
Use template names when creating issues with manage_issues.
111+
"""
112+
config = _load_issue_templates_config()
113+
114+
if not config:
115+
return "No issue templates configured.\n\nTo configure:\n1. Create a YAML file with your templates\n2. Add ISSUE_TEMPLATE_PATH=/path/to/templates.yaml to .quickcall.env"
116+
117+
templates = config.get("templates", {})
118+
defaults = config.get("defaults", {})
119+
120+
if not templates:
121+
lines = ["Issue Templates:", ""]
122+
if defaults:
123+
labels = defaults.get("labels", [])
124+
lines.append("Default template:")
125+
if labels:
126+
lines.append(f" Labels: {', '.join(labels)}")
127+
if defaults.get("body"):
128+
lines.append(f" Body template: {defaults['body'][:100]}...")
129+
return "\n".join(lines)
130+
131+
lines = ["Available Issue Templates:", ""]
132+
133+
for name, template in templates.items():
134+
labels = template.get("labels", [])
135+
body_preview = template.get("body", "")[:80]
136+
lines.append(f"- {name}")
137+
if labels:
138+
lines.append(f" Labels: {', '.join(labels)}")
139+
if body_preview:
140+
lines.append(f" Body: {body_preview}...")
141+
142+
lines.append("")
143+
lines.append(
144+
"Usage: manage_issues(action='create', title='...', template='<name>')"
145+
)
146+
147+
return "\n".join(lines)

mcp_server/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from mcp_server.tools.slack_tools import create_slack_tools
2525
from mcp_server.tools.auth_tools import create_auth_tools
2626
from mcp_server.resources.slack_resources import create_slack_resources
27+
from mcp_server.resources.github_resources import create_github_resources
2728

2829
# Configure logging
2930
logging.basicConfig(
@@ -57,6 +58,7 @@ def create_server() -> FastMCP:
5758

5859
# Register resources (available in Claude's context)
5960
create_slack_resources(mcp)
61+
create_github_resources(mcp)
6062

6163
# Log current status
6264
if is_authenticated:

0 commit comments

Comments
 (0)