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