Skip to content

Commit d96369d

Browse files
committed
v2.1.0: p_value field, MCP timeseries tool, version bump
- Add p_value: float to SampleResult dataclass - Parse p_value from API response in _parse_scan() - Add waveguard_scan_timeseries tool to client MCP server - Bump version to 2.1.0 (pyproject.toml + client.py + MCP server)
1 parent 511ea7a commit d96369d

5 files changed

Lines changed: 243 additions & 7 deletions

File tree

mcp_server/server.py

Lines changed: 122 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,55 @@ def _api_get(path: str) -> Any:
149149
"required": ["training", "test"],
150150
},
151151
},
152+
{
153+
"name": "waveguard_scan_timeseries",
154+
"description": (
155+
"Detect anomalies in time-series data using GPU-accelerated wave "
156+
"physics simulation. Send a flat array of numeric values and a "
157+
"window size. The tool automatically creates overlapping windows, "
158+
"uses the first N as training (normal baseline), and scores the "
159+
"remaining windows as test samples. Returns per-window anomaly "
160+
"scores, confidence, and p-values.\n\n"
161+
"Example: send 100 CPU-usage readings with window_size=10. "
162+
"The first 5 windows become training, the rest are tested."
163+
),
164+
"inputSchema": {
165+
"type": "object",
166+
"properties": {
167+
"data": {
168+
"type": "array",
169+
"items": {"type": "number"},
170+
"description": (
171+
"Flat array of numeric time-series values in "
172+
"chronological order."
173+
),
174+
"minItems": 4,
175+
},
176+
"window_size": {
177+
"type": "integer",
178+
"description": (
179+
"Number of data points per window (default: 10). "
180+
"Smaller windows = finer resolution."
181+
),
182+
},
183+
"test_windows": {
184+
"type": "integer",
185+
"description": (
186+
"Number of trailing windows to test (default: auto, "
187+
"uses last ~40%% of windows)."
188+
),
189+
},
190+
"sensitivity": {
191+
"type": "number",
192+
"description": (
193+
"Anomaly threshold multiplier (default: 2.0). Lower = "
194+
"more sensitive. Range: 0.5 to 5.0."
195+
),
196+
},
197+
},
198+
"required": ["data"],
199+
},
200+
},
152201
{
153202
"name": "waveguard_health",
154203
"description": (
@@ -169,6 +218,71 @@ def _api_get(path: str) -> Any:
169218
# ═══════════════════════════════════════════════════════════════════════════
170219

171220

221+
def _execute_timeseries(arguments: dict) -> dict:
222+
"""Sliding-window timeseries scan via the /v1/scan endpoint."""
223+
data = arguments["data"]
224+
window = int(arguments.get("window_size", 10))
225+
sensitivity = arguments.get("sensitivity", 2.0)
226+
227+
# Build windows
228+
windows = [data[i : i + window] for i in range(0, len(data) - window + 1)]
229+
if len(windows) < 3:
230+
return {
231+
"content": [
232+
{
233+
"type": "text",
234+
"text": (
235+
f"Not enough data: {len(data)} points with "
236+
f"window_size={window} gives {len(windows)} windows "
237+
f"(need at least 3)."
238+
),
239+
}
240+
],
241+
"isError": True,
242+
}
243+
244+
# Split into training / test
245+
test_count = arguments.get("test_windows")
246+
if test_count is None:
247+
test_count = max(1, len(windows) * 2 // 5)
248+
test_count = min(test_count, len(windows) - 2)
249+
250+
training = windows[: len(windows) - test_count]
251+
test = windows[len(windows) - test_count :]
252+
253+
body: dict = {
254+
"training": training,
255+
"test": test,
256+
"encoder_type": "timeseries",
257+
"sensitivity": sensitivity,
258+
}
259+
result = _api_post("/v1/scan", body)
260+
261+
# Summarise
262+
lines = [
263+
f"Time-series scan: {len(windows)} windows "
264+
f"(window_size={window}, {len(training)} train, {len(test)} test)",
265+
"",
266+
]
267+
for i, r in enumerate(result.get("results", [])):
268+
idx = len(training) + i
269+
is_anom = r.get("is_anomaly", False)
270+
conf = r.get("confidence", 0)
271+
pval = r.get("p_value", 1.0)
272+
marker = "ANOMALY" if is_anom else "Normal"
273+
lines.append(
274+
f" Window {idx}: {marker} (confidence: {conf:.0%}, "
275+
f"p-value: {pval:.4f})"
276+
)
277+
summary = "\n".join(lines)
278+
return {
279+
"content": [
280+
{"type": "text", "text": summary},
281+
{"type": "text", "text": json.dumps(result, indent=2)},
282+
]
283+
}
284+
285+
172286
def execute_tool(name: str, arguments: dict) -> dict:
173287
"""Execute an MCP tool and return the result."""
174288
try:
@@ -221,6 +335,9 @@ def execute_tool(name: str, arguments: dict) -> dict:
221335
]
222336
}
223337

338+
elif name == "waveguard_scan_timeseries":
339+
return _execute_timeseries(arguments)
340+
224341
elif name == "waveguard_health":
225342
result = _api_get("/v1/health")
226343
status = (
@@ -254,7 +371,7 @@ class MCPStdioServer:
254371
def __init__(self) -> None:
255372
self.server_info = {
256373
"name": "waveguard",
257-
"version": "2.0.0",
374+
"version": "2.1.0",
258375
}
259376

260377
def handle_message(self, msg: dict) -> Optional[dict]:
@@ -318,7 +435,7 @@ def handle_message(self, msg: dict) -> Optional[dict]:
318435
def run_stdio(self) -> None:
319436
"""Run the MCP server on stdin/stdout."""
320437
sys.stderr.write(
321-
f"WaveGuard MCP server v2.0.0 started (API: {API_URL})\n"
438+
f"WaveGuard MCP server v2.1.0 started (API: {API_URL})\n"
322439
)
323440
sys.stderr.flush()
324441

@@ -353,7 +470,7 @@ def run_http_server(port: int = 3001) -> None:
353470
print("HTTP transport requires: pip install fastapi uvicorn")
354471
sys.exit(1)
355472

356-
mcp_app = FA(title="WaveGuard MCP Server", version="2.0.0")
473+
mcp_app = FA(title="WaveGuard MCP Server", version="2.1.0")
357474
server = MCPStdioServer()
358475

359476
@mcp_app.post("/mcp")
@@ -364,7 +481,7 @@ async def mcp_endpoint(request: dict) -> dict: # type: ignore[type-arg]
364481
async def mcp_tools() -> dict: # type: ignore[type-arg]
365482
return {"tools": TOOLS}
366483

367-
print(f"WaveGuard MCP HTTP server v2.0.0 on port {port}")
484+
print(f"WaveGuard MCP HTTP server v2.1.0 on port {port}")
368485
uvicorn.run(mcp_app, host="0.0.0.0", port=port)
369486

370487

@@ -374,7 +491,7 @@ async def mcp_tools() -> dict: # type: ignore[type-arg]
374491

375492
if __name__ == "__main__":
376493
parser = argparse.ArgumentParser(
377-
description="WaveGuard MCP Server v2.0.0"
494+
description="WaveGuard MCP Server v2.1.0"
378495
)
379496
parser.add_argument(
380497
"--http",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "WaveGuardClient"
7-
version = "2.0.0"
7+
version = "2.1.0"
88
description = "Python SDK for WaveGuard — physics-based anomaly detection API"
99
readme = "README.md"
1010
license = "MIT"

test_api.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Quick test: SDK direct API + p_value + grid 64."""
2+
from waveguard import WaveGuard
3+
4+
wg = WaveGuard()
5+
6+
sr = wg.scan(
7+
training=[
8+
{"cpu": 45, "mem": 60, "disk": 30, "net": 100, "io": 20},
9+
{"cpu": 50, "mem": 65, "disk": 32, "net": 110, "io": 22},
10+
{"cpu": 48, "mem": 62, "disk": 31, "net": 105, "io": 21},
11+
{"cpu": 47, "mem": 63, "disk": 29, "net": 102, "io": 19},
12+
{"cpu": 46, "mem": 61, "disk": 30, "net": 108, "io": 23},
13+
],
14+
test=[
15+
{"cpu": 46, "mem": 61, "disk": 30, "net": 103, "io": 20},
16+
{"cpu": 99, "mem": 95, "disk": 90, "net": 900, "io": 85},
17+
],
18+
)
19+
20+
print("=== SDK DIRECT API TEST ===")
21+
print(f"Results: {len(sr.results)} samples")
22+
for i, r in enumerate(sr.results):
23+
print(
24+
f" Sample {i+1}: anomaly={r.is_anomaly}, score={r.score:.2f}, "
25+
f"confidence={r.confidence:.2%}, p_value={r.p_value:.6f}, "
26+
f"grid={r.engine.grid_size}"
27+
)
28+
feats = [(f.label, round(f.z_score, 2)) for f in r.top_features[:3]]
29+
print(f" top features: {feats}")
30+
31+
print()
32+
print(f"p_value field present: {hasattr(sr.results[0], 'p_value')}")
33+
print(f"Grid size (should be 64): {sr.results[0].engine.grid_size}")
34+
print()
35+
print("=== ALL CHECKS ===")
36+
checks = [
37+
("p_value field exists", hasattr(sr.results[0], 'p_value')),
38+
("Grid = 64", sr.results[0].engine.grid_size == 64),
39+
("p_value is float", isinstance(sr.results[0].p_value, float)),
40+
("2 results returned", len(sr.results) == 2),
41+
]
42+
for name, ok in checks:
43+
print(f" {'PASS' if ok else 'FAIL'}: {name}")
44+
print(f"\nAll checks passed: {all(ok for _, ok in checks)}")

test_mcp.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Test MCP endpoint: tools/list (verify 3 tools) + waveguard_scan_timeseries."""
2+
import requests
3+
import json
4+
5+
URL = "https://gpartin--waveguard-api-fastapi-app.modal.run/mcp"
6+
HDR = {"Content-Type": "application/json"}
7+
8+
9+
def mcp(method, params=None, msg_id=1):
10+
body = {"jsonrpc": "2.0", "method": method, "id": msg_id}
11+
if params:
12+
body["params"] = params
13+
r = requests.post(URL, json=body, headers=HDR, timeout=90)
14+
r.raise_for_status()
15+
return r.json()
16+
17+
18+
# 1) Initialize
19+
init = mcp("initialize", {"protocolVersion": "2024-11-05",
20+
"capabilities": {},
21+
"clientInfo": {"name": "test", "version": "1.0"}})
22+
print("=== MCP INITIALIZE ===")
23+
print(f"Server: {init['result']['serverInfo']}")
24+
25+
# 2) tools/list
26+
tools = mcp("tools/list", msg_id=2)
27+
names = [t["name"] for t in tools["result"]["tools"]]
28+
print(f"\n=== MCP TOOLS/LIST ({len(names)} tools) ===")
29+
for n in names:
30+
print(f" - {n}")
31+
32+
assert "waveguard_scan_timeseries" in names, "timeseries tool missing!"
33+
print("\nPASS: waveguard_scan_timeseries present")
34+
35+
# 3) Call waveguard_scan_timeseries
36+
import math
37+
# Generate sine wave with a spike anomaly
38+
data = [math.sin(i * 0.3) * 10 + 50 for i in range(40)]
39+
data[35] = 200 # anomaly spike
40+
41+
ts_result = mcp("tools/call", {
42+
"name": "waveguard_scan_timeseries",
43+
"arguments": {
44+
"data": data,
45+
"window_size": 5,
46+
"sensitivity": 2.0,
47+
}
48+
}, msg_id=3)
49+
50+
print("\n=== MCP TIMESERIES SCAN ===")
51+
content = ts_result["result"]["content"]
52+
print(content[0]["text"])
53+
54+
# Parse JSON results
55+
full = json.loads(content[1]["text"])
56+
results = full.get("results", [])
57+
print(f"\nReturned {len(results)} test windows")
58+
any_anomaly = any(r.get("is_anomaly") for r in results)
59+
any_pvalue = any("p_value" in r for r in results)
60+
print(f"Any anomaly detected: {any_anomaly}")
61+
print(f"p_value in results: {any_pvalue}")
62+
63+
# 4) Summary
64+
print("\n=== ALL MCP CHECKS ===")
65+
checks = [
66+
("3 tools listed", len(names) == 3),
67+
("timeseries tool present", "waveguard_scan_timeseries" in names),
68+
("timeseries returns results", len(results) > 0),
69+
("p_value in results", any_pvalue),
70+
]
71+
for name, ok in checks:
72+
print(f" {'PASS' if ok else 'FAIL'}: {name}")
73+
print(f"\nAll MCP checks passed: {all(ok for _, ok in checks)}")

waveguard/client.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
ServerError,
3939
)
4040

41-
__version__ = "2.0.0"
41+
__version__ = "2.1.0"
4242

4343

4444
# ─────────────────────────────── Data Classes ─────────────────────────────
@@ -71,6 +71,7 @@ class SampleResult:
7171
threshold: float
7272
mahalanobis_distance: float
7373
confidence: float
74+
p_value: float
7475
top_features: List[FeatureInfo]
7576
latency_ms: float
7677
engine: EngineInfo
@@ -272,6 +273,7 @@ def _parse_scan(
272273
threshold=r.get("threshold", 0.0),
273274
mahalanobis_distance=r.get("mahalanobis_distance", 0.0),
274275
confidence=r.get("confidence", 0.0),
276+
p_value=r.get("p_value", 1.0),
275277
top_features=features,
276278
latency_ms=r.get("latency_ms", 0.0),
277279
engine=EngineInfo(

0 commit comments

Comments
 (0)