google-labs-jules[bot] commited on
Commit
8aa8f36
·
1 Parent(s): a4fc311

Implement alert_mcp_server with Gradio and httpx

Browse files
pyproject.toml CHANGED
@@ -1,19 +1,19 @@
1
  [project]
2
- name = "alert_mcp"
3
  version = "0.1.0"
4
- description = "MCP server for CredentialWatch alerts"
5
  readme = "README.md"
6
  requires-python = ">=3.11"
7
  dependencies = [
8
- "fastapi",
9
- "uvicorn",
10
- "sqlalchemy",
11
  "pydantic",
12
- "mcp", # Assuming a generic mcp package or just standard http tools
 
13
  ]
14
 
15
  [tool.uv]
16
  dev-dependencies = [
17
  "pytest",
18
- "httpx",
19
  ]
 
1
  [project]
2
+ name = "alert_mcp_server"
3
  version = "0.1.0"
4
+ description = "Gradio MCP server for CredentialWatch alerts"
5
  readme = "README.md"
6
  requires-python = ">=3.11"
7
  dependencies = [
8
+ "gradio[mcp]",
9
+ "httpx",
 
10
  "pydantic",
11
+ "python-dotenv",
12
+ "mcp"
13
  ]
14
 
15
  [tool.uv]
16
  dev-dependencies = [
17
  "pytest",
18
+ "pytest-asyncio"
19
  ]
src/alert_mcp_server/config.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import os
2
+ from dotenv import load_dotenv
3
+
4
+ load_dotenv()
5
+
6
+ ALERT_API_BASE_URL = os.getenv("ALERT_API_BASE_URL", "http://localhost:8000")
src/alert_mcp_server/main.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from .tools import log_alert, get_open_alerts, mark_alert_resolved, summarize_alerts
3
+
4
+ # Define the Gradio interface
5
+ # We can use a TabbedInterface to organize the tools for the UI,
6
+ # which also registers them as MCP tools.
7
+
8
+ def create_demo():
9
+ with gr.Blocks(title="CredentialWatch Alert MCP") as demo:
10
+ gr.Markdown("# CredentialWatch Alert MCP Server")
11
+ gr.Markdown("This server exposes tools for managing alerts via MCP. It also provides a simple UI.")
12
+
13
+ with gr.Tab("Log Alert"):
14
+ gr.Markdown("## Log a new alert")
15
+ with gr.Row():
16
+ t1_provider_id = gr.Number(label="Provider ID", value=1, precision=0)
17
+ t1_credential_id = gr.Number(label="Credential ID (Optional)", value=None, precision=0)
18
+
19
+ with gr.Row():
20
+ t1_severity = gr.Dropdown(choices=["info", "warning", "critical"], label="Severity", value="info")
21
+ t1_window_days = gr.Number(label="Window Days", value=30, precision=0)
22
+
23
+ t1_message = gr.Textbox(label="Message")
24
+ t1_channel = gr.Textbox(label="Channel", value="ui")
25
+
26
+ t1_output = gr.JSON(label="Response")
27
+ t1_btn = gr.Button("Log Alert")
28
+
29
+ t1_btn.click(
30
+ fn=log_alert,
31
+ inputs=[t1_provider_id, t1_severity, t1_window_days, t1_message, t1_credential_id, t1_channel],
32
+ outputs=t1_output
33
+ )
34
+
35
+ with gr.Tab("Get Open Alerts"):
36
+ gr.Markdown("## List open alerts")
37
+ t2_provider_id = gr.Number(label="Provider ID (Optional)", value=None, precision=0)
38
+ t2_severity = gr.Dropdown(choices=["info", "warning", "critical", None], label="Severity (Optional)", value=None)
39
+
40
+ t2_output = gr.JSON(label="Open Alerts")
41
+ t2_btn = gr.Button("Get Alerts")
42
+
43
+ t2_btn.click(
44
+ fn=get_open_alerts,
45
+ inputs=[t2_provider_id, t2_severity],
46
+ outputs=t2_output
47
+ )
48
+
49
+ with gr.Tab("Resolve Alert"):
50
+ gr.Markdown("## Mark alert as resolved")
51
+ t3_alert_id = gr.Number(label="Alert ID", precision=0)
52
+ t3_note = gr.Textbox(label="Resolution Note")
53
+
54
+ t3_output = gr.JSON(label="Updated Alert")
55
+ t3_btn = gr.Button("Resolve")
56
+
57
+ t3_btn.click(
58
+ fn=mark_alert_resolved,
59
+ inputs=[t3_alert_id, t3_note],
60
+ outputs=t3_output
61
+ )
62
+
63
+ with gr.Tab("Summarize Alerts"):
64
+ gr.Markdown("## Summary of alerts")
65
+ t4_window = gr.Number(label="Window Days (Optional)", value=None, precision=0)
66
+
67
+ t4_output = gr.JSON(label="Summary")
68
+ t4_btn = gr.Button("Summarize")
69
+
70
+ t4_btn.click(
71
+ fn=summarize_alerts,
72
+ inputs=[t4_window],
73
+ outputs=t4_output
74
+ )
75
+
76
+ return demo
77
+
78
+ if __name__ == "__main__":
79
+ demo = create_demo()
80
+ demo.launch(mcp_server=True)
src/alert_mcp_server/schemas.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional, Literal
2
+ from pydantic import BaseModel, Field
3
+ from datetime import datetime
4
+
5
+ class AlertCreate(BaseModel):
6
+ provider_id: int
7
+ credential_id: Optional[int] = None
8
+ severity: Literal["info", "warning", "critical"]
9
+ window_days: int
10
+ message: str
11
+ channel: Optional[str] = "ui"
12
+
13
+ class AlertRead(BaseModel):
14
+ id: int
15
+ provider_id: int
16
+ credential_id: Optional[int] = None
17
+ severity: str
18
+ window_days: int
19
+ message: str
20
+ channel: str
21
+ created_at: datetime
22
+ resolved_at: Optional[datetime] = None
23
+ resolution_note: Optional[str] = None
24
+
25
+ class AlertResolution(BaseModel):
26
+ resolution_note: Optional[str] = None
27
+
28
+ class AlertSummary(BaseModel):
29
+ severity_counts: dict[str, int]
src/alert_mcp_server/tests/test_alert_mcp_server.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import pytest
3
+ import httpx
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+ from src.alert_mcp_server.tools import log_alert, get_open_alerts, mark_alert_resolved, summarize_alerts
6
+
7
+ @pytest.mark.asyncio
8
+ async def test_log_alert():
9
+ mock_response = {
10
+ "id": 1,
11
+ "provider_id": 1,
12
+ "severity": "info",
13
+ "message": "Test alert",
14
+ "created_at": "2023-10-27T10:00:00"
15
+ }
16
+
17
+ # We use new_callable=AsyncMock for the client.post method because it is awaited.
18
+ # However, the return value (the response object) should be a synchronous Mock (MagicMock)
19
+ # because methods like .json() and .raise_for_status() are synchronous on the Response object.
20
+ with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
21
+ mock_response_obj = MagicMock()
22
+ mock_response_obj.status_code = 200
23
+ mock_response_obj.json.return_value = mock_response
24
+ mock_response_obj.raise_for_status.return_value = None
25
+
26
+ mock_post.return_value = mock_response_obj
27
+
28
+ result = await log_alert(
29
+ provider_id=1,
30
+ severity="info",
31
+ window_days=30,
32
+ message="Test alert"
33
+ )
34
+
35
+ assert result == mock_response
36
+ mock_post.assert_called_once()
37
+ args, kwargs = mock_post.call_args
38
+ assert kwargs["json"]["provider_id"] == 1
39
+ assert kwargs["json"]["severity"] == "info"
40
+
41
+ @pytest.mark.asyncio
42
+ async def test_get_open_alerts():
43
+ mock_response = [
44
+ {
45
+ "id": 1,
46
+ "provider_id": 1,
47
+ "severity": "critical",
48
+ "message": "Critical alert",
49
+ "created_at": "2023-10-27T10:00:00"
50
+ }
51
+ ]
52
+
53
+ with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get:
54
+ mock_response_obj = MagicMock()
55
+ mock_response_obj.status_code = 200
56
+ mock_response_obj.json.return_value = mock_response
57
+ mock_response_obj.raise_for_status.return_value = None
58
+
59
+ mock_get.return_value = mock_response_obj
60
+
61
+ result = await get_open_alerts(provider_id=1)
62
+
63
+ assert result == mock_response
64
+ mock_get.assert_called_once()
65
+ args, kwargs = mock_get.call_args
66
+ assert kwargs["params"]["provider_id"] == 1
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_mark_alert_resolved():
70
+ mock_response = {
71
+ "id": 1,
72
+ "resolved_at": "2023-10-27T12:00:00",
73
+ "resolution_note": "Fixed"
74
+ }
75
+
76
+ with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
77
+ mock_response_obj = MagicMock()
78
+ mock_response_obj.status_code = 200
79
+ mock_response_obj.json.return_value = mock_response
80
+ mock_response_obj.raise_for_status.return_value = None
81
+
82
+ mock_post.return_value = mock_response_obj
83
+
84
+ result = await mark_alert_resolved(alert_id=1, resolution_note="Fixed")
85
+
86
+ assert result == mock_response
87
+ mock_post.assert_called_once()
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_summarize_alerts():
91
+ mock_response = {"info": 5, "warning": 2, "critical": 1}
92
+
93
+ with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
94
+ mock_response_obj = MagicMock()
95
+ mock_response_obj.status_code = 200
96
+ mock_response_obj.json.return_value = mock_response
97
+ mock_response_obj.raise_for_status.return_value = None
98
+
99
+ mock_post.return_value = mock_response_obj
100
+
101
+ result = await summarize_alerts(window_days=7)
102
+
103
+ assert result == mock_response
104
+ mock_post.assert_called_once()
src/alert_mcp_server/tools.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ from typing import List, Optional, Dict, Any
3
+ from .config import ALERT_API_BASE_URL
4
+ from .schemas import AlertCreate, AlertRead, AlertResolution
5
+
6
+ # We'll use a synchronous client for simplicity in Gradio functions,
7
+ # but Gradio supports async too. Let's use synchronous for now as it's often easier with Gradio tools.
8
+ # Actually, httpx is great for async, but standard requests is often used.
9
+ # Let's stick to httpx but use it synchronously or asynchronously depending on how we define the tools.
10
+ # Gradio tools can be async def.
11
+
12
+ async def log_alert(
13
+ provider_id: int,
14
+ severity: str,
15
+ window_days: int,
16
+ message: str,
17
+ credential_id: Optional[int] = None,
18
+ channel: Optional[str] = "ui"
19
+ ) -> Dict[str, Any]:
20
+ """
21
+ Log a new alert in the system.
22
+
23
+ Args:
24
+ provider_id: The ID of the provider.
25
+ severity: One of "info", "warning", "critical".
26
+ window_days: The window in days.
27
+ message: The alert message.
28
+ credential_id: Optional credential ID.
29
+ channel: Notification channel (default: "ui").
30
+ """
31
+ # Validation logic is handled by Pydantic model implicitly if we use it,
32
+ # but here we are constructing the payload.
33
+ # Basic validation for severity
34
+ if severity not in ["info", "warning", "critical"]:
35
+ raise ValueError("Severity must be one of: info, warning, critical")
36
+
37
+ payload = {
38
+ "provider_id": provider_id,
39
+ "credential_id": credential_id,
40
+ "severity": severity,
41
+ "window_days": window_days,
42
+ "message": message,
43
+ "channel": channel
44
+ }
45
+
46
+ async with httpx.AsyncClient(base_url=ALERT_API_BASE_URL) as client:
47
+ try:
48
+ response = await client.post("/alerts", json=payload)
49
+ response.raise_for_status()
50
+ return response.json()
51
+ except httpx.HTTPStatusError as e:
52
+ return {"error": f"HTTP error occurred: {e.response.status_code} - {e.response.text}"}
53
+ except Exception as e:
54
+ return {"error": f"An error occurred: {str(e)}"}
55
+
56
+ async def get_open_alerts(
57
+ provider_id: Optional[int] = None,
58
+ severity: Optional[str] = None
59
+ ) -> List[Dict[str, Any]]:
60
+ """
61
+ Get a list of open (unresolved) alerts.
62
+
63
+ Args:
64
+ provider_id: Optional filter by provider ID.
65
+ severity: Optional filter by severity.
66
+ """
67
+ params = {}
68
+ if provider_id is not None:
69
+ params["provider_id"] = provider_id
70
+ if severity is not None:
71
+ params["severity"] = severity
72
+
73
+ # Using POST /alerts/open with filters as per instructions, or GET if API supports params.
74
+ # Instruction says: "GET or POST to /alerts/open with filters."
75
+ # I'll try POST as it's more flexible for filters usually, or standard GET with query params.
76
+ # Let's assume POST for filters body if complex, or GET with query params.
77
+ # I will assume GET with query params first as it's standard for 'get', but the instruction says "GET or POST".
78
+ # I will implement as GET with query params for now.
79
+
80
+ async with httpx.AsyncClient(base_url=ALERT_API_BASE_URL) as client:
81
+ try:
82
+ response = await client.get("/alerts/open", params=params)
83
+ response.raise_for_status()
84
+ return response.json()
85
+ except httpx.HTTPStatusError as e:
86
+ return [{"error": f"HTTP error: {e.response.status_code}"}]
87
+ except Exception as e:
88
+ return [{"error": str(e)}]
89
+
90
+ async def mark_alert_resolved(
91
+ alert_id: int,
92
+ resolution_note: Optional[str] = None
93
+ ) -> Dict[str, Any]:
94
+ """
95
+ Mark an alert as resolved.
96
+
97
+ Args:
98
+ alert_id: The ID of the alert to resolve.
99
+ resolution_note: A note explaining the resolution.
100
+ """
101
+ payload = {"resolution_note": resolution_note}
102
+
103
+ async with httpx.AsyncClient(base_url=ALERT_API_BASE_URL) as client:
104
+ try:
105
+ response = await client.post(f"/alerts/{alert_id}/resolve", json=payload)
106
+ response.raise_for_status()
107
+ return response.json()
108
+ except httpx.HTTPStatusError as e:
109
+ return {"error": f"HTTP error: {e.response.status_code}"}
110
+ except Exception as e:
111
+ return {"error": str(e)}
112
+
113
+ async def summarize_alerts(window_days: Optional[int] = None) -> Dict[str, int]:
114
+ """
115
+ Get a summary of alerts (counts by severity).
116
+
117
+ Args:
118
+ window_days: Optional window in days to summarize over.
119
+ """
120
+ payload = {}
121
+ if window_days is not None:
122
+ payload["window_days"] = window_days
123
+
124
+ async with httpx.AsyncClient(base_url=ALERT_API_BASE_URL) as client:
125
+ try:
126
+ response = await client.post("/alerts/summary", json=payload)
127
+ response.raise_for_status()
128
+ return response.json()
129
+ except httpx.HTTPStatusError as e:
130
+ return {"error": f"HTTP error: {e.response.status_code}"}
131
+ except Exception as e:
132
+ return {"error": str(e)}