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,429 @@
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 ListPullRequestsInput(BaseModel):
16
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
17
+
18
+ owner: str = Field(..., description="Repository owner.", min_length=1)
19
+ repo: str = Field(..., description="Repository name.", min_length=1)
20
+ state: Optional[str] = Field(
21
+ default="open",
22
+ description="PR state: 'open' (default), 'closed', or 'all'.",
23
+ )
24
+ base: Optional[str] = Field(
25
+ default=None,
26
+ description="Filter by base branch name (e.g. 'main').",
27
+ )
28
+ head: Optional[str] = Field(
29
+ default=None,
30
+ description="Filter by head branch (format: 'user:branch' or just 'branch').",
31
+ )
32
+ sort: Optional[str] = Field(
33
+ default="updated",
34
+ description="Sort by: 'created', 'updated' (default), 'popularity', 'long-running'.",
35
+ )
36
+ limit: int = Field(
37
+ default=20, ge=1, le=100, description="Max PRs to return (1–100, default 20)."
38
+ )
39
+ page: int = Field(default=1, ge=1, description="Page number.")
40
+ response_format: ResponseFormat = Field(
41
+ default=ResponseFormat.MARKDOWN, description="Output format."
42
+ )
43
+
44
+
45
+ class GetPullRequestInput(BaseModel):
46
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
47
+
48
+ owner: str = Field(..., description="Repository owner.", min_length=1)
49
+ repo: str = Field(..., description="Repository name.", min_length=1)
50
+ pull_number: int = Field(..., description="Pull request number.", ge=1)
51
+ include_diff: bool = Field(
52
+ default=False,
53
+ description=(
54
+ "If true, append the unified diff of the PR's changes. "
55
+ "May be large for PRs with many file changes."
56
+ ),
57
+ )
58
+ response_format: ResponseFormat = Field(
59
+ default=ResponseFormat.MARKDOWN, description="Output format."
60
+ )
61
+
62
+
63
+ class CreatePullRequestInput(BaseModel):
64
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
65
+
66
+ owner: str = Field(..., description="Repository owner.", min_length=1)
67
+ repo: str = Field(..., description="Repository name.", min_length=1)
68
+ title: str = Field(..., description="PR title.", min_length=1, max_length=256)
69
+ head: str = Field(
70
+ ...,
71
+ description="Branch containing changes (format: 'branch' or 'owner:branch').",
72
+ min_length=1,
73
+ )
74
+ base: str = Field(
75
+ ..., description="Branch to merge into (e.g. 'main').", min_length=1
76
+ )
77
+ body: Optional[str] = Field(
78
+ default=None, description="PR description (Markdown supported)."
79
+ )
80
+ draft: bool = Field(default=False, description="Open as a draft PR.")
81
+ maintainer_can_modify: bool = Field(
82
+ default=True,
83
+ description="Allow maintainers to push to the head branch.",
84
+ )
85
+
86
+
87
+ class MergePullRequestInput(BaseModel):
88
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
89
+
90
+ owner: str = Field(..., description="Repository owner.", min_length=1)
91
+ repo: str = Field(..., description="Repository name.", min_length=1)
92
+ pull_number: int = Field(..., description="PR number to merge.", ge=1)
93
+ commit_title: Optional[str] = Field(
94
+ default=None,
95
+ description="Title for the merge commit (defaults to PR title).",
96
+ )
97
+ commit_message: Optional[str] = Field(
98
+ default=None,
99
+ description="Extra detail for the merge commit message.",
100
+ )
101
+ merge_method: Optional[str] = Field(
102
+ default="merge",
103
+ description="Merge strategy: 'merge' (default), 'squash', or 'rebase'.",
104
+ )
105
+
106
+
107
+ class AddPRCommentInput(BaseModel):
108
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
109
+
110
+ owner: str = Field(..., description="Repository owner.", min_length=1)
111
+ repo: str = Field(..., description="Repository name.", min_length=1)
112
+ pull_number: int = Field(..., description="Pull request number.", ge=1)
113
+ body: str = Field(
114
+ ..., description="Comment text (Markdown supported).", min_length=1
115
+ )
116
+
117
+
118
+ # MARK: Tools
119
+
120
+
121
+ @mcp.tool(
122
+ name="github_list_pull_requests",
123
+ annotations=ToolAnnotations(
124
+ title="List Pull Requests",
125
+ readOnlyHint=True,
126
+ destructiveHint=False,
127
+ idempotentHint=True,
128
+ openWorldHint=True,
129
+ ),
130
+ )
131
+ async def github_list_pull_requests(params: ListPullRequestsInput) -> str:
132
+ """List pull requests in a GitHub repository.
133
+
134
+ Args:
135
+ params (ListPullRequestsInput): Validated input containing:
136
+ - owner (str): Repository owner.
137
+ - repo (str): Repository name.
138
+ - state (Optional[str]): 'open', 'closed', or 'all'.
139
+ - base (Optional[str]): Filter by base branch.
140
+ - head (Optional[str]): Filter by head branch.
141
+ - sort (Optional[str]): Sort key.
142
+ - limit (int): Max PRs (1–100).
143
+ - page (int): Page number.
144
+ - response_format (ResponseFormat): 'markdown' or 'json'.
145
+
146
+ Returns:
147
+ str: Formatted PR list. Markdown shows number, title, author, head/base
148
+ branches, draft status, and URL. JSON is the raw API array.
149
+
150
+ Error response: "Error <code>: <message>" string.
151
+ """
152
+ try:
153
+ query: dict = {
154
+ "state": params.state,
155
+ "sort": params.sort,
156
+ "per_page": params.limit,
157
+ "page": params.page,
158
+ }
159
+ if params.base:
160
+ query["base"] = params.base
161
+ if params.head:
162
+ query["head"] = params.head
163
+
164
+ resp = await github_request(
165
+ "GET",
166
+ f"/repos/{params.owner}/{params.repo}/pulls",
167
+ params=query,
168
+ )
169
+ prs = resp.json()
170
+
171
+ if params.response_format == ResponseFormat.JSON:
172
+ return fmt_json(prs)
173
+
174
+ lines = [f"## Pull Requests: {params.owner}/{params.repo} ({params.state})", ""]
175
+ if not prs:
176
+ lines.append("*No pull requests found.*")
177
+ else:
178
+ for pr in prs:
179
+ num = pr.get("number", "?")
180
+ title = pr.get("title", "")
181
+ url = pr.get("html_url", "")
182
+ author = pr.get("user", {}).get("login", "unknown")
183
+ head_label = pr.get("head", {}).get("label", "?")
184
+ base_label = pr.get("base", {}).get("label", "?")
185
+ draft = " [DRAFT]" if pr.get("draft") else ""
186
+ updated = fmt_timestamp(pr.get("updated_at"))
187
+ lines.append(f"- **#{num}**{draft} [{title}]({url})")
188
+ lines.append(f" - Author: {author} | {head_label} → {base_label}")
189
+ lines.append(f" - Updated: {updated}")
190
+ return "\n".join(lines)
191
+ except Exception as e:
192
+ return handle_github_error(e)
193
+
194
+
195
+ @mcp.tool(
196
+ name="github_get_pull_request",
197
+ annotations=ToolAnnotations(
198
+ title="Get Pull Request Details",
199
+ readOnlyHint=True,
200
+ destructiveHint=False,
201
+ idempotentHint=True,
202
+ openWorldHint=True,
203
+ ),
204
+ )
205
+ async def github_get_pull_request(params: GetPullRequestInput) -> str:
206
+ """Get full details for a single pull request, optionally including the unified diff.
207
+
208
+ Args:
209
+ params (GetPullRequestInput): Validated input containing:
210
+ - owner (str): Repository owner.
211
+ - repo (str): Repository name.
212
+ - pull_number (int): PR number.
213
+ - include_diff (bool): If true, append the unified diff.
214
+ - response_format (ResponseFormat): 'markdown' or 'json'.
215
+
216
+ Returns:
217
+ str: Full PR details. Markdown shows title, state, author, branches,
218
+ labels, reviewers, checks status, body, and optionally the diff.
219
+ JSON is the raw API object (diff excluded in JSON mode).
220
+
221
+ Error response: "Error <code>: <message>" string.
222
+ """
223
+ try:
224
+ resp = await github_request(
225
+ "GET",
226
+ f"/repos/{params.owner}/{params.repo}/pulls/{params.pull_number}",
227
+ )
228
+ pr = resp.json()
229
+
230
+ if params.response_format == ResponseFormat.JSON:
231
+ return fmt_json(pr)
232
+
233
+ author = pr.get("user", {}).get("login", "unknown")
234
+ head_label = pr.get("head", {}).get("label", "?")
235
+ base_label = pr.get("base", {}).get("label", "?")
236
+ labels = ", ".join(lbl["name"] for lbl in pr.get("labels", []))
237
+ reviewers = ", ".join(r["login"] for r in pr.get("requested_reviewers", []))
238
+ draft = " (DRAFT)" if pr.get("draft") else ""
239
+ merged = pr.get("merged", False)
240
+ state_str = "merged" if merged else pr.get("state", "")
241
+
242
+ lines = [
243
+ f"## PR #{pr.get('number')}: {pr.get('title')}{draft}",
244
+ "",
245
+ f"- **State**: {state_str}",
246
+ f"- **Author**: {author}",
247
+ f"- **Branch**: {head_label} → {base_label}",
248
+ f"- **Labels**: {labels or 'None'}",
249
+ f"- **Reviewers**: {reviewers or 'None'}",
250
+ f"- **Commits**: {pr.get('commits', 0)}",
251
+ f"- **Changed files**: {pr.get('changed_files', 0)}",
252
+ f"- **Additions**: +{pr.get('additions', 0)} / Deletions: -{pr.get('deletions', 0)}",
253
+ f"- **Mergeable**: {pr.get('mergeable', 'unknown')}",
254
+ f"- **Created**: {fmt_timestamp(pr.get('created_at'))}",
255
+ f"- **Updated**: {fmt_timestamp(pr.get('updated_at'))}",
256
+ ]
257
+ if merged:
258
+ lines.append(f"- **Merged**: {fmt_timestamp(pr.get('merged_at'))}")
259
+ lines += [
260
+ f"- **URL**: {pr.get('html_url', '')}",
261
+ "",
262
+ "### Description",
263
+ "",
264
+ pr.get("body") or "*No description provided.*",
265
+ ]
266
+
267
+ if params.include_diff:
268
+ try:
269
+ diff_resp = await github_request(
270
+ "GET",
271
+ f"/repos/{params.owner}/{params.repo}/pulls/{params.pull_number}",
272
+ accept="application/vnd.github.diff",
273
+ )
274
+ lines += ["", "### Unified Diff", "", "```diff", diff_resp.text, "```"]
275
+ except Exception as diff_err:
276
+ lines += ["", f"*(Could not fetch diff: {diff_err})*"]
277
+
278
+ return "\n".join(lines)
279
+ except Exception as e:
280
+ return handle_github_error(e)
281
+
282
+
283
+ @mcp.tool(
284
+ name="github_create_pull_request",
285
+ annotations=ToolAnnotations(
286
+ title="Create a Pull Request",
287
+ readOnlyHint=False,
288
+ destructiveHint=False,
289
+ idempotentHint=False,
290
+ openWorldHint=True,
291
+ ),
292
+ )
293
+ async def github_create_pull_request(params: CreatePullRequestInput) -> str:
294
+ """Create a new pull request in a GitHub repository.
295
+
296
+ Args:
297
+ params (CreatePullRequestInput): Validated input containing:
298
+ - owner (str): Repository owner.
299
+ - repo (str): Repository name.
300
+ - title (str): PR title.
301
+ - head (str): Source branch with changes.
302
+ - base (str): Target branch to merge into.
303
+ - body (Optional[str]): PR description.
304
+ - draft (bool): Open as draft (default False).
305
+ - maintainer_can_modify (bool): Allow maintainer pushes (default True).
306
+
307
+ Returns:
308
+ str: Confirmation with PR number and URL.
309
+
310
+ Error response: "Error <code>: <message>" string.
311
+ """
312
+ try:
313
+ body: dict = {
314
+ "title": params.title,
315
+ "head": params.head,
316
+ "base": params.base,
317
+ "draft": params.draft,
318
+ "maintainer_can_modify": params.maintainer_can_modify,
319
+ }
320
+ if params.body:
321
+ body["body"] = params.body
322
+
323
+ resp = await github_request(
324
+ "POST",
325
+ f"/repos/{params.owner}/{params.repo}/pulls",
326
+ json=body,
327
+ )
328
+ pr = resp.json()
329
+ draft_str = " (draft)" if pr.get("draft") else ""
330
+ return (
331
+ f"Created PR #{pr['number']}{draft_str}: {pr['title']}\n"
332
+ f"URL: {pr.get('html_url', '')}"
333
+ )
334
+ except Exception as e:
335
+ return handle_github_error(e)
336
+
337
+
338
+ @mcp.tool(
339
+ name="github_merge_pull_request",
340
+ annotations=ToolAnnotations(
341
+ title="Merge a Pull Request",
342
+ readOnlyHint=False,
343
+ destructiveHint=True,
344
+ idempotentHint=False,
345
+ openWorldHint=True,
346
+ ),
347
+ )
348
+ async def github_merge_pull_request(params: MergePullRequestInput) -> str:
349
+ """Merge an open, mergeable pull request.
350
+
351
+ Args:
352
+ params (MergePullRequestInput): Validated input containing:
353
+ - owner (str): Repository owner.
354
+ - repo (str): Repository name.
355
+ - pull_number (int): PR number.
356
+ - commit_title (Optional[str]): Override merge commit title.
357
+ - commit_message (Optional[str]): Additional commit message detail.
358
+ - merge_method (Optional[str]): 'merge', 'squash', or 'rebase'.
359
+
360
+ Returns:
361
+ str: Confirmation with merge commit SHA or a descriptive error.
362
+
363
+ Error response: "Error <code>: <message>" string.
364
+ "Error 405: PR not mergeable" if the PR has conflicts or is already merged.
365
+ """
366
+ try:
367
+ body: dict = {"merge_method": params.merge_method or "merge"}
368
+ if params.commit_title:
369
+ body["commit_title"] = params.commit_title
370
+ if params.commit_message:
371
+ body["commit_message"] = params.commit_message
372
+
373
+ resp = await github_request(
374
+ "PUT",
375
+ f"/repos/{params.owner}/{params.repo}/pulls/{params.pull_number}/merge",
376
+ json=body,
377
+ )
378
+ data = resp.json()
379
+ sha = data.get("sha", "")[:7]
380
+ return (
381
+ f"PR #{params.pull_number} merged successfully "
382
+ f"(method: {params.merge_method}, commit: {sha}).\n"
383
+ f"{data.get('message', '')}"
384
+ )
385
+ except Exception as e:
386
+ return handle_github_error(e)
387
+
388
+
389
+ @mcp.tool(
390
+ name="github_add_pr_comment",
391
+ annotations=ToolAnnotations(
392
+ title="Add a Comment to a Pull Request",
393
+ readOnlyHint=False,
394
+ destructiveHint=False,
395
+ idempotentHint=False,
396
+ openWorldHint=True,
397
+ ),
398
+ )
399
+ async def github_add_pr_comment(params: AddPRCommentInput) -> str:
400
+ """Add a general comment to a pull request (as an issue comment, visible in the Conversation tab).
401
+
402
+ For inline code review comments on specific lines, use the GitHub Reviews API directly.
403
+
404
+ Args:
405
+ params (AddPRCommentInput): Validated input containing:
406
+ - owner (str): Repository owner.
407
+ - repo (str): Repository name.
408
+ - pull_number (int): PR number.
409
+ - body (str): Comment text (Markdown supported).
410
+
411
+ Returns:
412
+ str: Confirmation with comment ID and URL.
413
+
414
+ Error response: "Error <code>: <message>" string.
415
+ """
416
+ try:
417
+ resp = await github_request(
418
+ "POST",
419
+ # PRs share the issues comment endpoint for conversation-level comments
420
+ f"/repos/{params.owner}/{params.repo}/issues/{params.pull_number}/comments",
421
+ json={"body": params.body},
422
+ )
423
+ comment = resp.json()
424
+ return (
425
+ f"Comment added to PR #{params.pull_number} (comment ID: {comment['id']}).\n"
426
+ f"URL: {comment.get('html_url', '')}"
427
+ )
428
+ except Exception as e:
429
+ return handle_github_error(e)