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
1 change: 1 addition & 0 deletions packages/gg_api_core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies = [
"httpx~=0.28",
"fastmcp~=3.0",
"python-dotenv~=1.1",
"pydantic~=2.12",
"pydantic-settings~=2.9",
"jinja2~=3.1",
]
Expand Down
29 changes: 20 additions & 9 deletions packages/gg_api_core/src/gg_api_core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
import logging
import re
from collections.abc import Sequence
from datetime import datetime
from enum import Enum
from importlib.metadata import PackageNotFoundError, version
from typing import Any, Dict, Optional, TypedDict, cast
from urllib.parse import quote_plus, unquote, urlparse

import httpx
from pydantic import TypeAdapter, ValidationError

from gg_api_core.settings import get_settings

Expand All @@ -22,6 +24,14 @@
DEFAULT_USER_AGENT = "GitGuardian-MCP-Server"


def _to_date_only(value: str) -> str:
"""Normalize a date/datetime string to its ``YYYY-MM-DD`` portion."""
try:
return TypeAdapter(datetime).validate_python(value.strip()).date().isoformat()
except ValidationError as exc:
raise ValueError(f"Invalid date value: {value!r}") from exc


class DownstreamUnauthorizedError(Exception):
"""Raised when the downstream GitGuardian API returns 401.

Expand Down Expand Up @@ -1092,7 +1102,7 @@ async def get_incidents(self, incident_ids: list[int]) -> list[dict[str, Any]]:
async def update_incident(
self,
incident_id: str,
status: str | None = None,
severity: str | None = None,
custom_tags: list | None = None,
) -> dict[str, Any]:
"""Update a secret incident.
Expand All @@ -1106,16 +1116,16 @@ async def update_incident(
Returns:
Updated incident data
"""
logger.info(f"Updating incident {incident_id} with status={status}, custom_tags={custom_tags}")
logger.info(f"Updating incident {incident_id} with severity={severity}, custom_tags={custom_tags}")

payload: dict[str, Any] = {}
if status:
payload["status"] = status
if severity:
payload["severity"] = severity
if custom_tags:
payload["custom_tags"] = custom_tags

if not payload:
raise ValueError("At least one of status or custom_tags must be provided")
raise ValueError("At least one of severity or custom_tags must be provided")

return await self._request_patch(f"/incidents/secrets/{incident_id}", json=payload)

Expand Down Expand Up @@ -2639,9 +2649,9 @@ def format_param(value: Any) -> str:

# Date filters
if date_before:
params["date__le"] = date_before
params["date__le"] = _to_date_only(date_before)
if date_after:
params["date__ge"] = date_after
params["date__ge"] = _to_date_only(date_after)

# Secret scope filter
if secret_scope:
Expand Down Expand Up @@ -2812,10 +2822,11 @@ def format_param(value: Any) -> str:
params["teams__in"] = format_param(teams)
if similar_to is not None:
params["similar_to"] = similar_to
# Date filters
if date_before:
params["date__le"] = date_before
params["date__le"] = _to_date_only(date_before)
if date_after:
params["date__ge"] = date_after
params["date__ge"] = _to_date_only(date_after)
if secret_scope:
params["secret_scope__in"] = format_param(secret_scope)
if analyzer_status:
Expand Down
22 changes: 12 additions & 10 deletions packages/gg_api_core/src/gg_api_core/tools/manage_incident.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,30 +99,32 @@ async def manage_private_incident(params: ManageIncidentParams) -> dict[str, Any
raise ToolError(f"Error: {str(e)}")


class UpdateIncidentStatusParams(BaseModel):
"""Parameters for updating incident status."""
class UpdateIncidentSeverityParams(BaseModel):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What is the relation between the previous (broken) status tool and the new one related to severity? I don't really understand why we do this replacement (I understand why the previous one was broken)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The "PATCH incident" endpoint exists, and it allows to update an incident's severity: https://api.gitguardian.com/docs#tag/Internal-Secret-Incidents/operation/update-secret-incident
I considered it was more interesting to add this feature that was already 90% implemented rather than just delete the broken tool.

"""Parameters for updating incident severity."""

incident_id: str | int = Field(description="ID of the secret incident")
status: str = Field(description="New status (IGNORED, TRIGGERED, ASSIGNED, RESOLVED)")
severity: Literal["critical", "high", "medium", "low", "info", "unknown"] = Field(
description="New severity for the incident"
)


async def update_incident_status(params: UpdateIncidentStatusParams) -> dict[str, Any]:
async def update_incident_severity(params: UpdateIncidentSeverityParams) -> dict[str, Any]:
"""
Update a secret incident with status and/or custom tags.
Set the severity of a secret incident.

Args:
params: UpdateIncidentStatusParams model containing status update configuration
params: UpdateIncidentSeverityParams model containing the severity update configuration

Returns:
Updated incident data
"""
client = await get_client()
logger.debug(f"Updating incident {params.incident_id} status to {params.status}")
logger.debug(f"Updating incident {params.incident_id} severity to {params.severity}")

try:
result = await client.update_incident(incident_id=str(params.incident_id), status=params.status)
logger.debug(f"Updated incident {params.incident_id} status to {params.status}")
result = await client.update_incident(incident_id=str(params.incident_id), severity=params.severity)
logger.debug(f"Updated incident {params.incident_id} severity to {params.severity}")
return result
except Exception as e:
logger.exception(f"Error updating incident status: {str(e)}")
logger.exception(f"Error updating incident severity: {str(e)}")
raise ToolError(f"Error: {str(e)}")
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ async def update_public_incident_status(params: UpdatePublicIncidentStatusParams
Public incidents are surfaced by GitGuardian Public Monitoring (public GitHub repos/gists,
Docker Hub, etc.). Public incident IDs are NOT interchangeable with internal incident IDs;
use this tool — not `manage_private_incident` / `update_incident_status` — when acting on
use this tool — not `manage_private_incident` — when acting on
results from `list_public_incidents` or `get_public_incident`.
Supported actions:
Expand Down
8 changes: 4 additions & 4 deletions packages/gg_mcp_server/src/gg_mcp_server/register_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
from gg_api_core.tools.list_users import list_users
from gg_api_core.tools.manage_incident import (
manage_private_incident,
update_incident_status,
update_incident_severity,
)
from gg_api_core.tools.read_custom_tags import read_custom_tags
from gg_api_core.tools.remediate_secret_incidents import remediate_secret_incidents
Expand Down Expand Up @@ -74,7 +74,7 @@
Read tools: `list_incidents`, `count_incidents`, `get_incident`, `list_repo_occurrences`,
`remediate_secret_incidents`, `list_sources`, `find_current_source_id`, `list_incident_comments`,
`list_incident_activity_logs`.
Write tools: `manage_private_incident`, `update_incident_status`, `assign_incident`,
Write tools: `manage_private_incident`, `update_incident_severity`, `assign_incident`,
`update_or_create_incident_custom_tags`, `create_code_fix_request`, `manage_incident_comment`.
- **Public incidents** — detected by GitGuardian Public Monitoring on the worldwide public
perimeter: public GitHub repos/gists, Docker Hub, etc. Not linked to a workspace source.
Expand Down Expand Up @@ -339,8 +339,8 @@ async def get_current_token_info() -> dict[str, Any]:
)

mcp.tool(
update_incident_status,
description="(Internal sources only) Update an internal secret incident with status. "
update_incident_severity,
description="(Internal sources only) Set the severity of an internal secret incident. "
"Does not work on public-monitoring incident IDs.",
required_scopes=["incidents:write"],
)
Expand Down
19 changes: 19 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -759,3 +759,22 @@ async def test_list_incidents_for_mcp_unassigned_uses_member_id_filter(self, cli
params = client._request_get.call_args.kwargs["params"]
assert params["assignee_member_id"] == "0"
assert "assignee__in" not in params

@pytest.mark.asyncio
async def test_list_incidents_for_mcp_truncates_datetime_bounds_to_date(self, client):
"""
GIVEN ISO datetime date bounds
WHEN calling list_incidents_for_mcp
THEN date__ge / date__le are sent as date-only values (the endpoint
filters on a date-only field and 400s on datetimes)
"""
client._request_get = AsyncMock(return_value={"results": []})

await client.list_incidents_for_mcp(
date_after="2026-07-01T00:00:00Z",
date_before="2026-08-01T00:00:00Z",
)

params = client._request_get.call_args.kwargs["params"]
assert params["date__ge"] == "2026-07-01"
assert params["date__le"] == "2026-08-01"
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading