Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ FROM quay.io/hummingbird/python:latest

# Labels for container metadata
LABEL name="mcp-github-crunchtools" \
version="0.1.0" \
version="0.2.0" \
summary="Secure MCP server for GitHub issues, pull requests, files, and search" \
description="A security-focused MCP server for GitHub built on Red Hat UBI" \
maintainer="crunchtools.com" \
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "mcp-github-crunchtools"
version = "0.1.0"
version = "0.2.0"
description = "Secure MCP server for GitHub issues, pull requests, files, and search"
requires-python = ">=3.10"
license = "AGPL-3.0-or-later"
Expand Down
20 changes: 20 additions & 0 deletions src/mcp_github_crunchtools/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
MAX_COMMENT_LENGTH = 65536
MAX_QUERY_LENGTH = 1000
MAX_PER_PAGE = 100
MAX_TITLE_LENGTH = 256
MAX_BODY_LENGTH = 65536


def resolve_owner(owner: str | None) -> str:
Expand Down Expand Up @@ -111,3 +113,21 @@ class CreateIssueCommentInput(BaseModel):
max_length=MAX_COMMENT_LENGTH,
description="Comment body (Markdown)",
)


class CreateIssueInput(BaseModel):
"""Validated input for creating an issue."""

model_config = ConfigDict(extra="forbid")

title: str = Field(
...,
min_length=1,
max_length=MAX_TITLE_LENGTH,
description="Issue title",
)
body: str = Field(
default="",
max_length=MAX_BODY_LENGTH,
description="Issue body (Markdown)",
)
114 changes: 109 additions & 5 deletions src/mcp_github_crunchtools/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from fastmcp import FastMCP

from .tools import (
create_issue,
create_issue_comment,
get_file_content,
get_issue,
Expand All @@ -18,6 +19,9 @@
list_issues,
list_pull_requests,
list_repo_tree,
list_workflow_runs,
rerun_failed_jobs,
rerun_workflow_run,
search_code,
search_issues,
)
Expand All @@ -26,7 +30,7 @@

mcp = FastMCP(
name="mcp-github",
version="0.1.0",
version="0.2.0",
instructions=(
"Secure MCP server for GitHub repositories: issues, pull requests "
"(diffs and CI checks), repository files, and code/issue search. "
Expand Down Expand Up @@ -86,6 +90,31 @@ async def get_issue_tool(
return await get_issue(owner=owner, repo=repo, issue_number=issue_number)


@mcp.tool()
async def create_issue_tool(
repo: str,
title: str,
body: str = "",
labels: list[str] | None = None,
owner: str | None = None,
) -> dict[str, Any]:
"""Create a new issue in a GitHub repository.

Args:
repo: Repository name
title: Issue title (required, non-empty)
body: Issue body (Markdown)
labels: Optional list of label names to apply
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)

Returns:
Created issue details (number, html_url, title)
"""
return await create_issue(
owner=owner, repo=repo, title=title, body=body, labels=labels
)


@mcp.tool()
async def create_issue_comment_tool(
repo: str,
Expand Down Expand Up @@ -184,18 +213,22 @@ async def get_pull_request_checks_tool(
pull_number: int,
owner: str | None = None,
) -> dict[str, Any]:
"""Get a combined CI status summary for a GitHub pull request.
"""Get a CI verdict for a PR that distinguishes skipped from failed.

Combines check-runs and the legacy commit status into one summary,
including the overall state and per-check conclusions.
Classifies every check-run and commit-status context into passed,
failing, pending, or skipped. SKIPPED checks are NOT failures. Use the
returned ``ready_to_merge`` boolean as the signal for whether the PR is
clear to merge (True only when nothing is failing or pending and the PR
is not known-unmergeable).

Args:
repo: Repository name
pull_number: Pull request number
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)

Returns:
Summary with head SHA, overall state, and per-check conclusions
A verdict with head SHA, mergeability, ready_to_merge, a summary
count, and per-bucket check lists
"""
return await get_pull_request_checks(
owner=owner, repo=repo, pull_number=pull_number
Expand Down Expand Up @@ -286,3 +319,74 @@ async def search_issues_tool(
Search results with total count and matched issues/PRs
"""
return await search_issues(query=query, per_page=per_page, page=page)


@mcp.tool()
async def list_workflow_runs_tool(
repo: str,
owner: str | None = None,
branch: str | None = None,
status: str | None = None,
per_page: int = 20,
page: int = 1,
) -> dict[str, Any]:
"""List GitHub Actions workflow runs for a repository.

Args:
repo: Repository name
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
branch: Filter by head branch name
status: Filter by status or conclusion (e.g., "completed",
"in_progress", "queued", "failure", "success")
per_page: Results per page, max 100 (default: 20)
page: Page number (default: 1)

Returns:
Trimmed list of workflow runs with total count
"""
return await list_workflow_runs(
owner=owner,
repo=repo,
branch=branch,
status=status,
per_page=per_page,
page=page,
)


@mcp.tool()
async def rerun_workflow_run_tool(
repo: str,
run_id: int,
owner: str | None = None,
) -> dict[str, Any]:
"""Re-run all jobs in a GitHub Actions workflow run.

Args:
repo: Repository name
run_id: Workflow run ID
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)

Returns:
A confirmation dict: {"status": "rerun_requested", "run_id": run_id}
"""
return await rerun_workflow_run(owner=owner, repo=repo, run_id=run_id)


@mcp.tool()
async def rerun_failed_jobs_tool(
repo: str,
run_id: int,
owner: str | None = None,
) -> dict[str, Any]:
"""Re-run only the failed jobs in a GitHub Actions workflow run.

Args:
repo: Repository name
run_id: Workflow run ID
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)

Returns:
A confirmation dict: {"status": "rerun_requested", "run_id": run_id}
"""
return await rerun_failed_jobs(owner=owner, repo=repo, run_id=run_id)
11 changes: 10 additions & 1 deletion src/mcp_github_crunchtools/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
This package contains all the MCP tool implementations for GitHub operations.
"""

from .actions import (
list_workflow_runs,
rerun_failed_jobs,
rerun_workflow_run,
)
from .files import get_file_content, list_repo_tree
from .issues import create_issue_comment, get_issue, list_issues
from .issues import create_issue, create_issue_comment, get_issue, list_issues
from .pull_requests import (
get_pull_request,
get_pull_request_checks,
Expand All @@ -16,6 +21,7 @@
__all__ = [
"list_issues",
"get_issue",
"create_issue",
"create_issue_comment",
"list_pull_requests",
"get_pull_request",
Expand All @@ -25,4 +31,7 @@
"list_repo_tree",
"search_code",
"search_issues",
"list_workflow_runs",
"rerun_workflow_run",
"rerun_failed_jobs",
]
124 changes: 124 additions & 0 deletions src/mcp_github_crunchtools/tools/actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""GitHub Actions tools.

Tools for listing workflow runs and re-running CI on GitHub Actions.
"""

from typing import Any

from ..client import get_client
from ..models import (
clamp_per_page,
resolve_owner,
validate_name,
validate_positive_int,
)


async def list_workflow_runs(
owner: str | None,
repo: str,
branch: str | None = None,
status: str | None = None,
per_page: int = 20,
page: int = 1,
) -> dict[str, Any]:
"""List GitHub Actions workflow runs for a repository.

Args:
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
repo: Repository name
branch: Filter by head branch name
status: Filter by status or conclusion (e.g., "completed",
"in_progress", "queued", "failure", "success")
per_page: Results per page, max 100 (default: 20)
page: Page number (default: 1)

Returns:
Trimmed list of workflow runs with pagination info
"""
owner = resolve_owner(owner)
repo = validate_name(repo, "repo")

params: dict[str, Any] = {
"per_page": clamp_per_page(per_page),
"page": validate_positive_int(page, "page"),
}
if branch:
params["branch"] = branch
if status:
params["status"] = status

client = get_client()
result = await client.get(
f"/repos/{owner}/{repo}/actions/runs", params=params
)

runs = result.get("workflow_runs", [])
items = [
{
"id": run.get("id"),
"name": run.get("name"),
"head_branch": run.get("head_branch"),
"event": run.get("event"),
"status": run.get("status"),
"conclusion": run.get("conclusion"),
"html_url": run.get("html_url"),
"created_at": run.get("created_at"),
}
for run in runs
]

return {
"total_count": result.get("total_count", len(items)),
"items": items,
}


async def rerun_workflow_run(
owner: str | None,
repo: str,
run_id: int,
) -> dict[str, Any]:
"""Re-run all jobs in a GitHub Actions workflow run.

Args:
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
repo: Repository name
run_id: Workflow run ID

Returns:
A confirmation dict: {"status": "rerun_requested", "run_id": run_id}
"""
owner = resolve_owner(owner)
repo = validate_name(repo, "repo")
run_id = validate_positive_int(run_id, "run_id")

client = get_client()
await client.post(f"/repos/{owner}/{repo}/actions/runs/{run_id}/rerun")
return {"status": "rerun_requested", "run_id": run_id}


async def rerun_failed_jobs(
owner: str | None,
repo: str,
run_id: int,
) -> dict[str, Any]:
"""Re-run only the failed jobs in a GitHub Actions workflow run.

Args:
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
repo: Repository name
run_id: Workflow run ID

Returns:
A confirmation dict: {"status": "rerun_requested", "run_id": run_id}
"""
owner = resolve_owner(owner)
repo = validate_name(repo, "repo")
run_id = validate_positive_int(run_id, "run_id")

client = get_client()
await client.post(
f"/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs"
)
return {"status": "rerun_requested", "run_id": run_id}
Loading
Loading