Skip to content
Draft
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: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies = [

[project.optional-dependencies]
cli = ["typer[all]>=0.12,<1.0"]
mcp-server = ["mcp>=1.27,<2"]
dev = [
"pytest>=9.0.3",
"pytest-cov",
Expand All @@ -41,6 +42,7 @@ dev = [
"pydantic",
"pip-audit>=2.7",
"typer[all]>=0.12,<1.0",
"mcp>=1.27,<2",
]

[project.scripts]
Expand Down
10 changes: 10 additions & 0 deletions src/hyperping/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@
"AsyncHyperpingMcpClient",
"HyperpingClient",
"HyperpingMcpClient",
# MCP server
"create_mcp_server",
# MCP
"MCP_URL",
# Configuration
Expand Down Expand Up @@ -201,7 +203,15 @@ def __getattr__(name: str) -> object:
``IncidentStatus`` and ``IncidentUpdateCreate`` — legacy type aliases.

All four will be removed in v0.3.0.

``create_mcp_server`` is loaded lazily to avoid importing the optional
``mcp`` package at top-level import time.
"""
if name == "create_mcp_server":
from hyperping.mcp_server import create_mcp_server

return create_mcp_server

import warnings

if name == "HYPERPING_API_BASE":
Expand Down
86 changes: 86 additions & 0 deletions src/hyperping/mcp_server/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""MCP server factory for the Hyperping SDK.

Exposes a single public entry point, :func:`create_mcp_server`, that builds
a :class:`~mcp.server.fastmcp.FastMCP` instance pre-loaded with Hyperping
tools grouped by resource type.

Example::

from hyperping.mcp_server import create_mcp_server

server = create_mcp_server(api_key="sk_...")
server.run()
"""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP

from hyperping.client import HyperpingClient
from hyperping.mcp_client import HyperpingMcpClient


def create_mcp_server(
api_key: str | None = None,
client: HyperpingClient | None = None,
mcp_client: HyperpingMcpClient | None = None,
tools: list[str] | None = None,
name: str = "hyperping",
) -> FastMCP:
"""Create a FastMCP server pre-loaded with Hyperping tools.

Args:
api_key: Hyperping API key. Used to create internal REST and MCP
clients when *client* and *mcp_client* are not supplied.
client: Pre-configured :class:`~hyperping.client.HyperpingClient`.
Takes precedence over *api_key* for REST operations.
mcp_client: Pre-configured :class:`~hyperping.mcp_client.HyperpingMcpClient`.
Required for the observability tool group. When ``None`` and
*api_key* is provided, one is created internally. When ``None``
and only *client* is provided, the observability group is skipped.
tools: List of tool group names to register. ``None`` (default)
registers all groups. Valid group names: ``monitors``,
``incidents``, ``maintenance``, ``outages``, ``statuspages``,
``healthchecks``, ``observability``.
name: Server name passed to FastMCP. Defaults to ``"hyperping"``.

Returns:
Configured :class:`~mcp.server.fastmcp.FastMCP` instance.

Raises:
ImportError: If the ``mcp`` package is not installed.
ValueError: If neither *api_key* nor *client* is provided, or if
*tools* contains an unrecognised group name.
"""
try:
from mcp.server.fastmcp import FastMCP
except ImportError as exc:
raise ImportError(
"The mcp package is required. Install with: pip install 'hyperping[mcp-server]'"
) from exc

if client is None and api_key is None:
raise ValueError("Provide api_key or a pre-configured client.")

if client is None:
from hyperping.client import HyperpingClient

client = HyperpingClient(api_key=api_key) # type: ignore[arg-type]

if mcp_client is None and api_key is not None:
from hyperping.mcp_client import HyperpingMcpClient

mcp_client = HyperpingMcpClient(api_key=api_key)

server = FastMCP(name)

from hyperping.mcp_server._registry import register_tools

register_tools(server, client, mcp_client, tools)
return server


__all__ = ["create_mcp_server"]
79 changes: 79 additions & 0 deletions src/hyperping/mcp_server/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""CLI entrypoint for the Hyperping MCP server.

Run with::

python -m hyperping.mcp_server --api-key sk_...
python -m hyperping.mcp_server --api-key sk_... --transport sse --port 8080
python -m hyperping.mcp_server --api-key sk_... --tools monitors,incidents
"""

from __future__ import annotations

import argparse
import os

from hyperping.mcp_server import create_mcp_server


def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="python -m hyperping.mcp_server",
description="Start the Hyperping MCP server.",
)
parser.add_argument(
"--api-key",
default=None,
help="Hyperping API key. Falls back to the HYPERPING_API_KEY environment variable.",
)
parser.add_argument(
"--transport",
choices=["stdio", "sse"],
default="stdio",
help="MCP transport to use (default: stdio).",
)
parser.add_argument(
"--port",
type=int,
default=8080,
help="Port for the SSE transport (default: 8080).",
)
parser.add_argument(
"--tools",
default=None,
help=(
"Comma-separated list of tool groups to enable. "
"Valid groups: monitors, incidents, maintenance, outages, "
"statuspages, healthchecks, observability. "
"Omit to enable all groups."
),
)
parser.add_argument(
"--name",
default="hyperping",
help="Server name (default: hyperping).",
)
return parser


def main(argv: list[str] | None = None) -> None:
"""Parse arguments and start the MCP server."""
parser = _build_parser()
args = parser.parse_args(argv)

api_key = args.api_key or os.environ.get("HYPERPING_API_KEY")
if not api_key:
parser.error(
"API key is required. Pass --api-key or set the HYPERPING_API_KEY environment variable."
)

tools: list[str] | None = None
if args.tools:
tools = [t.strip() for t in args.tools.split(",") if t.strip()]

server = create_mcp_server(api_key=api_key, tools=tools, name=args.name)

server.run(transport=args.transport)


if __name__ == "__main__":
main()
94 changes: 94 additions & 0 deletions src/hyperping/mcp_server/_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Tool group registry for the Hyperping MCP server.

:func:`register_tools` is the single entry point used by the factory.
It dispatches to per-group registration functions and validates the
requested group list.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP

from hyperping.client import HyperpingClient
from hyperping.mcp_client import HyperpingMcpClient

_VALID_GROUPS: frozenset[str] = frozenset(
{
"monitors",
"incidents",
"maintenance",
"outages",
"statuspages",
"healthchecks",
"observability",
}
)


def register_tools(
mcp: FastMCP,
client: HyperpingClient,
mcp_client: HyperpingMcpClient | None,
groups: list[str] | None,
) -> None:
"""Register tool groups on *mcp*.

Args:
mcp: FastMCP server instance.
client: Hyperping REST client used by most tool groups.
mcp_client: Hyperping MCP client used by the observability group.
When ``None``, the observability group is silently skipped even
if it appears in *groups*.
groups: Tool group names to register. ``None`` registers all groups.

Raises:
ValueError: If *groups* contains an unrecognised name.
"""
if groups is None:
groups = list(_VALID_GROUPS)

unknown = set(groups) - _VALID_GROUPS
if unknown:
raise ValueError(
f"Unknown tool groups: {sorted(unknown)}. Valid groups: {sorted(_VALID_GROUPS)}"
)

group_set = set(groups)

if "monitors" in group_set:
from hyperping.mcp_server._tools_monitors import register_monitor_tools

register_monitor_tools(mcp, client, mcp_client)

if "incidents" in group_set:
from hyperping.mcp_server._tools_incidents import register_incident_tools

register_incident_tools(mcp, client)

if "maintenance" in group_set:
from hyperping.mcp_server._tools_maintenance import register_maintenance_tools

register_maintenance_tools(mcp, client)

if "outages" in group_set:
from hyperping.mcp_server._tools_outages import register_outage_tools

register_outage_tools(mcp, client)

if "statuspages" in group_set:
from hyperping.mcp_server._tools_statuspages import register_statuspage_tools

register_statuspage_tools(mcp, client)

if "healthchecks" in group_set:
from hyperping.mcp_server._tools_healthchecks import register_healthcheck_tools

register_healthcheck_tools(mcp, client)

if "observability" in group_set and mcp_client is not None:
from hyperping.mcp_server._tools_observability import register_observability_tools

register_observability_tools(mcp, mcp_client)
84 changes: 84 additions & 0 deletions src/hyperping/mcp_server/_tools_healthchecks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Healthcheck tool registrations for the Hyperping MCP server.

Registers 7 tools: list_healthchecks, get_healthcheck, create_healthcheck,
update_healthcheck, delete_healthcheck, pause_healthcheck, resume_healthcheck.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP

from hyperping.client import HyperpingClient


def register_healthcheck_tools(mcp: FastMCP, client: HyperpingClient) -> None:
"""Register healthcheck tools on *mcp*."""

@mcp.tool()
def list_healthchecks() -> list[dict[str, Any]]:
"""List all healthchecks (push-based cron/heartbeat monitors)."""
return [h.model_dump() for h in client.list_healthchecks()]

@mcp.tool()
def get_healthcheck(healthcheck_id: str) -> dict[str, Any]:
"""Get a single healthcheck by UUID."""
return client.get_healthcheck(healthcheck_id).model_dump()

@mcp.tool()
def create_healthcheck(
name: str,
period: int,
grace: int,
escalation_policy: str | None = None,
project_uuid: str | None = None,
) -> dict[str, Any]:
"""This will create a new healthcheck. period and grace are in seconds."""
from hyperping.models import HealthcheckCreate

fields: dict[str, Any] = {"name": name, "period": period, "grace": grace}
if escalation_policy is not None:
fields["escalation_policy"] = escalation_policy
if project_uuid is not None:
fields["project_uuid"] = project_uuid
return client.create_healthcheck(HealthcheckCreate(**fields)).model_dump()

@mcp.tool()
def update_healthcheck(
healthcheck_id: str,
name: str | None = None,
period: int | None = None,
grace: int | None = None,
escalation_policy: str | None = None,
) -> dict[str, Any]:
"""Update an existing healthcheck. Only supplied fields are changed."""
from hyperping.models import HealthcheckUpdate

fields: dict[str, Any] = {}
if name is not None:
fields["name"] = name
if period is not None:
fields["period"] = period
if grace is not None:
fields["grace"] = grace
if escalation_policy is not None:
fields["escalation_policy"] = escalation_policy
return client.update_healthcheck(healthcheck_id, HealthcheckUpdate(**fields)).model_dump()

@mcp.tool()
def delete_healthcheck(healthcheck_id: str) -> dict[str, Any]:
"""This will permanently delete a healthcheck."""
client.delete_healthcheck(healthcheck_id)
return {"success": True}

@mcp.tool()
def pause_healthcheck(healthcheck_id: str) -> dict[str, Any]:
"""Pause a healthcheck so it stops alerting on missed pings."""
return client.pause_healthcheck(healthcheck_id).model_dump()

@mcp.tool()
def resume_healthcheck(healthcheck_id: str) -> dict[str, Any]:
"""Resume a paused healthcheck."""
return client.resume_healthcheck(healthcheck_id).model_dump()
Loading