mcp-github-crunchtools 1.0.1__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.
- mcp_github_crunchtools/__init__.py +58 -0
- mcp_github_crunchtools/__main__.py +6 -0
- mcp_github_crunchtools/client.py +271 -0
- mcp_github_crunchtools/config.py +140 -0
- mcp_github_crunchtools/errors.py +65 -0
- mcp_github_crunchtools/models.py +215 -0
- mcp_github_crunchtools/server.py +496 -0
- mcp_github_crunchtools/tools/__init__.py +48 -0
- mcp_github_crunchtools/tools/actions.py +187 -0
- mcp_github_crunchtools/tools/files.py +124 -0
- mcp_github_crunchtools/tools/issues.py +224 -0
- mcp_github_crunchtools/tools/pull_requests.py +287 -0
- mcp_github_crunchtools/tools/search.py +81 -0
- mcp_github_crunchtools-1.0.1.dist-info/METADATA +263 -0
- mcp_github_crunchtools-1.0.1.dist-info/RECORD +18 -0
- mcp_github_crunchtools-1.0.1.dist-info/WHEEL +4 -0
- mcp_github_crunchtools-1.0.1.dist-info/entry_points.txt +2 -0
- mcp_github_crunchtools-1.0.1.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Pydantic models and validation helpers for input validation.
|
|
2
|
+
|
|
3
|
+
All tool inputs are validated through these helpers to prevent injection
|
|
4
|
+
attacks and ensure data integrity before making API calls.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
from .config import get_config
|
|
12
|
+
|
|
13
|
+
SAFE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
14
|
+
SAFE_REF_PATTERN = re.compile(r"^[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$")
|
|
15
|
+
|
|
16
|
+
ISSUE_STATES = frozenset({"open", "closed", "all"})
|
|
17
|
+
PR_STATES = frozenset({"open", "closed", "all"})
|
|
18
|
+
|
|
19
|
+
MAX_NAME_LENGTH = 100
|
|
20
|
+
MAX_PATH_LENGTH = 1000
|
|
21
|
+
MAX_COMMENT_LENGTH = 65536
|
|
22
|
+
MAX_QUERY_LENGTH = 1000
|
|
23
|
+
MAX_PER_PAGE = 100
|
|
24
|
+
MAX_TITLE_LENGTH = 256
|
|
25
|
+
MAX_BODY_LENGTH = 65536
|
|
26
|
+
MAX_REF_LENGTH = 255
|
|
27
|
+
MAX_WORKFLOW_INPUTS = 32
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_owner(owner: str | None) -> str:
|
|
31
|
+
"""Resolve and validate a repository owner.
|
|
32
|
+
|
|
33
|
+
Falls back to GITHUB_DEFAULT_ORG when no owner is supplied.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
owner: Owner login (user or organization), or None.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A validated owner login.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: If no owner is available or the value is invalid.
|
|
43
|
+
"""
|
|
44
|
+
if owner is None or not owner.strip():
|
|
45
|
+
default = get_config().default_org
|
|
46
|
+
if not default:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"owner is required (or set GITHUB_DEFAULT_ORG)"
|
|
49
|
+
)
|
|
50
|
+
owner = default
|
|
51
|
+
|
|
52
|
+
return validate_name(owner, "owner")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_name(value: str, field: str) -> str:
|
|
56
|
+
"""Validate an owner or repository name against a safe allowlist.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
value: The value to validate.
|
|
60
|
+
field: Field name for error messages.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The stripped, validated value.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If the value is empty or contains disallowed characters.
|
|
67
|
+
"""
|
|
68
|
+
if not value or not value.strip():
|
|
69
|
+
raise ValueError(f"{field} must not be empty")
|
|
70
|
+
|
|
71
|
+
value = value.strip()
|
|
72
|
+
|
|
73
|
+
if len(value) > MAX_NAME_LENGTH:
|
|
74
|
+
raise ValueError(f"{field} is too long")
|
|
75
|
+
|
|
76
|
+
if not SAFE_NAME_PATTERN.match(value):
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"{field} must contain only letters, digits, dots, hyphens, "
|
|
79
|
+
"and underscores"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_positive_int(value: int, field: str) -> int:
|
|
86
|
+
"""Validate that a value is a positive integer.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
value: The value to validate.
|
|
90
|
+
field: Field name for error messages.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The validated integer.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
ValueError: If the value is not a positive integer.
|
|
97
|
+
"""
|
|
98
|
+
if not isinstance(value, int) or value < 1:
|
|
99
|
+
raise ValueError(f"{field} must be a positive integer")
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def clamp_per_page(per_page: int) -> int:
|
|
104
|
+
"""Clamp per_page to GitHub's allowed range (1-100)."""
|
|
105
|
+
return max(1, min(per_page, MAX_PER_PAGE))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate_ref(value: str, field: str = "ref") -> str:
|
|
109
|
+
"""Validate a git ref (branch or tag name).
|
|
110
|
+
|
|
111
|
+
Unlike names, refs may contain slashes between non-empty segments (e.g.
|
|
112
|
+
"release/1.2"), but must reject whitespace, control characters, ".."
|
|
113
|
+
traversal, empty "//" segments, and leading/trailing "." or "/" — in
|
|
114
|
+
line with git's check-ref-format rules, so invalid refs are caught here
|
|
115
|
+
rather than as a GitHub 422.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
value: The ref to validate.
|
|
119
|
+
field: Field name for error messages.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The stripped, validated ref.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ValueError: If the ref is empty, too long, malformed, or contains
|
|
126
|
+
disallowed characters.
|
|
127
|
+
"""
|
|
128
|
+
if not value or not value.strip():
|
|
129
|
+
raise ValueError(f"{field} must not be empty")
|
|
130
|
+
|
|
131
|
+
value = value.strip()
|
|
132
|
+
|
|
133
|
+
if len(value) > MAX_REF_LENGTH:
|
|
134
|
+
raise ValueError(f"{field} is too long")
|
|
135
|
+
|
|
136
|
+
if (
|
|
137
|
+
".." in value
|
|
138
|
+
or "//" in value
|
|
139
|
+
or "./" in value
|
|
140
|
+
or value.startswith((".", "/"))
|
|
141
|
+
or value.endswith((".", "/"))
|
|
142
|
+
):
|
|
143
|
+
raise ValueError(f"{field} is not a valid git ref")
|
|
144
|
+
|
|
145
|
+
if not SAFE_REF_PATTERN.match(value):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"{field} must contain only letters, digits, dots, hyphens, "
|
|
148
|
+
"underscores, and slashes"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def validate_workflow_inputs(inputs: dict[str, object]) -> dict[str, object]:
|
|
155
|
+
"""Validate workflow_dispatch inputs before sending them to GitHub.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
inputs: Mapping of input name to a primitive value.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The validated inputs mapping.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
ValueError: If there are too many inputs, a key is not a safe name,
|
|
165
|
+
or a value is not a string, number, or boolean.
|
|
166
|
+
"""
|
|
167
|
+
is_mapping = isinstance(inputs, dict)
|
|
168
|
+
if not is_mapping:
|
|
169
|
+
raise ValueError("inputs must be an object of name/value pairs")
|
|
170
|
+
|
|
171
|
+
if len(inputs) > MAX_WORKFLOW_INPUTS:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"inputs may not exceed {MAX_WORKFLOW_INPUTS} entries"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
for key, value in inputs.items():
|
|
177
|
+
validate_name(key, "inputs key")
|
|
178
|
+
is_primitive = isinstance(value, (str, int, float, bool))
|
|
179
|
+
if not is_primitive:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"inputs value for '{key}' must be a string, number, or boolean"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return inputs
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class CreateIssueCommentInput(BaseModel):
|
|
188
|
+
"""Validated input for creating an issue comment."""
|
|
189
|
+
|
|
190
|
+
model_config = ConfigDict(extra="forbid")
|
|
191
|
+
|
|
192
|
+
body: str = Field(
|
|
193
|
+
...,
|
|
194
|
+
min_length=1,
|
|
195
|
+
max_length=MAX_COMMENT_LENGTH,
|
|
196
|
+
description="Comment body (Markdown)",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class CreateIssueInput(BaseModel):
|
|
201
|
+
"""Validated input for creating an issue."""
|
|
202
|
+
|
|
203
|
+
model_config = ConfigDict(extra="forbid")
|
|
204
|
+
|
|
205
|
+
title: str = Field(
|
|
206
|
+
...,
|
|
207
|
+
min_length=1,
|
|
208
|
+
max_length=MAX_TITLE_LENGTH,
|
|
209
|
+
description="Issue title",
|
|
210
|
+
)
|
|
211
|
+
body: str = Field(
|
|
212
|
+
default="",
|
|
213
|
+
max_length=MAX_BODY_LENGTH,
|
|
214
|
+
description="Issue body (Markdown)",
|
|
215
|
+
)
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""FastMCP server setup for GitHub MCP.
|
|
2
|
+
|
|
3
|
+
This module creates and configures the MCP server with all tools.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastmcp import FastMCP
|
|
10
|
+
|
|
11
|
+
from .tools import (
|
|
12
|
+
create_issue,
|
|
13
|
+
create_issue_comment,
|
|
14
|
+
get_file_content,
|
|
15
|
+
get_issue,
|
|
16
|
+
get_pull_request,
|
|
17
|
+
get_pull_request_checks,
|
|
18
|
+
get_pull_request_diff,
|
|
19
|
+
list_issues,
|
|
20
|
+
list_pull_requests,
|
|
21
|
+
list_repo_tree,
|
|
22
|
+
list_workflow_runs,
|
|
23
|
+
rerun_failed_jobs,
|
|
24
|
+
rerun_workflow_run,
|
|
25
|
+
search_code,
|
|
26
|
+
search_issues,
|
|
27
|
+
trigger_workflow,
|
|
28
|
+
update_issue,
|
|
29
|
+
update_pull_request,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
mcp = FastMCP(
|
|
35
|
+
name="mcp-github",
|
|
36
|
+
version="1.0.1",
|
|
37
|
+
instructions=(
|
|
38
|
+
"Secure MCP server for GitHub repositories: issues, pull requests "
|
|
39
|
+
"(diffs and CI checks), repository files, and code/issue search. "
|
|
40
|
+
"Works with github.com and GitHub Enterprise Server."
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp.tool()
|
|
46
|
+
async def list_issues_tool(
|
|
47
|
+
repo: str,
|
|
48
|
+
owner: str | None = None,
|
|
49
|
+
state: str = "open",
|
|
50
|
+
labels: str | None = None,
|
|
51
|
+
per_page: int = 30,
|
|
52
|
+
page: int = 1,
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
"""List issues for a GitHub repository (pull requests excluded).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
repo: Repository name
|
|
58
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
59
|
+
state: Filter by state (open, closed, all)
|
|
60
|
+
labels: Comma-separated label names
|
|
61
|
+
per_page: Results per page, max 100 (default: 30)
|
|
62
|
+
page: Page number (default: 1)
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List of issues with pagination info
|
|
66
|
+
"""
|
|
67
|
+
return await list_issues(
|
|
68
|
+
owner=owner,
|
|
69
|
+
repo=repo,
|
|
70
|
+
state=state,
|
|
71
|
+
labels=labels,
|
|
72
|
+
per_page=per_page,
|
|
73
|
+
page=page,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@mcp.tool()
|
|
78
|
+
async def get_issue_tool(
|
|
79
|
+
repo: str,
|
|
80
|
+
issue_number: int,
|
|
81
|
+
owner: str | None = None,
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
"""Get a single GitHub issue by number.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
repo: Repository name
|
|
87
|
+
issue_number: Issue number (e.g., #42)
|
|
88
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Issue details
|
|
92
|
+
"""
|
|
93
|
+
return await get_issue(owner=owner, repo=repo, issue_number=issue_number)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@mcp.tool()
|
|
97
|
+
async def create_issue_tool(
|
|
98
|
+
repo: str,
|
|
99
|
+
title: str,
|
|
100
|
+
body: str = "",
|
|
101
|
+
labels: list[str] | None = None,
|
|
102
|
+
owner: str | None = None,
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
"""Create a new issue in a GitHub repository.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
repo: Repository name
|
|
108
|
+
title: Issue title (required, non-empty)
|
|
109
|
+
body: Issue body (Markdown)
|
|
110
|
+
labels: Optional list of label names to apply
|
|
111
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Created issue details (number, html_url, title)
|
|
115
|
+
"""
|
|
116
|
+
return await create_issue(owner=owner, repo=repo, title=title, body=body, labels=labels)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@mcp.tool()
|
|
120
|
+
async def create_issue_comment_tool(
|
|
121
|
+
repo: str,
|
|
122
|
+
issue_number: int,
|
|
123
|
+
body: str,
|
|
124
|
+
owner: str | None = None,
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
"""Create a comment on a GitHub issue or pull request.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
repo: Repository name
|
|
130
|
+
issue_number: Issue or pull request number
|
|
131
|
+
body: Comment body (Markdown)
|
|
132
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Created comment details
|
|
136
|
+
"""
|
|
137
|
+
return await create_issue_comment(owner=owner, repo=repo, issue_number=issue_number, body=body)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@mcp.tool()
|
|
141
|
+
async def update_issue_tool(
|
|
142
|
+
repo: str,
|
|
143
|
+
issue_number: int,
|
|
144
|
+
state: str | None = None,
|
|
145
|
+
state_reason: str | None = None,
|
|
146
|
+
title: str | None = None,
|
|
147
|
+
body: str | None = None,
|
|
148
|
+
labels: list[str] | None = None,
|
|
149
|
+
owner: str | None = None,
|
|
150
|
+
) -> dict[str, Any]:
|
|
151
|
+
"""Update a GitHub issue, including closing or reopening it.
|
|
152
|
+
|
|
153
|
+
Set state="closed" to close an issue. Set state="open" with
|
|
154
|
+
state_reason="reopened" to reopen.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
repo: Repository name
|
|
158
|
+
issue_number: Issue number
|
|
159
|
+
state: "open" or "closed"
|
|
160
|
+
state_reason: "completed", "not_planned", or "reopened"
|
|
161
|
+
title: New title (optional)
|
|
162
|
+
body: New body (optional)
|
|
163
|
+
labels: Replacement list of label names (optional)
|
|
164
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Updated issue details (number, state, html_url, title)
|
|
168
|
+
"""
|
|
169
|
+
return await update_issue(
|
|
170
|
+
owner=owner,
|
|
171
|
+
repo=repo,
|
|
172
|
+
issue_number=issue_number,
|
|
173
|
+
state=state,
|
|
174
|
+
state_reason=state_reason,
|
|
175
|
+
title=title,
|
|
176
|
+
body=body,
|
|
177
|
+
labels=labels,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@mcp.tool()
|
|
182
|
+
async def list_pull_requests_tool(
|
|
183
|
+
repo: str,
|
|
184
|
+
owner: str | None = None,
|
|
185
|
+
state: str = "open",
|
|
186
|
+
per_page: int = 30,
|
|
187
|
+
page: int = 1,
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
"""List pull requests for a GitHub repository.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
repo: Repository name
|
|
193
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
194
|
+
state: Filter by state (open, closed, all)
|
|
195
|
+
per_page: Results per page, max 100 (default: 30)
|
|
196
|
+
page: Page number (default: 1)
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
List of pull requests with pagination info
|
|
200
|
+
"""
|
|
201
|
+
return await list_pull_requests(
|
|
202
|
+
owner=owner,
|
|
203
|
+
repo=repo,
|
|
204
|
+
state=state,
|
|
205
|
+
per_page=per_page,
|
|
206
|
+
page=page,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@mcp.tool()
|
|
211
|
+
async def get_pull_request_tool(
|
|
212
|
+
repo: str,
|
|
213
|
+
pull_number: int,
|
|
214
|
+
owner: str | None = None,
|
|
215
|
+
) -> dict[str, Any]:
|
|
216
|
+
"""Get a single GitHub pull request by number.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
repo: Repository name
|
|
220
|
+
pull_number: Pull request number
|
|
221
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Pull request details including head/base refs and merge status
|
|
225
|
+
"""
|
|
226
|
+
return await get_pull_request(owner=owner, repo=repo, pull_number=pull_number)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@mcp.tool()
|
|
230
|
+
async def get_pull_request_diff_tool(
|
|
231
|
+
repo: str,
|
|
232
|
+
pull_number: int,
|
|
233
|
+
owner: str | None = None,
|
|
234
|
+
) -> dict[str, Any]:
|
|
235
|
+
"""Get the unified diff for a GitHub pull request.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
repo: Repository name
|
|
239
|
+
pull_number: Pull request number
|
|
240
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Dictionary with the diff text under the "content" key
|
|
244
|
+
"""
|
|
245
|
+
return await get_pull_request_diff(owner=owner, repo=repo, pull_number=pull_number)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@mcp.tool()
|
|
249
|
+
async def get_pull_request_checks_tool(
|
|
250
|
+
repo: str,
|
|
251
|
+
pull_number: int,
|
|
252
|
+
owner: str | None = None,
|
|
253
|
+
) -> dict[str, Any]:
|
|
254
|
+
"""Get a CI verdict for a PR that distinguishes skipped from failed.
|
|
255
|
+
|
|
256
|
+
Classifies every check-run and commit-status context into passed,
|
|
257
|
+
failing, pending, or skipped. SKIPPED checks are NOT failures. Use the
|
|
258
|
+
returned ``ready_to_merge`` boolean as the signal for whether the PR is
|
|
259
|
+
clear to merge (True only when nothing is failing or pending and the PR
|
|
260
|
+
is not known-unmergeable).
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
repo: Repository name
|
|
264
|
+
pull_number: Pull request number
|
|
265
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
A verdict with head SHA, mergeability, ready_to_merge, a summary
|
|
269
|
+
count, and per-bucket check lists
|
|
270
|
+
"""
|
|
271
|
+
return await get_pull_request_checks(owner=owner, repo=repo, pull_number=pull_number)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@mcp.tool()
|
|
275
|
+
async def update_pull_request_tool(
|
|
276
|
+
repo: str,
|
|
277
|
+
pull_number: int,
|
|
278
|
+
state: str | None = None,
|
|
279
|
+
title: str | None = None,
|
|
280
|
+
body: str | None = None,
|
|
281
|
+
owner: str | None = None,
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
"""Update a GitHub pull request, including closing or reopening it.
|
|
284
|
+
|
|
285
|
+
This does NOT merge. Set state="closed" to close a PR without merging.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
repo: Repository name
|
|
289
|
+
pull_number: Pull request number
|
|
290
|
+
state: "open" or "closed"
|
|
291
|
+
title: New title (optional)
|
|
292
|
+
body: New body (optional)
|
|
293
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Updated PR details (number, state, html_url, title)
|
|
297
|
+
"""
|
|
298
|
+
return await update_pull_request(
|
|
299
|
+
owner=owner,
|
|
300
|
+
repo=repo,
|
|
301
|
+
pull_number=pull_number,
|
|
302
|
+
state=state,
|
|
303
|
+
title=title,
|
|
304
|
+
body=body,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@mcp.tool()
|
|
309
|
+
async def get_file_content_tool(
|
|
310
|
+
repo: str,
|
|
311
|
+
path: str,
|
|
312
|
+
owner: str | None = None,
|
|
313
|
+
ref: str | None = None,
|
|
314
|
+
) -> dict[str, Any]:
|
|
315
|
+
"""Get the decoded text content of a file in a GitHub repository.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
repo: Repository name
|
|
319
|
+
path: Path to the file within the repository
|
|
320
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
321
|
+
ref: Branch, tag, or commit SHA (default: the default branch)
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
File metadata plus decoded text (or a notice if binary/too large)
|
|
325
|
+
"""
|
|
326
|
+
return await get_file_content(owner=owner, repo=repo, path=path, ref=ref)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@mcp.tool()
|
|
330
|
+
async def list_repo_tree_tool(
|
|
331
|
+
repo: str,
|
|
332
|
+
owner: str | None = None,
|
|
333
|
+
tree_sha: str = "HEAD",
|
|
334
|
+
recursive: bool = False,
|
|
335
|
+
) -> dict[str, Any]:
|
|
336
|
+
"""List the git tree (files and directories) of a GitHub repository.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
repo: Repository name
|
|
340
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
341
|
+
tree_sha: Tree SHA, branch name, or "HEAD" (default: HEAD)
|
|
342
|
+
recursive: Recurse into subtrees (default: false)
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Tree listing with entries and a truncation flag
|
|
346
|
+
"""
|
|
347
|
+
return await list_repo_tree(owner=owner, repo=repo, tree_sha=tree_sha, recursive=recursive)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@mcp.tool()
|
|
351
|
+
async def search_code_tool(
|
|
352
|
+
query: str,
|
|
353
|
+
per_page: int = 30,
|
|
354
|
+
page: int = 1,
|
|
355
|
+
) -> dict[str, Any]:
|
|
356
|
+
"""Search for code across GitHub.
|
|
357
|
+
|
|
358
|
+
Uses GitHub code search syntax (e.g., "addClass repo:jquery/jquery").
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
query: Search query string
|
|
362
|
+
per_page: Results per page, max 100 (default: 30)
|
|
363
|
+
page: Page number (default: 1)
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
Search results with total count and matched code items
|
|
367
|
+
"""
|
|
368
|
+
return await search_code(query=query, per_page=per_page, page=page)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@mcp.tool()
|
|
372
|
+
async def search_issues_tool(
|
|
373
|
+
query: str,
|
|
374
|
+
per_page: int = 30,
|
|
375
|
+
page: int = 1,
|
|
376
|
+
) -> dict[str, Any]:
|
|
377
|
+
"""Search for issues and pull requests across GitHub.
|
|
378
|
+
|
|
379
|
+
Uses GitHub issue search syntax (e.g., "is:open is:pr author:octocat").
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
query: Search query string
|
|
383
|
+
per_page: Results per page, max 100 (default: 30)
|
|
384
|
+
page: Page number (default: 1)
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
Search results with total count and matched issues/PRs
|
|
388
|
+
"""
|
|
389
|
+
return await search_issues(query=query, per_page=per_page, page=page)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@mcp.tool()
|
|
393
|
+
async def list_workflow_runs_tool(
|
|
394
|
+
repo: str,
|
|
395
|
+
owner: str | None = None,
|
|
396
|
+
branch: str | None = None,
|
|
397
|
+
status: str | None = None,
|
|
398
|
+
per_page: int = 20,
|
|
399
|
+
page: int = 1,
|
|
400
|
+
) -> dict[str, Any]:
|
|
401
|
+
"""List GitHub Actions workflow runs for a repository.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
repo: Repository name
|
|
405
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
406
|
+
branch: Filter by head branch name
|
|
407
|
+
status: Filter by status or conclusion (e.g., "completed",
|
|
408
|
+
"in_progress", "queued", "failure", "success")
|
|
409
|
+
per_page: Results per page, max 100 (default: 20)
|
|
410
|
+
page: Page number (default: 1)
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
Trimmed list of workflow runs with total count
|
|
414
|
+
"""
|
|
415
|
+
return await list_workflow_runs(
|
|
416
|
+
owner=owner,
|
|
417
|
+
repo=repo,
|
|
418
|
+
branch=branch,
|
|
419
|
+
status=status,
|
|
420
|
+
per_page=per_page,
|
|
421
|
+
page=page,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@mcp.tool()
|
|
426
|
+
async def trigger_workflow_tool(
|
|
427
|
+
repo: str,
|
|
428
|
+
workflow_id: str,
|
|
429
|
+
ref: str | None = None,
|
|
430
|
+
inputs: dict[str, Any] | None = None,
|
|
431
|
+
owner: str | None = None,
|
|
432
|
+
) -> dict[str, Any]:
|
|
433
|
+
"""Trigger a fresh GitHub Actions run via the workflow_dispatch event.
|
|
434
|
+
|
|
435
|
+
Use this to force a new build. Unlike rerun_workflow_run_tool (which
|
|
436
|
+
re-runs an existing run and is rejected by GitHub for runs older than 30
|
|
437
|
+
days), this starts a brand-new run regardless of when the workflow last
|
|
438
|
+
ran. The target workflow must declare an ``on: workflow_dispatch`` trigger.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
repo: Repository name
|
|
442
|
+
workflow_id: Workflow file name (e.g. "build.yml") or its numeric ID
|
|
443
|
+
ref: Git ref (branch or tag) to run on (default: the repo's
|
|
444
|
+
default branch)
|
|
445
|
+
inputs: Optional workflow_dispatch inputs as name/value pairs
|
|
446
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
A confirmation dict:
|
|
450
|
+
{"status": "dispatch_requested", "workflow": ..., "ref": ...}
|
|
451
|
+
"""
|
|
452
|
+
return await trigger_workflow(
|
|
453
|
+
owner=owner,
|
|
454
|
+
repo=repo,
|
|
455
|
+
workflow_id=workflow_id,
|
|
456
|
+
ref=ref,
|
|
457
|
+
inputs=inputs,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@mcp.tool()
|
|
462
|
+
async def rerun_workflow_run_tool(
|
|
463
|
+
repo: str,
|
|
464
|
+
run_id: int,
|
|
465
|
+
owner: str | None = None,
|
|
466
|
+
) -> dict[str, Any]:
|
|
467
|
+
"""Re-run all jobs in a GitHub Actions workflow run.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
repo: Repository name
|
|
471
|
+
run_id: Workflow run ID
|
|
472
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
A confirmation dict: {"status": "rerun_requested", "run_id": run_id}
|
|
476
|
+
"""
|
|
477
|
+
return await rerun_workflow_run(owner=owner, repo=repo, run_id=run_id)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@mcp.tool()
|
|
481
|
+
async def rerun_failed_jobs_tool(
|
|
482
|
+
repo: str,
|
|
483
|
+
run_id: int,
|
|
484
|
+
owner: str | None = None,
|
|
485
|
+
) -> dict[str, Any]:
|
|
486
|
+
"""Re-run only the failed jobs in a GitHub Actions workflow run.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
repo: Repository name
|
|
490
|
+
run_id: Workflow run ID
|
|
491
|
+
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
A confirmation dict: {"status": "rerun_requested", "run_id": run_id}
|
|
495
|
+
"""
|
|
496
|
+
return await rerun_failed_jobs(owner=owner, repo=repo, run_id=run_id)
|