github-mcp 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- github_mcp/__init__.py +3 -0
- github_mcp/app.py +3 -0
- github_mcp/client.py +252 -0
- github_mcp/formatting.py +144 -0
- github_mcp/server.py +17 -0
- github_mcp/tools/__init__.py +1 -0
- github_mcp/tools/issues.py +375 -0
- github_mcp/tools/pulls.py +429 -0
- github_mcp/tools/repos.py +686 -0
- github_mcp/tools/search.py +524 -0
- github_mcp-1.1.0.dist-info/METADATA +193 -0
- github_mcp-1.1.0.dist-info/RECORD +15 -0
- github_mcp-1.1.0.dist-info/WHEEL +4 -0
- github_mcp-1.1.0.dist-info/entry_points.txt +3 -0
- github_mcp-1.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from github_mcp.app import mcp
|
|
8
|
+
from mcp.types import ToolAnnotations
|
|
9
|
+
from github_mcp.client import github_request, handle_github_error
|
|
10
|
+
from github_mcp.formatting import ResponseFormat, fmt_json, fmt_timestamp
|
|
11
|
+
|
|
12
|
+
# MARK: Input models
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ListIssuesInput(BaseModel):
|
|
16
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
17
|
+
|
|
18
|
+
owner: str = Field(..., description="Repository owner (username or org).", min_length=1)
|
|
19
|
+
repo: str = Field(..., description="Repository name.", min_length=1)
|
|
20
|
+
state: Optional[str] = Field(
|
|
21
|
+
default="open",
|
|
22
|
+
description="Issue state filter: 'open' (default), 'closed', or 'all'.",
|
|
23
|
+
)
|
|
24
|
+
labels: Optional[str] = Field(
|
|
25
|
+
default=None,
|
|
26
|
+
description="Comma-separated list of label names to filter by (e.g. 'bug,help wanted').",
|
|
27
|
+
)
|
|
28
|
+
assignee: Optional[str] = Field(
|
|
29
|
+
default=None,
|
|
30
|
+
description="Filter by assignee username. Use 'none' for unassigned, '*' for any.",
|
|
31
|
+
)
|
|
32
|
+
limit: int = Field(default=20, ge=1, le=100, description="Maximum issues to return (1–100, default 20).")
|
|
33
|
+
page: int = Field(default=1, ge=1, description="Page number for pagination (default 1).")
|
|
34
|
+
response_format: ResponseFormat = Field(default=ResponseFormat.MARKDOWN, description="Output format.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GetIssueInput(BaseModel):
|
|
38
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
39
|
+
|
|
40
|
+
owner: str = Field(..., description="Repository owner.", min_length=1)
|
|
41
|
+
repo: str = Field(..., description="Repository name.", min_length=1)
|
|
42
|
+
issue_number: int = Field(..., description="Issue number.", ge=1)
|
|
43
|
+
response_format: ResponseFormat = Field(default=ResponseFormat.MARKDOWN, description="Output format.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CreateIssueInput(BaseModel):
|
|
47
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
48
|
+
|
|
49
|
+
owner: str = Field(..., description="Repository owner.", min_length=1)
|
|
50
|
+
repo: str = Field(..., description="Repository name.", min_length=1)
|
|
51
|
+
title: str = Field(..., description="Issue title.", min_length=1, max_length=256)
|
|
52
|
+
body: Optional[str] = Field(default=None, description="Issue body (Markdown supported).")
|
|
53
|
+
labels: Optional[list[str]] = Field(default=None, description="List of label names to apply.")
|
|
54
|
+
assignees: Optional[list[str]] = Field(default=None, description="List of GitHub usernames to assign.")
|
|
55
|
+
milestone: Optional[int] = Field(default=None, description="Milestone number to associate.", ge=1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class UpdateIssueInput(BaseModel):
|
|
59
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
60
|
+
|
|
61
|
+
owner: str = Field(..., description="Repository owner.", min_length=1)
|
|
62
|
+
repo: str = Field(..., description="Repository name.", min_length=1)
|
|
63
|
+
issue_number: int = Field(..., description="Issue number to update.", ge=1)
|
|
64
|
+
title: Optional[str] = Field(default=None, description="New issue title.", max_length=256)
|
|
65
|
+
body: Optional[str] = Field(default=None, description="New issue body (replaces existing).")
|
|
66
|
+
state: Optional[str] = Field(
|
|
67
|
+
default=None,
|
|
68
|
+
description="Change issue state: 'open' to reopen, 'closed' to close.",
|
|
69
|
+
)
|
|
70
|
+
labels: Optional[list[str]] = Field(default=None, description="Replace all labels with this list.")
|
|
71
|
+
assignees: Optional[list[str]] = Field(default=None, description="Replace all assignees with this list.")
|
|
72
|
+
milestone: Optional[int] = Field(default=None, description="Set milestone number (0 to clear).", ge=0)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AddIssueCommentInput(BaseModel):
|
|
76
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
77
|
+
|
|
78
|
+
owner: str = Field(..., description="Repository owner.", min_length=1)
|
|
79
|
+
repo: str = Field(..., description="Repository name.", min_length=1)
|
|
80
|
+
issue_number: int = Field(..., description="Issue number to comment on.", ge=1)
|
|
81
|
+
body: str = Field(..., description="Comment text (Markdown supported).", min_length=1)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# MARK: Tools
|
|
85
|
+
|
|
86
|
+
@mcp.tool(
|
|
87
|
+
name="github_list_issues",
|
|
88
|
+
annotations=ToolAnnotations(
|
|
89
|
+
title="List Repository Issues",
|
|
90
|
+
readOnlyHint=True,
|
|
91
|
+
destructiveHint=False,
|
|
92
|
+
idempotentHint=True,
|
|
93
|
+
openWorldHint=True,
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
async def github_list_issues(params: ListIssuesInput) -> str:
|
|
97
|
+
"""List issues in a GitHub repository with optional filters.
|
|
98
|
+
|
|
99
|
+
Note: GitHub's issues endpoint also returns pull requests. This tool
|
|
100
|
+
filters to only return true issues (not PRs).
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
params (ListIssuesInput): Validated input containing:
|
|
104
|
+
- owner (str): Repository owner.
|
|
105
|
+
- repo (str): Repository name.
|
|
106
|
+
- state (Optional[str]): 'open', 'closed', or 'all'.
|
|
107
|
+
- labels (Optional[str]): Comma-separated label names.
|
|
108
|
+
- assignee (Optional[str]): Filter by assignee.
|
|
109
|
+
- limit (int): Max issues (1–100).
|
|
110
|
+
- page (int): Page number.
|
|
111
|
+
- response_format (ResponseFormat): 'markdown' or 'json'.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
str: Formatted issue list. Markdown shows number, title, state, labels,
|
|
115
|
+
assignees, and URL. JSON is the raw API array.
|
|
116
|
+
|
|
117
|
+
Error response: "Error <code>: <message>" string.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
query: dict = {"state": params.state, "per_page": params.limit, "page": params.page}
|
|
121
|
+
if params.labels:
|
|
122
|
+
query["labels"] = params.labels
|
|
123
|
+
if params.assignee:
|
|
124
|
+
query["assignee"] = params.assignee
|
|
125
|
+
|
|
126
|
+
resp = await github_request(
|
|
127
|
+
"GET",
|
|
128
|
+
f"/repos/{params.owner}/{params.repo}/issues",
|
|
129
|
+
params=query,
|
|
130
|
+
)
|
|
131
|
+
all_items = resp.json()
|
|
132
|
+
# Filter out pull requests
|
|
133
|
+
issues = [i for i in all_items if "pull_request" not in i]
|
|
134
|
+
|
|
135
|
+
if params.response_format == ResponseFormat.JSON:
|
|
136
|
+
return fmt_json(issues)
|
|
137
|
+
|
|
138
|
+
lines = [f"## Issues: {params.owner}/{params.repo} ({params.state})", ""]
|
|
139
|
+
if not issues:
|
|
140
|
+
lines.append("*No issues found.*")
|
|
141
|
+
else:
|
|
142
|
+
for issue in issues:
|
|
143
|
+
num = issue.get("number", "?")
|
|
144
|
+
title = issue.get("title", "")
|
|
145
|
+
url = issue.get("html_url", "")
|
|
146
|
+
state = issue.get("state", "")
|
|
147
|
+
labels = ", ".join(lbl["name"] for lbl in issue.get("labels", []))
|
|
148
|
+
assignees = ", ".join(a["login"] for a in issue.get("assignees", []))
|
|
149
|
+
created = fmt_timestamp(issue.get("created_at"))
|
|
150
|
+
label_str = f" [{labels}]" if labels else ""
|
|
151
|
+
line = f"- **#{num}** [{title}]({url}){label_str} — {state}"
|
|
152
|
+
lines.append(line)
|
|
153
|
+
if assignees:
|
|
154
|
+
lines.append(f" - Assignees: {assignees}")
|
|
155
|
+
lines.append(f" - Created: {created}")
|
|
156
|
+
return "\n".join(lines)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
return handle_github_error(e)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@mcp.tool(
|
|
162
|
+
name="github_get_issue",
|
|
163
|
+
annotations=ToolAnnotations(
|
|
164
|
+
title="Get GitHub Issue",
|
|
165
|
+
readOnlyHint=True,
|
|
166
|
+
destructiveHint=False,
|
|
167
|
+
idempotentHint=True,
|
|
168
|
+
openWorldHint=True,
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
async def github_get_issue(params: GetIssueInput) -> str:
|
|
172
|
+
"""Get full details for a single GitHub issue including body and metadata.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
params (GetIssueInput): Validated input containing:
|
|
176
|
+
- owner (str): Repository owner.
|
|
177
|
+
- repo (str): Repository name.
|
|
178
|
+
- issue_number (int): Issue number.
|
|
179
|
+
- response_format (ResponseFormat): 'markdown' or 'json'.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
str: Full issue details. Markdown shows title, number, state, author,
|
|
183
|
+
assignees, labels, milestone, dates, and body. JSON is the raw
|
|
184
|
+
API object.
|
|
185
|
+
|
|
186
|
+
Error response: "Error <code>: <message>" string.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
resp = await github_request(
|
|
190
|
+
"GET",
|
|
191
|
+
f"/repos/{params.owner}/{params.repo}/issues/{params.issue_number}",
|
|
192
|
+
)
|
|
193
|
+
issue = resp.json()
|
|
194
|
+
|
|
195
|
+
if params.response_format == ResponseFormat.JSON:
|
|
196
|
+
return fmt_json(issue)
|
|
197
|
+
|
|
198
|
+
labels = ", ".join(lbl["name"] for lbl in issue.get("labels", []))
|
|
199
|
+
assignees = ", ".join(a["login"] for a in issue.get("assignees", []))
|
|
200
|
+
milestone = issue.get("milestone", {})
|
|
201
|
+
milestone_str = milestone.get("title", "None") if milestone else "None"
|
|
202
|
+
author = issue.get("user", {}).get("login", "unknown")
|
|
203
|
+
lines = [
|
|
204
|
+
f"## Issue #{issue.get('number')}: {issue.get('title')}",
|
|
205
|
+
"",
|
|
206
|
+
f"- **State**: {issue.get('state')}",
|
|
207
|
+
f"- **Author**: {author}",
|
|
208
|
+
f"- **Assignees**: {assignees or 'None'}",
|
|
209
|
+
f"- **Labels**: {labels or 'None'}",
|
|
210
|
+
f"- **Milestone**: {milestone_str}",
|
|
211
|
+
f"- **Comments**: {issue.get('comments', 0)}",
|
|
212
|
+
f"- **Created**: {fmt_timestamp(issue.get('created_at'))}",
|
|
213
|
+
f"- **Updated**: {fmt_timestamp(issue.get('updated_at'))}",
|
|
214
|
+
f"- **Closed**: {fmt_timestamp(issue.get('closed_at'))}",
|
|
215
|
+
f"- **URL**: {issue.get('html_url', '')}",
|
|
216
|
+
"",
|
|
217
|
+
"### Body",
|
|
218
|
+
"",
|
|
219
|
+
issue.get("body") or "*No description provided.*",
|
|
220
|
+
]
|
|
221
|
+
return "\n".join(lines)
|
|
222
|
+
except Exception as e:
|
|
223
|
+
return handle_github_error(e)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@mcp.tool(
|
|
227
|
+
name="github_create_issue",
|
|
228
|
+
annotations=ToolAnnotations(
|
|
229
|
+
title="Create a GitHub Issue",
|
|
230
|
+
readOnlyHint=False,
|
|
231
|
+
destructiveHint=False,
|
|
232
|
+
idempotentHint=False,
|
|
233
|
+
openWorldHint=True,
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
async def github_create_issue(params: CreateIssueInput) -> str:
|
|
237
|
+
"""Create a new issue in a GitHub repository.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
params (CreateIssueInput): Validated input containing:
|
|
241
|
+
- owner (str): Repository owner.
|
|
242
|
+
- repo (str): Repository name.
|
|
243
|
+
- title (str): Issue title.
|
|
244
|
+
- body (Optional[str]): Issue body (Markdown).
|
|
245
|
+
- labels (Optional[list[str]]): Label names to apply.
|
|
246
|
+
- assignees (Optional[list[str]]): Usernames to assign.
|
|
247
|
+
- milestone (Optional[int]): Milestone number.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
str: Confirmation with issue number and URL.
|
|
251
|
+
|
|
252
|
+
Error response: "Error <code>: <message>" string.
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
body: dict = {"title": params.title}
|
|
256
|
+
if params.body:
|
|
257
|
+
body["body"] = params.body
|
|
258
|
+
if params.labels:
|
|
259
|
+
body["labels"] = params.labels
|
|
260
|
+
if params.assignees:
|
|
261
|
+
body["assignees"] = params.assignees
|
|
262
|
+
if params.milestone:
|
|
263
|
+
body["milestone"] = params.milestone
|
|
264
|
+
|
|
265
|
+
resp = await github_request(
|
|
266
|
+
"POST",
|
|
267
|
+
f"/repos/{params.owner}/{params.repo}/issues",
|
|
268
|
+
json=body,
|
|
269
|
+
)
|
|
270
|
+
issue = resp.json()
|
|
271
|
+
return (
|
|
272
|
+
f"Created issue #{issue['number']}: {issue['title']}\n"
|
|
273
|
+
f"URL: {issue.get('html_url', '')}"
|
|
274
|
+
)
|
|
275
|
+
except Exception as e:
|
|
276
|
+
return handle_github_error(e)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@mcp.tool(
|
|
280
|
+
name="github_update_issue",
|
|
281
|
+
annotations=ToolAnnotations(
|
|
282
|
+
title="Update a GitHub Issue",
|
|
283
|
+
readOnlyHint=False,
|
|
284
|
+
destructiveHint=False,
|
|
285
|
+
idempotentHint=False,
|
|
286
|
+
openWorldHint=True,
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
async def github_update_issue(params: UpdateIssueInput) -> str:
|
|
290
|
+
"""Update an existing GitHub issue — edit title/body, change state, labels, or assignees.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
params (UpdateIssueInput): Validated input containing:
|
|
294
|
+
- owner (str): Repository owner.
|
|
295
|
+
- repo (str): Repository name.
|
|
296
|
+
- issue_number (int): Issue number to update.
|
|
297
|
+
- title (Optional[str]): New title.
|
|
298
|
+
- body (Optional[str]): New body (replaces existing).
|
|
299
|
+
- state (Optional[str]): 'open' or 'closed'.
|
|
300
|
+
- labels (Optional[list[str]]): Replacement label list.
|
|
301
|
+
- assignees (Optional[list[str]]): Replacement assignees list.
|
|
302
|
+
- milestone (Optional[int]): Milestone number (0 to clear).
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
str: Confirmation with updated issue number, state, and URL.
|
|
306
|
+
|
|
307
|
+
Error response: "Error <code>: <message>" string.
|
|
308
|
+
"""
|
|
309
|
+
try:
|
|
310
|
+
body: dict = {}
|
|
311
|
+
if params.title is not None:
|
|
312
|
+
body["title"] = params.title
|
|
313
|
+
if params.body is not None:
|
|
314
|
+
body["body"] = params.body
|
|
315
|
+
if params.state is not None:
|
|
316
|
+
body["state"] = params.state
|
|
317
|
+
if params.labels is not None:
|
|
318
|
+
body["labels"] = params.labels
|
|
319
|
+
if params.assignees is not None:
|
|
320
|
+
body["assignees"] = params.assignees
|
|
321
|
+
if params.milestone is not None:
|
|
322
|
+
body["milestone"] = params.milestone if params.milestone > 0 else None
|
|
323
|
+
|
|
324
|
+
resp = await github_request(
|
|
325
|
+
"PATCH",
|
|
326
|
+
f"/repos/{params.owner}/{params.repo}/issues/{params.issue_number}",
|
|
327
|
+
json=body,
|
|
328
|
+
)
|
|
329
|
+
issue = resp.json()
|
|
330
|
+
return (
|
|
331
|
+
f"Updated issue #{issue['number']}: {issue['title']} — state: {issue['state']}\n"
|
|
332
|
+
f"URL: {issue.get('html_url', '')}"
|
|
333
|
+
)
|
|
334
|
+
except Exception as e:
|
|
335
|
+
return handle_github_error(e)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@mcp.tool(
|
|
339
|
+
name="github_add_issue_comment",
|
|
340
|
+
annotations=ToolAnnotations(
|
|
341
|
+
title="Add a Comment to a GitHub Issue",
|
|
342
|
+
readOnlyHint=False,
|
|
343
|
+
destructiveHint=False,
|
|
344
|
+
idempotentHint=False,
|
|
345
|
+
openWorldHint=True,
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
async def github_add_issue_comment(params: AddIssueCommentInput) -> str:
|
|
349
|
+
"""Add a comment to an existing GitHub issue.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
params (AddIssueCommentInput): Validated input containing:
|
|
353
|
+
- owner (str): Repository owner.
|
|
354
|
+
- repo (str): Repository name.
|
|
355
|
+
- issue_number (int): Issue to comment on.
|
|
356
|
+
- body (str): Comment text (Markdown supported).
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
str: Confirmation with comment ID and URL.
|
|
360
|
+
|
|
361
|
+
Error response: "Error <code>: <message>" string.
|
|
362
|
+
"""
|
|
363
|
+
try:
|
|
364
|
+
resp = await github_request(
|
|
365
|
+
"POST",
|
|
366
|
+
f"/repos/{params.owner}/{params.repo}/issues/{params.issue_number}/comments",
|
|
367
|
+
json={"body": params.body},
|
|
368
|
+
)
|
|
369
|
+
comment = resp.json()
|
|
370
|
+
return (
|
|
371
|
+
f"Comment added to issue #{params.issue_number} (comment ID: {comment['id']}).\n"
|
|
372
|
+
f"URL: {comment.get('html_url', '')}"
|
|
373
|
+
)
|
|
374
|
+
except Exception as e:
|
|
375
|
+
return handle_github_error(e)
|