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.
@@ -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)