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
13 changes: 9 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,21 @@ claude mcp add mcp-github-crunchtools \
| `SSL_CERT_FILE` | No | — | Custom CA bundle for self-hosted instances |
| `GITHUB_SSL_VERIFY` | No | `true` | Set `false` to disable SSL verification |

## Available Tools (11)
## Available Tools (18)

| Category | Tools |
|----------|-------|
| Issues | `list_issues_tool`, `get_issue_tool`, `create_issue_comment_tool` |
| Pull Requests | `list_pull_requests_tool`, `get_pull_request_tool`, `get_pull_request_diff_tool`, `get_pull_request_checks_tool` |
| Issues | `list_issues_tool`, `get_issue_tool`, `create_issue_tool`, `create_issue_comment_tool`, `update_issue_tool` |
| Pull Requests | `list_pull_requests_tool`, `get_pull_request_tool`, `get_pull_request_diff_tool`, `get_pull_request_checks_tool`, `update_pull_request_tool` |
| Files | `get_file_content_tool`, `list_repo_tree_tool` |
| Search | `search_code_tool`, `search_issues_tool` |
| Actions | `list_workflow_runs_tool`, `trigger_workflow_tool`, `rerun_workflow_run_tool`, `rerun_failed_jobs_tool` |

`create_issue_comment_tool` is the only write tool; the gateway scopes it.
Write tools (`create_issue*`, `update_*`, `trigger_workflow_tool`, `rerun_*`)
are scoped per-profile by the gateway. `trigger_workflow_tool` dispatches a
fresh run (via `workflow_dispatch`) and is the way to *force* a build —
`rerun_*` only re-run an existing run and GitHub rejects runs older than 30
days.

## Example Usage

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.3.0"
version = "0.4.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
2 changes: 1 addition & 1 deletion src/mcp_github_crunchtools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

from .server import mcp

__version__ = "0.1.0"
__version__ = "0.4.0"
__all__ = ["main", "mcp"]


Expand Down
11 changes: 8 additions & 3 deletions src/mcp_github_crunchtools/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,12 @@ async def _request(
responses (such as raw diffs) are returned as {"content": text}.

Raises:
GitHubApiError: On API errors
GitHubApiError: On API errors, including non-rate-limit 403s
(e.g. re-running a workflow run older than 30 days), whose
original GitHub message is preserved so callers can tell a
real scope problem from other 403 conditions
RateLimitError: On rate limiting
PermissionDeniedError: On authorization failures
PermissionDeniedError: On 401 authorization failures
NotFoundError: When the resource does not exist
"""
client = await self._get_client()
Expand Down Expand Up @@ -202,8 +205,10 @@ def _handle_error_response(self, response: httpx.Response) -> None:
retry_after = response.headers.get("x-ratelimit-reset")
raise RateLimitError(int(retry_after) if retry_after else None)

if status_code in (401, 403):
if status_code == 401:
raise PermissionDeniedError("Valid token with required scopes")
if status_code == 403:
raise GitHubApiError(status_code, error_msg)
if status_code == 404:
raise NotFoundError(error_msg)

Expand Down
82 changes: 82 additions & 0 deletions src/mcp_github_crunchtools/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .config import get_config

SAFE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
SAFE_REF_PATTERN = re.compile(r"^[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$")

ISSUE_STATES = frozenset({"open", "closed", "all"})
PR_STATES = frozenset({"open", "closed", "all"})
Expand All @@ -22,6 +23,8 @@
MAX_PER_PAGE = 100
MAX_TITLE_LENGTH = 256
MAX_BODY_LENGTH = 65536
MAX_REF_LENGTH = 255
MAX_WORKFLOW_INPUTS = 32


def resolve_owner(owner: str | None) -> str:
Expand Down Expand Up @@ -102,6 +105,85 @@ def clamp_per_page(per_page: int) -> int:
return max(1, min(per_page, MAX_PER_PAGE))


def validate_ref(value: str, field: str = "ref") -> str:
"""Validate a git ref (branch or tag name).

Unlike names, refs may contain slashes between non-empty segments (e.g.
"release/1.2"), but must reject whitespace, control characters, ".."
traversal, empty "//" segments, and leading/trailing "." or "/" — in
line with git's check-ref-format rules, so invalid refs are caught here
rather than as a GitHub 422.

Args:
value: The ref to validate.
field: Field name for error messages.

Returns:
The stripped, validated ref.

Raises:
ValueError: If the ref is empty, too long, malformed, or contains
disallowed characters.
"""
if not value or not value.strip():
raise ValueError(f"{field} must not be empty")

value = value.strip()

if len(value) > MAX_REF_LENGTH:
raise ValueError(f"{field} is too long")

if (
".." in value
or "//" in value
or "./" in value
or value.startswith((".", "/"))
or value.endswith((".", "/"))
):
raise ValueError(f"{field} is not a valid git ref")

if not SAFE_REF_PATTERN.match(value):
raise ValueError(
f"{field} must contain only letters, digits, dots, hyphens, "
"underscores, and slashes"
)

return value


def validate_workflow_inputs(inputs: dict[str, object]) -> dict[str, object]:
"""Validate workflow_dispatch inputs before sending them to GitHub.

Args:
inputs: Mapping of input name to a primitive value.

Returns:
The validated inputs mapping.

Raises:
ValueError: If there are too many inputs, a key is not a safe name,
or a value is not a string, number, or boolean.
"""
is_mapping = isinstance(inputs, dict)
if not is_mapping:
raise ValueError("inputs must be an object of name/value pairs")

if len(inputs) > MAX_WORKFLOW_INPUTS:
raise ValueError(
f"inputs may not exceed {MAX_WORKFLOW_INPUTS} entries"
)

for key, value in inputs.items():
validate_name(key, "inputs key")
is_primitive = isinstance(value, (str, int, float, bool))
if not is_primitive:
raise ValueError(
f"inputs value for '{key}' must be a string, number, or boolean"
)

return inputs
Comment on lines +176 to +184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The validate_name function strips leading and trailing whitespace from the key and returns the cleaned key. However, the original inputs dictionary is returned unmodified, meaning any unstripped keys (which might bypass the regex validation or cause issues later) are still sent to the GitHub API. Constructing and returning a new dictionary with the validated keys ensures the validation is actually applied.

Suggested change
for key, value in inputs.items():
validate_name(key, "inputs key")
is_primitive = isinstance(value, (str, int, float, bool))
if not is_primitive:
raise ValueError(
f"inputs value for '{key}' must be a string, number, or boolean"
)
return inputs
validated: dict[str, object] = {}
for key, value in inputs.items():
clean_key = validate_name(key, "inputs key")
is_primitive = isinstance(value, (str, int, float, bool))
if not is_primitive:
raise ValueError(
f"inputs value for '{key}' must be a string, number, or boolean"
)
validated[clean_key] = value
return validated



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

Expand Down
39 changes: 38 additions & 1 deletion src/mcp_github_crunchtools/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
rerun_workflow_run,
search_code,
search_issues,
trigger_workflow,
update_issue,
update_pull_request,
)
Expand All @@ -32,7 +33,7 @@

mcp = FastMCP(
name="mcp-github",
version="0.2.0",
version="0.4.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 @@ -431,6 +432,42 @@ async def list_workflow_runs_tool(
)


@mcp.tool()
async def trigger_workflow_tool(
repo: str,
workflow_id: str,
ref: str | None = None,
inputs: dict[str, Any] | None = None,
owner: str | None = None,
) -> dict[str, Any]:
"""Trigger a fresh GitHub Actions run via the workflow_dispatch event.

Use this to force a new build. Unlike rerun_workflow_run_tool (which
re-runs an existing run and is rejected by GitHub for runs older than 30
days), this starts a brand-new run regardless of when the workflow last
ran. The target workflow must declare an ``on: workflow_dispatch`` trigger.

Args:
repo: Repository name
workflow_id: Workflow file name (e.g. "build.yml") or its numeric ID
ref: Git ref (branch or tag) to run on (default: the repo's
default branch)
inputs: Optional workflow_dispatch inputs as name/value pairs
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)

Returns:
A confirmation dict:
{"status": "dispatch_requested", "workflow": ..., "ref": ...}
"""
return await trigger_workflow(
owner=owner,
repo=repo,
workflow_id=workflow_id,
ref=ref,
inputs=inputs,
)


@mcp.tool()
async def rerun_workflow_run_tool(
repo: str,
Expand Down
2 changes: 2 additions & 0 deletions src/mcp_github_crunchtools/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
list_workflow_runs,
rerun_failed_jobs,
rerun_workflow_run,
trigger_workflow,
)
from .files import get_file_content, list_repo_tree
from .issues import (
Expand Down Expand Up @@ -41,6 +42,7 @@
"search_code",
"search_issues",
"list_workflow_runs",
"trigger_workflow",
"rerun_workflow_run",
"rerun_failed_jobs",
]
65 changes: 64 additions & 1 deletion src/mcp_github_crunchtools/tools/actions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""GitHub Actions tools.

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

from typing import Any
Expand All @@ -11,6 +12,8 @@
resolve_owner,
validate_name,
validate_positive_int,
validate_ref,
validate_workflow_inputs,
)


Expand Down Expand Up @@ -74,13 +77,68 @@ async def list_workflow_runs(
}


async def trigger_workflow(
owner: str | None,
repo: str,
workflow_id: str,
ref: str | None = None,
inputs: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Trigger a fresh GitHub Actions run via the workflow_dispatch event.

Unlike ``rerun_workflow_run`` (which re-runs an *existing* run and is
rejected by GitHub with a 403 for runs created more than 30 days ago),
this dispatches a brand-new run, so it works no matter how long ago the
workflow last ran. The target workflow's YAML must declare an
``on: workflow_dispatch`` trigger.

Args:
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
repo: Repository name
workflow_id: Workflow file name (e.g. "build.yml") or its numeric ID
ref: Git ref (branch or tag) to run on. Defaults to the
repository's default branch when omitted.
inputs: Optional workflow_dispatch inputs as name/value pairs

Returns:
A confirmation dict:
{"status": "dispatch_requested", "workflow": ..., "ref": ...}
"""
owner = resolve_owner(owner)
repo = validate_name(repo, "repo")
workflow_id = validate_name(workflow_id, "workflow_id")

client = get_client()

if ref is None or not ref.strip():
repo_info = await client.get(f"/repos/{owner}/{repo}")
ref = repo_info.get("default_branch", "main")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the default_branch key exists in the API response but its value is explicitly null (which can happen in some repository states), repo_info.get("default_branch", "main") will return None instead of falling back to "main". This would cause a type/runtime error when passed to validate_ref. Using or "main" ensures a safe fallback.

Suggested change
ref = repo_info.get("default_branch", "main")
ref = repo_info.get("default_branch") or "main"

ref = validate_ref(ref)

body: dict[str, Any] = {"ref": ref}
if inputs:
body["inputs"] = validate_workflow_inputs(inputs)

await client.post(
f"/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches",
json_data=body,
)
return {"status": "dispatch_requested", "workflow": workflow_id, "ref": ref}


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.

GitHub rejects re-runs of runs created more than 30 days ago with a 403
("Unable to retry this workflow run because it was created over a month
ago"). That is NOT a permission problem: it surfaces as a GitHubApiError
carrying GitHub's message, not a PermissionDeniedError (which is reserved
for 401). To force a fresh build regardless of age, use trigger_workflow.

Args:
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
repo: Repository name
Expand All @@ -105,6 +163,11 @@ async def rerun_failed_jobs(
) -> dict[str, Any]:
"""Re-run only the failed jobs in a GitHub Actions workflow run.

As with rerun_workflow_run, GitHub rejects re-runs of runs older than 30
days with a 403 that surfaces as a GitHubApiError (GitHub's message
preserved), not a PermissionDeniedError. Use trigger_workflow to force a
fresh build regardless of age.

Args:
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
repo: Repository name
Expand Down
Loading
Loading