Skip to content

Commit b5e0a30

Browse files
committed
feat(diagnostics): add webex_health_check MCP tool
Adds a new webex_health_check() tool that agents can call to self-diagnose before starting a workflow. It validates the bot token via people.me() and optionally lists visible rooms, returning a structured status with overall_status ("healthy" | "degraded" | "unhealthy"), per-check results, timing info, and server/Python version metadata. - New src/webex_bot_mcp/tools/diagnostics.py with _check_token / _check_rooms helpers - Exported from tools/__init__.py and registered in main.py - tools_count bumped from 27 to 28 - 17 unit tests covering healthy path, no-rooms flag, missing token, auth failure, room error (degraded), and sample-cap behaviour
1 parent af5eff7 commit b5e0a30

5 files changed

Lines changed: 353 additions & 2 deletions

File tree

src/webex_bot_mcp/main.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
update_webex_team, delete_webex_team,
5454
list_webex_team_memberships, add_webex_team_membership,
5555
delete_webex_team_membership,
56+
# Diagnostic tools
57+
webex_health_check,
5658
)
5759

5860
# Import version and error handling from common
@@ -135,6 +137,9 @@ async def dispatch(self, request, call_next):
135137
mcp.tool()(add_webex_team_membership)
136138
mcp.tool()(delete_webex_team_membership)
137139

140+
# Diagnostic tools
141+
mcp.tool()(webex_health_check)
142+
138143

139144
# ========== RESOURCES ==========
140145
# Resources provide contextual information that AI assistants can read
@@ -1117,7 +1122,7 @@ def server_version():
11171122
"streamable-http", "stdio",
11181123
"error-handling", "versioning"
11191124
],
1120-
"tools_count": 34,
1125+
"tools_count": 35,
11211126
"resources_count": 11,
11221127
"prompts_count": 8,
11231128
"breaking_changes": {

src/webex_bot_mcp/tools/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
delete_webex_team_membership,
3434
)
3535

36+
from .diagnostics import (
37+
webex_health_check,
38+
)
39+
3640
__all__ = [
3741
# Room functions
3842
'list_webex_rooms', 'create_webex_room', 'update_webex_room',
@@ -60,4 +64,6 @@
6064
'update_webex_team', 'delete_webex_team',
6165
'list_webex_team_memberships', 'add_webex_team_membership',
6266
'delete_webex_team_membership',
67+
# Diagnostic tools
68+
'webex_health_check',
6369
]
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""
2+
Diagnostic tools for Webex Bot MCP Server.
3+
"""
4+
import sys
5+
import time
6+
from typing import Any, Dict
7+
8+
from .common import (
9+
MCP_SERVER_VERSION,
10+
WebexErrorCodes,
11+
WebexTokenMissingError,
12+
create_success_response,
13+
get_webex_api,
14+
)
15+
16+
17+
def _check_token() -> Dict[str, Any]:
18+
start = time.time()
19+
try:
20+
me = get_webex_api().people.me()
21+
return {
22+
"status": "ok",
23+
"bot_id": me.id,
24+
"bot_name": me.displayName,
25+
"bot_email": me.emails[0] if me.emails else None,
26+
"org_id": me.orgId,
27+
"duration_ms": round((time.time() - start) * 1000),
28+
}
29+
except WebexTokenMissingError as e:
30+
return {
31+
"status": "error",
32+
"error_code": WebexErrorCodes.UNAUTHORIZED,
33+
"message": str(e),
34+
"duration_ms": round((time.time() - start) * 1000),
35+
}
36+
except Exception as e:
37+
err = str(e).lower()
38+
if "unauthorized" in err or "invalid token" in err or "401" in err:
39+
code = WebexErrorCodes.UNAUTHORIZED
40+
elif "forbidden" in err or "403" in err:
41+
code = WebexErrorCodes.FORBIDDEN
42+
else:
43+
code = WebexErrorCodes.WEBEX_API_ERROR
44+
return {
45+
"status": "error",
46+
"error_code": code,
47+
"message": str(e),
48+
"duration_ms": round((time.time() - start) * 1000),
49+
}
50+
51+
52+
def _check_rooms(max_rooms: int = 10) -> Dict[str, Any]:
53+
start = time.time()
54+
try:
55+
rooms = list(get_webex_api().rooms.list(max=max_rooms))
56+
room_types: Dict[str, int] = {}
57+
for r in rooms:
58+
rt = getattr(r, "type", "unknown")
59+
room_types[rt] = room_types.get(rt, 0) + 1
60+
return {
61+
"status": "ok",
62+
"total_visible": len(rooms),
63+
"room_types": room_types,
64+
"sample": [
65+
{"id": r.id, "title": r.title, "type": getattr(r, "type", "unknown")}
66+
for r in rooms[:3]
67+
],
68+
"duration_ms": round((time.time() - start) * 1000),
69+
}
70+
except Exception as e:
71+
return {
72+
"status": "error",
73+
"message": str(e),
74+
"duration_ms": round((time.time() - start) * 1000),
75+
}
76+
77+
78+
def webex_health_check(include_rooms: bool = True) -> Dict[str, Any]:
79+
"""
80+
Validate Webex connectivity and return a structured diagnostic status.
81+
82+
Checks the bot token (identity + API reachability) and, optionally, lists
83+
visible rooms to confirm room-access permissions. Designed for agents that
84+
need to self-diagnose before starting a workflow.
85+
86+
Args:
87+
include_rooms: When True (default), also verify room-list access.
88+
89+
Returns:
90+
Standardized success response whose ``data`` contains:
91+
- ``overall_status``: "healthy", "degraded", or "unhealthy"
92+
- ``checks``: per-check result dicts keyed by check name
93+
- ``duration_ms``: total wall-clock time for all checks
94+
- ``server_version``: running MCP server version
95+
- ``python_version``: Python interpreter version string
96+
97+
``overall_status`` is "healthy" when all checks pass, "unhealthy" when
98+
the token check fails, and "degraded" when the token is valid but a
99+
secondary check (e.g. rooms) errors.
100+
"""
101+
wall_start = time.time()
102+
103+
token_check = _check_token()
104+
checks: Dict[str, Any] = {"token": token_check}
105+
106+
if include_rooms:
107+
if token_check["status"] == "ok":
108+
checks["rooms"] = _check_rooms()
109+
else:
110+
checks["rooms"] = {
111+
"status": "skipped",
112+
"message": "Token check failed; room check skipped.",
113+
}
114+
115+
statuses = [c["status"] for c in checks.values()]
116+
if any(s == "error" for s in statuses):
117+
overall = "unhealthy" if token_check["status"] == "error" else "degraded"
118+
else:
119+
overall = "healthy"
120+
121+
return create_success_response(
122+
data={
123+
"overall_status": overall,
124+
"checks": checks,
125+
"duration_ms": round((time.time() - wall_start) * 1000),
126+
"server_version": MCP_SERVER_VERSION,
127+
"python_version": sys.version.split()[0],
128+
}
129+
)

tests/test_diagnostics.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""
2+
Unit tests for webex_health_check tool.
3+
4+
The webexpythonsdk and python-dotenv stubs are set up before any project
5+
module is imported so no real credentials or SDK installation are needed.
6+
"""
7+
import os
8+
import sys
9+
import types
10+
import unittest
11+
from unittest.mock import MagicMock, patch
12+
13+
# ── Provide fake env var and SDK before any tools module is imported ──────────
14+
os.environ.setdefault("WEBEX_ACCESS_TOKEN", "test-token")
15+
16+
_sdk_mod = types.ModuleType("webexpythonsdk")
17+
_sdk_mod.WebexAPI = MagicMock(return_value=MagicMock())
18+
sys.modules.setdefault("webexpythonsdk", _sdk_mod)
19+
20+
if "dotenv" not in sys.modules:
21+
_dotenv_mod = types.ModuleType("dotenv")
22+
_dotenv_mod.load_dotenv = lambda *a, **kw: None
23+
sys.modules["dotenv"] = _dotenv_mod
24+
25+
from webex_bot_mcp.tools.diagnostics import webex_health_check # noqa: E402
26+
from webex_bot_mcp.tools.common import WebexTokenMissingError # noqa: E402
27+
import webex_bot_mcp.tools.diagnostics as _diag_mod # noqa: E402
28+
29+
30+
# ── helpers ───────────────────────────────────────────────────────────────────
31+
32+
def _fake_me(**overrides):
33+
me = MagicMock()
34+
me.id = "bot-id-123"
35+
me.displayName = "Test Bot"
36+
me.emails = ["testbot@example.com"]
37+
me.orgId = "org-456"
38+
for k, v in overrides.items():
39+
setattr(me, k, v)
40+
return me
41+
42+
43+
def _fake_room(room_id="R1", title="Room One", room_type="group"):
44+
r = MagicMock()
45+
r.id = room_id
46+
r.title = title
47+
r.type = room_type
48+
return r
49+
50+
51+
class TestWebexHealthCheckHealthy(unittest.TestCase):
52+
"""All checks pass → overall_status healthy."""
53+
54+
def setUp(self):
55+
self.mock_api = MagicMock()
56+
self.mock_api.people.me.return_value = _fake_me()
57+
self.mock_api.rooms.list.return_value = iter([
58+
_fake_room("R1", "Alpha", "group"),
59+
_fake_room("R2", "Beta", "direct"),
60+
])
61+
62+
def test_overall_status_healthy(self):
63+
with patch.object(_diag_mod, "get_webex_api", return_value=self.mock_api):
64+
result = webex_health_check()
65+
self.assertTrue(result["success"])
66+
self.assertEqual(result["data"]["overall_status"], "healthy")
67+
68+
def test_token_check_fields(self):
69+
with patch.object(_diag_mod, "get_webex_api", return_value=self.mock_api):
70+
result = webex_health_check()
71+
token = result["data"]["checks"]["token"]
72+
self.assertEqual(token["status"], "ok")
73+
self.assertEqual(token["bot_id"], "bot-id-123")
74+
self.assertEqual(token["bot_name"], "Test Bot")
75+
self.assertEqual(token["bot_email"], "testbot@example.com")
76+
self.assertIn("duration_ms", token)
77+
78+
def test_rooms_check_fields(self):
79+
with patch.object(_diag_mod, "get_webex_api", return_value=self.mock_api):
80+
result = webex_health_check()
81+
rooms = result["data"]["checks"]["rooms"]
82+
self.assertEqual(rooms["status"], "ok")
83+
self.assertEqual(rooms["total_visible"], 2)
84+
self.assertEqual(rooms["room_types"], {"group": 1, "direct": 1})
85+
self.assertEqual(len(rooms["sample"]), 2)
86+
87+
def test_top_level_metadata(self):
88+
with patch.object(_diag_mod, "get_webex_api", return_value=self.mock_api):
89+
result = webex_health_check()
90+
data = result["data"]
91+
self.assertIn("duration_ms", data)
92+
self.assertIn("server_version", data)
93+
self.assertIn("python_version", data)
94+
self.assertIn("timestamp", result)
95+
self.assertIn("server_version", result)
96+
97+
98+
class TestWebexHealthCheckNoRooms(unittest.TestCase):
99+
"""include_rooms=False skips room check."""
100+
101+
def setUp(self):
102+
self.mock_api = MagicMock()
103+
self.mock_api.people.me.return_value = _fake_me()
104+
105+
def test_rooms_check_absent(self):
106+
with patch.object(_diag_mod, "get_webex_api", return_value=self.mock_api):
107+
result = webex_health_check(include_rooms=False)
108+
self.assertNotIn("rooms", result["data"]["checks"])
109+
110+
def test_still_healthy(self):
111+
with patch.object(_diag_mod, "get_webex_api", return_value=self.mock_api):
112+
result = webex_health_check(include_rooms=False)
113+
self.assertEqual(result["data"]["overall_status"], "healthy")
114+
115+
def test_rooms_list_not_called(self):
116+
with patch.object(_diag_mod, "get_webex_api", return_value=self.mock_api):
117+
webex_health_check(include_rooms=False)
118+
self.mock_api.rooms.list.assert_not_called()
119+
120+
121+
class TestWebexHealthCheckTokenMissing(unittest.TestCase):
122+
"""WebexTokenMissingError → unhealthy, room check skipped."""
123+
124+
def _raise_missing(self):
125+
raise WebexTokenMissingError("No token available.")
126+
127+
def test_overall_status_unhealthy(self):
128+
with patch.object(_diag_mod, "get_webex_api", side_effect=self._raise_missing):
129+
result = webex_health_check()
130+
self.assertTrue(result["success"])
131+
self.assertEqual(result["data"]["overall_status"], "unhealthy")
132+
133+
def test_token_check_error(self):
134+
with patch.object(_diag_mod, "get_webex_api", side_effect=self._raise_missing):
135+
result = webex_health_check()
136+
token = result["data"]["checks"]["token"]
137+
self.assertEqual(token["status"], "error")
138+
self.assertEqual(token["error_code"], "E401")
139+
140+
def test_rooms_skipped(self):
141+
with patch.object(_diag_mod, "get_webex_api", side_effect=self._raise_missing):
142+
result = webex_health_check()
143+
rooms = result["data"]["checks"]["rooms"]
144+
self.assertEqual(rooms["status"], "skipped")
145+
146+
147+
class TestWebexHealthCheckTokenAuthError(unittest.TestCase):
148+
"""HTTP 401 from people.me → unhealthy."""
149+
150+
def setUp(self):
151+
self.mock_api = MagicMock()
152+
self.mock_api.people.me.side_effect = Exception("401 Unauthorized")
153+
154+
def test_overall_status_unhealthy(self):
155+
with patch.object(_diag_mod, "get_webex_api", return_value=self.mock_api):
156+
result = webex_health_check()
157+
self.assertEqual(result["data"]["overall_status"], "unhealthy")
158+
159+
def test_token_error_code_e401(self):
160+
with patch.object(_diag_mod, "get_webex_api", return_value=self.mock_api):
161+
result = webex_health_check()
162+
self.assertEqual(result["data"]["checks"]["token"]["error_code"], "E401")
163+
164+
165+
class TestWebexHealthCheckRoomError(unittest.TestCase):
166+
"""Token OK but rooms.list fails → degraded."""
167+
168+
def setUp(self):
169+
self.mock_api = MagicMock()
170+
self.mock_api.people.me.return_value = _fake_me()
171+
self.mock_api.rooms.list.side_effect = Exception("network error")
172+
173+
def test_overall_status_degraded(self):
174+
with patch.object(_diag_mod, "get_webex_api", return_value=self.mock_api):
175+
result = webex_health_check()
176+
self.assertEqual(result["data"]["overall_status"], "degraded")
177+
178+
def test_token_still_ok(self):
179+
with patch.object(_diag_mod, "get_webex_api", return_value=self.mock_api):
180+
result = webex_health_check()
181+
self.assertEqual(result["data"]["checks"]["token"]["status"], "ok")
182+
183+
def test_rooms_status_error(self):
184+
with patch.object(_diag_mod, "get_webex_api", return_value=self.mock_api):
185+
result = webex_health_check()
186+
self.assertEqual(result["data"]["checks"]["rooms"]["status"], "error")
187+
188+
189+
class TestWebexHealthCheckSampleCap(unittest.TestCase):
190+
"""Sample rooms list is capped at 3 entries."""
191+
192+
def setUp(self):
193+
self.mock_api = MagicMock()
194+
self.mock_api.people.me.return_value = _fake_me()
195+
self.mock_api.rooms.list.return_value = iter([
196+
_fake_room(f"R{i}", f"Room {i}") for i in range(8)
197+
])
198+
199+
def test_sample_capped_at_three(self):
200+
with patch.object(_diag_mod, "get_webex_api", return_value=self.mock_api):
201+
result = webex_health_check()
202+
self.assertEqual(len(result["data"]["checks"]["rooms"]["sample"]), 3)
203+
204+
def test_total_visible_is_full_count(self):
205+
with patch.object(_diag_mod, "get_webex_api", return_value=self.mock_api):
206+
result = webex_health_check()
207+
self.assertEqual(result["data"]["checks"]["rooms"]["total_visible"], 8)
208+
209+
210+
if __name__ == "__main__":
211+
unittest.main()

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)