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.2.0" \
version="0.3.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.2.0"
version = "0.3.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
8 changes: 8 additions & 0 deletions src/mcp_github_crunchtools/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,14 @@ async def put(
"""Make a PUT request."""
return await self._request("PUT", path, json_data=json_data)

async def patch(
self,
path: str,
json_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Make a PATCH request."""
return await self._request("PATCH", path, json_data=json_data)

async def delete(self, path: str) -> dict[str, Any]:
"""Make a DELETE request."""
return await self._request("DELETE", path)
Expand Down
77 changes: 77 additions & 0 deletions src/mcp_github_crunchtools/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
rerun_workflow_run,
search_code,
search_issues,
update_issue,
update_pull_request,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -138,6 +140,47 @@ async def create_issue_comment_tool(
)


@mcp.tool()
async def update_issue_tool(
repo: str,
issue_number: int,
state: str | None = None,
state_reason: str | None = None,
title: str | None = None,
body: str | None = None,
labels: list[str] | None = None,
owner: str | None = None,
) -> dict[str, Any]:
"""Update a GitHub issue, including closing or reopening it.

Set state="closed" to close an issue. Set state="open" with
state_reason="reopened" to reopen.

Args:
repo: Repository name
issue_number: Issue number
state: "open" or "closed"
state_reason: "completed", "not_planned", or "reopened"
title: New title (optional)
body: New body (optional)
labels: Replacement list of label names (optional)
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)

Returns:
Updated issue details (number, state, html_url, title)
"""
return await update_issue(
owner=owner,
repo=repo,
issue_number=issue_number,
state=state,
state_reason=state_reason,
title=title,
body=body,
labels=labels,
)


@mcp.tool()
async def list_pull_requests_tool(
repo: str,
Expand Down Expand Up @@ -235,6 +278,40 @@ async def get_pull_request_checks_tool(
)


@mcp.tool()
async def update_pull_request_tool(
repo: str,
pull_number: int,
state: str | None = None,
title: str | None = None,
body: str | None = None,
owner: str | None = None,
) -> dict[str, Any]:
"""Update a GitHub pull request, including closing or reopening it.

This does NOT merge. Set state="closed" to close a PR without merging.

Args:
repo: Repository name
pull_number: Pull request number
state: "open" or "closed"
title: New title (optional)
body: New body (optional)
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)

Returns:
Updated PR details (number, state, html_url, title)
"""
return await update_pull_request(
owner=owner,
repo=repo,
pull_number=pull_number,
state=state,
title=title,
body=body,
)


@mcp.tool()
async def get_file_content_tool(
repo: str,
Expand Down
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 @@ -9,12 +9,19 @@
rerun_workflow_run,
)
from .files import get_file_content, list_repo_tree
from .issues import create_issue, create_issue_comment, get_issue, list_issues
from .issues import (
create_issue,
create_issue_comment,
get_issue,
list_issues,
update_issue,
)
from .pull_requests import (
get_pull_request,
get_pull_request_checks,
get_pull_request_diff,
list_pull_requests,
update_pull_request,
)
from .search import search_code, search_issues

Expand All @@ -23,10 +30,12 @@
"get_issue",
"create_issue",
"create_issue_comment",
"update_issue",
"list_pull_requests",
"get_pull_request",
"get_pull_request_diff",
"get_pull_request_checks",
"update_pull_request",
"get_file_content",
"list_repo_tree",
"search_code",
Expand Down
63 changes: 63 additions & 0 deletions src/mcp_github_crunchtools/tools/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,69 @@ async def create_issue(
}


async def update_issue(
owner: str | None,
repo: str,
issue_number: int,
state: str | None = None,
state_reason: str | None = None,
title: str | None = None,
body: str | None = None,
labels: list[str] | None = None,
) -> dict[str, Any]:
"""Update an existing issue — including closing or reopening it.

Args:
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
repo: Repository name
issue_number: Issue number
state: "open" or "closed" (set "closed" to close the issue)
state_reason: When closing, one of "completed" or "not_planned";
when reopening, "reopened"
title: New title (optional)
body: New body (optional)
labels: Replacement list of label names (optional)

Returns:
Updated issue details (number, state, html_url, title)
"""
owner = resolve_owner(owner)
repo = validate_name(repo, "repo")
issue_number = validate_positive_int(issue_number, "issue_number")

json_data: dict[str, Any] = {}
if state is not None:
if state not in ("open", "closed"):
raise ValidationError("state must be 'open' or 'closed'")
json_data["state"] = state
if state_reason is not None:
if state_reason not in ("completed", "not_planned", "reopened"):
raise ValidationError(
"state_reason must be 'completed', 'not_planned', or 'reopened'"
)
json_data["state_reason"] = state_reason
if title is not None:
json_data["title"] = title
Comment on lines +176 to +177

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

When updating an issue, we should validate that the title is not empty or just whitespace if it is provided. This prevents sending invalid requests to the GitHub API, which would result in a 422 Unprocessable Entity error. Additionally, we should strip leading and trailing whitespace from the title for consistency with create_issue.

Suggested change
if title is not None:
json_data["title"] = title
if title is not None:
if not title.strip():
raise ValidationError("title must not be empty")
json_data["title"] = title.strip()

if body is not None:
json_data["body"] = body
if labels is not None:
json_data["labels"] = labels
if not json_data:
raise ValidationError("no fields to update")

client = get_client()
result = await client.patch(
f"/repos/{owner}/{repo}/issues/{issue_number}",
json_data=json_data,
)
return {
"number": result.get("number"),
"state": result.get("state"),
"html_url": result.get("html_url"),
"title": result.get("title"),
}


async def create_issue_comment(
owner: str | None,
repo: str,
Expand Down
52 changes: 52 additions & 0 deletions src/mcp_github_crunchtools/tools/pull_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,55 @@ async def get_pull_request_checks(
"skipped": skipped,
"passed": passed,
}


async def update_pull_request(
owner: str | None,
repo: str,
pull_number: int,
state: str | None = None,
title: str | None = None,
body: str | None = None,
) -> dict[str, Any]:
"""Update a pull request — including closing or reopening it.

Note: this does NOT merge. Set state="closed" to close a PR without merging.

Args:
owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
repo: Repository name
pull_number: Pull request number
state: "open" or "closed" (set "closed" to close the PR)
title: New title (optional)
body: New body (optional)

Returns:
Updated PR details (number, state, html_url, title)
"""
owner = resolve_owner(owner)
repo = validate_name(repo, "repo")
pull_number = validate_positive_int(pull_number, "pull_number")

json_data: dict[str, Any] = {}
if state is not None:
if state not in ("open", "closed"):
raise ValidationError("state must be 'open' or 'closed'")
json_data["state"] = state
if title is not None:
json_data["title"] = title
Comment on lines +270 to +271

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

When updating a pull request, we should validate that the title is not empty or just whitespace if it is provided. This prevents sending invalid requests to the GitHub API, which would result in a 422 Unprocessable Entity error. Additionally, we should strip leading and trailing whitespace from the title for consistency.

Suggested change
if title is not None:
json_data["title"] = title
if title is not None:
if not title.strip():
raise ValidationError("title must not be empty")
json_data["title"] = title.strip()

if body is not None:
json_data["body"] = body
if not json_data:
raise ValidationError("no fields to update")

client = get_client()
result = await client.patch(
f"/repos/{owner}/{repo}/pulls/{pull_number}",
json_data=json_data,
)
return {
"number": result.get("number"),
"state": result.get("state"),
"html_url": result.get("html_url"),
"title": result.get("title"),
}
55 changes: 53 additions & 2 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ def test_imports(self) -> None:
assert callable(func), f"{name} is not callable"

def test_tool_count(self) -> None:
"""Server should export exactly 15 tools."""
"""Server should export exactly 17 tools."""
from mcp_github_crunchtools.tools import __all__

assert len(__all__) == 15
assert len(__all__) == 17


class TestErrorSafety:
Expand Down Expand Up @@ -314,6 +314,57 @@ async def test_create_issue(self) -> None:
"title": "Bug found",
}

async def test_update_issue_close(self) -> None:
from mcp_github_crunchtools.tools import update_issue

resp = _mock_response(
json_data={
"number": 4,
"state": "closed",
"html_url": "https://github.com/o/r/issues/4",
"title": "Fixed",
},
)

with _patch_client(resp) as mock_client:
result = await update_issue(
owner="o",
repo="r",
issue_number=4,
state="closed",
state_reason="completed",
)
call = mock_client.return_value.request.call_args
assert call.kwargs["method"] == "PATCH"
assert call.kwargs["json"]["state"] == "closed"
assert call.kwargs["json"]["state_reason"] == "completed"

assert result["state"] == "closed"
assert result["number"] == 4

async def test_update_pull_request_close(self) -> None:
from mcp_github_crunchtools.tools import update_pull_request

resp = _mock_response(
json_data={
"number": 3,
"state": "closed",
"html_url": "https://github.com/o/r/pull/3",
"title": "WIP",
},
)

with _patch_client(resp) as mock_client:
result = await update_pull_request(
owner="o", repo="r", pull_number=3, state="closed"
)
call = mock_client.return_value.request.call_args
assert call.kwargs["method"] == "PATCH"
assert "/pulls/3" in call.kwargs["url"]
assert call.kwargs["json"]["state"] == "closed"

assert result["state"] == "closed"

@pytest.mark.asyncio
async def test_create_issue_empty_title(self) -> None:
from mcp_github_crunchtools.errors import ValidationError
Expand Down
Loading