Skip to content

Commit b36c8a0

Browse files
make an mcp app
Signed-off-by: Matthew Khouzam <matthew.khouzam@ericsson.com>
1 parent 5d65cb0 commit b36c8a0

3 files changed

Lines changed: 253 additions & 32 deletions

File tree

tmll/mcp/server.py

Lines changed: 212 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
#!/usr/bin/env python3
22
"""MCP server for TMLL CLI - exposes all CLI commands as MCP tools."""
33

4+
import base64
5+
import contextlib
6+
import functools
7+
import io
8+
import json
49
import subprocess
510
import sys
11+
import traceback as _tb
612
import urllib.request
713
from pathlib import Path
814
from typing import Optional
915

16+
import matplotlib
17+
matplotlib.use("Agg")
18+
import matplotlib.pyplot as plt
19+
import pandas as pd
20+
1021
from mcp.server.fastmcp import FastMCP
22+
from mcp.types import ImageContent, TextContent
1123

1224
mcp = FastMCP("tmll-cli-mcp-server")
1325

@@ -17,12 +29,57 @@
1729
DEFAULT_PORT = 8080
1830

1931

32+
# ---------------------------------------------------------------------------
33+
# Debug helpers
34+
# ---------------------------------------------------------------------------
35+
36+
def _log(msg: str) -> None:
37+
"""Write debug message to stderr (safe for MCP stdio transport)."""
38+
print(f"[tmll-mcp-debug] {msg}", file=sys.stderr, flush=True)
39+
40+
41+
@contextlib.contextmanager
42+
def _protect_stdout():
43+
"""Temporarily redirect stdout→stderr so stray print() cannot corrupt the MCP stdio transport."""
44+
old = sys.stdout
45+
sys.stdout = sys.stderr
46+
try:
47+
yield
48+
finally:
49+
sys.stdout = old
50+
51+
52+
def _safe_tool(fn):
53+
"""Decorator applied to every tool: protects stdout, logs entry/exit/errors."""
54+
@functools.wraps(fn)
55+
def wrapper(*args, **kwargs):
56+
name = fn.__name__
57+
_log(f">>> TOOL CALL {name} args={args!r} kwargs={kwargs!r}")
58+
with _protect_stdout():
59+
try:
60+
result = fn(*args, **kwargs)
61+
preview = repr(result)[:300]
62+
_log(f"<<< TOOL OK {name} result_preview={preview}")
63+
return result
64+
except Exception as exc:
65+
tb = _tb.format_exc()
66+
_log(f"!!! TOOL ERROR {name} {type(exc).__name__}: {exc}\n{tb}")
67+
raise
68+
return wrapper
69+
70+
71+
# ---------------------------------------------------------------------------
72+
# Server health
73+
# ---------------------------------------------------------------------------
74+
2075
def _server_is_running(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
2176
"""Check if the trace server is reachable."""
77+
url = f"http://{host}:{port}/tsp/api/health"
2278
try:
23-
urllib.request.urlopen(f"http://{host}:{port}/tsp/api/health", timeout=3)
79+
urllib.request.urlopen(url, timeout=3)
2480
return True
25-
except Exception:
81+
except Exception as exc:
82+
_log(f"Server health check failed ({url}): {exc}")
2683
return False
2784

2885

@@ -45,14 +102,46 @@ def ensure_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> str:
45102
return "Trace server was launched but is not yet responding. It may need more time to start."
46103

47104

105+
# ---------------------------------------------------------------------------
106+
# CLI runner
107+
# ---------------------------------------------------------------------------
108+
48109
def run_cli(*args: str) -> str:
49110
"""Run a tmll_cli.py command and return output."""
50-
result = subprocess.run(
51-
[sys.executable, CLI_PATH, "--log-stderr", *args],
52-
capture_output=True, text=True, timeout=120
53-
)
111+
cmd = [sys.executable, CLI_PATH, "--log-stderr", *args]
112+
_log(f"run_cli: executing {' '.join(cmd)}")
113+
try:
114+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
115+
except subprocess.TimeoutExpired as exc:
116+
msg = (
117+
f"CLI timed out after 120s\n"
118+
f" command: {' '.join(cmd)}\n"
119+
f" partial stdout: {exc.stdout!r}\n"
120+
f" partial stderr: {exc.stderr!r}"
121+
)
122+
_log(f"run_cli TIMEOUT: {msg}")
123+
raise RuntimeError(msg)
124+
except Exception as exc:
125+
msg = (
126+
f"Failed to launch CLI: {type(exc).__name__}: {exc}\n"
127+
f" command: {' '.join(cmd)}"
128+
)
129+
_log(f"run_cli LAUNCH ERROR: {msg}")
130+
raise RuntimeError(msg)
131+
132+
_log(f"run_cli: exit_code={result.returncode} stdout_len={len(result.stdout)} stderr_len={len(result.stderr)}")
133+
if result.stderr.strip():
134+
_log(f"run_cli stderr:\n{result.stderr.strip()}")
135+
54136
if result.returncode != 0:
55-
raise RuntimeError(result.stderr or f"CLI exited with code {result.returncode}")
137+
msg = (
138+
f"CLI exited with code {result.returncode}\n"
139+
f" command: {' '.join(cmd)}\n"
140+
f" stdout: {result.stdout.strip()}\n"
141+
f" stderr: {result.stderr.strip()}"
142+
)
143+
_log(f"run_cli FAILED: {msg}")
144+
raise RuntimeError(msg)
56145
return result.stdout.strip()
57146

58147

@@ -81,33 +170,42 @@ def _global_args(host: Optional[str], port: Optional[int]) -> list[str]:
81170
return args
82171

83172

173+
# ---------------------------------------------------------------------------
174+
# Tools
175+
# ---------------------------------------------------------------------------
176+
84177
@mcp.tool()
178+
@_safe_tool
85179
def create_experiment(traces: list[str], experiment_name: str, host: Optional[str] = None, port: Optional[int] = None) -> str:
86180
"""Create a trace experiment from LTTng trace files or directories."""
87181
return run_cli(*_global_args(host, port), "create", *traces, "-n", experiment_name)
88182

89183

90184
@mcp.tool()
185+
@_safe_tool
91186
def list_experiments() -> str:
92187
"""List all open experiments."""
93188
return run_cli("list")
94189

95190

96191
@mcp.tool()
192+
@_safe_tool
97193
def list_outputs(experiment_id: str, keywords: Optional[list[str]] = None) -> str:
98194
"""List available outputs for an experiment."""
99195
args = build_args({"keywords": ("-k", keywords)})
100196
return run_cli("list-outputs", experiment_id, *args)
101197

102198

103199
@mcp.tool()
200+
@_safe_tool
104201
def fetch_data(experiment_id: str, keywords: Optional[list[str]] = None, output_file: Optional[str] = None) -> str:
105202
"""Fetch data from experiment outputs."""
106203
args = build_args({"keywords": ("-k", keywords or ["cpu usage"]), "output_file": ("-o", output_file)})
107204
return run_cli("fetch-data", experiment_id, *args)
108205

109206

110207
@mcp.tool()
208+
@_safe_tool
111209
def delete_experiment(experiment_id: str) -> str:
112210
"""Delete an experiment."""
113211
return run_cli("delete", experiment_id)
@@ -121,6 +219,7 @@ def detect_anomalies(experiment_id: str, keywords: Optional[list[str]] = None, m
121219

122220

123221
@mcp.tool()
222+
@_safe_tool
124223
def detect_memory_leak(experiment_id: str, keywords: Optional[list[str]] = None) -> str:
125224
"""Detect memory leaks in trace data."""
126225
args = build_args({"keywords": ("-k", keywords or ["memory"])})
@@ -135,6 +234,7 @@ def detect_changepoints(experiment_id: str, keywords: Optional[list[str]] = None
135234

136235

137236
@mcp.tool()
237+
@_safe_tool
138238
def analyze_correlation(experiment_id: str, keywords: Optional[list[str]] = None, method: Optional[str] = None) -> str:
139239
"""Analyze correlation between outputs for root cause analysis (pearson, kendall, spearman)."""
140240
args = build_args({"keywords": ("-k", keywords or ["cpu", "memory"]), "method": ("-m", method or "pearson")})
@@ -157,11 +257,116 @@ def detect_idle_resources(experiment_id: str, keywords: Optional[list[str]] = No
157257

158258

159259
@mcp.tool()
260+
@_safe_tool
160261
def plan_capacity(experiment_id: str, keywords: Optional[list[str]] = None, horizon: Optional[int] = None) -> str:
161262
"""Perform capacity planning with predictive models."""
162263
args = build_args({"keywords": ("-k", keywords or ["cpu usage"]), "horizon": ("-H", horizon or 100)})
163264
return run_cli("capacity", experiment_id, *args)
164265

165266

267+
@mcp.tool()
268+
def cluster_data(experiment_id: str, keywords: Optional[list[str]] = None, n_clusters: Optional[int] = None, method: Optional[str] = None) -> str:
269+
"""Perform clustering analysis on trace data (kmeans, dbscan, hierarchical)."""
270+
args = build_args({"keywords": ("-k", keywords or ["cpu usage"]), "n_clusters": ("-n", n_clusters or 3), "method": ("-m", method or "kmeans")})
271+
return run_cli("cluster", experiment_id, *args)
272+
273+
274+
@mcp.tool()
275+
@_safe_tool
276+
def plot_xy_with_anomalies(
277+
experiment_id: str,
278+
keywords: Optional[list[str]] = None,
279+
method: Optional[str] = None,
280+
host: Optional[str] = None,
281+
port: Optional[int] = None,
282+
resample_freq: Optional[str] = None,
283+
) -> list[TextContent | ImageContent]:
284+
"""Fetch XY data from an experiment, run anomaly detection, and return an annotated plot image with a text summary."""
285+
from tmll.tmll_client import TMLLClient
286+
from tmll.common.models.experiment import Experiment
287+
from tmll.ml.modules.anomaly_detection.anomaly_detection_module import AnomalyDetection
288+
289+
h = host or DEFAULT_HOST
290+
p = port or DEFAULT_PORT
291+
keywords = keywords or ["cpu usage"]
292+
method = method or "iforest"
293+
294+
client = TMLLClient(h, p)
295+
296+
resp = client.tsp_client.fetch_experiment(experiment_id)
297+
if resp.status_code != 200:
298+
return [TextContent(type="text", text=f"Experiment {experiment_id} not found (status={resp.status_code}).")]
299+
experiment = Experiment.from_tsp_experiment(resp.model)
300+
experiment.assign_outputs(client._fetch_outputs(experiment))
301+
302+
outputs = experiment.find_outputs(keyword=keywords, type=["xy"])
303+
if not outputs:
304+
return [TextContent(type="text", text="No XY outputs found matching keywords.")]
305+
306+
ad_kwargs = {}
307+
if resample_freq:
308+
ad_kwargs["resample_freq"] = resample_freq
309+
ad = AnomalyDetection(client, experiment, outputs, **ad_kwargs)
310+
result = ad.find_anomalies(method=method)
311+
if not result or not result.anomalies:
312+
return [TextContent(type="text", text="Anomaly detection returned no results.")]
313+
314+
colors = plt.colormaps.get_cmap("tab10")
315+
contents: list[TextContent | ImageContent] = []
316+
total_anomalies = 0
317+
318+
for idx, (name, dataframe) in enumerate(ad.dataframes.items()):
319+
anomaly_df = result.anomalies.get(name, pd.DataFrame())
320+
periods = result.anomaly_periods.get(name, [])
321+
322+
fig, ax = plt.subplots(figsize=(14, 4), dpi=120)
323+
ax.plot(dataframe.index, dataframe.iloc[:, 0], color=colors(idx), linewidth=1.2, label=name)
324+
325+
for i, (start, end) in enumerate(periods):
326+
ax.axvspan(start, end, color="red", alpha=0.2, label="Anomaly Period" if i == 0 else None)
327+
328+
if not anomaly_df.empty:
329+
is_anomaly_cols = anomaly_df.filter(regex="_is_anomaly$")
330+
if not is_anomaly_cols.empty:
331+
is_anomaly = is_anomaly_cols.any(axis=1)
332+
else:
333+
is_anomaly = anomaly_df.any(axis=1)
334+
n_anomaly_points = int(is_anomaly.sum())
335+
total_anomalies += n_anomaly_points
336+
337+
# Scatter points not already inside a shaded period
338+
for point in anomaly_df[is_anomaly].index:
339+
if any(s <= point <= e for s, e in periods):
340+
continue
341+
if point in dataframe.index:
342+
ax.scatter(point, dataframe.loc[point].values[0], color="red", s=40, zorder=5)
343+
344+
ax.set_title(f"Anomaly Detection: {name} ({method})")
345+
ax.set_xlabel("Time")
346+
ax.set_ylabel(name)
347+
ax.legend(loc="upper right", fontsize=8)
348+
fig.tight_layout()
349+
350+
buf = io.BytesIO()
351+
fig.savefig(buf, format="png")
352+
plt.close(fig)
353+
buf.seek(0)
354+
contents.append(ImageContent(type="image", data=base64.b64encode(buf.read()).decode(), mimeType="image/png"))
355+
356+
period_summary = []
357+
for name, periods in result.anomaly_periods.items():
358+
for start, end in periods:
359+
period_summary.append(f" {name}: {start}{end}")
360+
361+
summary = f"Found {total_anomalies} anomalies across {len(ad.dataframes)} outputs using '{method}'."
362+
if period_summary:
363+
summary += "\n\nAnomaly periods:\n" + "\n".join(period_summary)
364+
365+
contents.insert(0, TextContent(type="text", text=summary))
366+
return contents
367+
368+
369+
166370
if __name__ == "__main__":
371+
_log(f"MCP server starting — CLI_PATH={CLI_PATH} python={sys.executable}")
167372
mcp.run()

tmll/ml/modules/anomaly_detection/anomaly_detection_module.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ def __init__(self, client: TMLLClient, experiment: Experiment,
8282
def _process(self, outputs: Optional[List[Output]] = None, **kwargs) -> None:
8383
super()._process(outputs=outputs,
8484
normalize=False,
85-
min_size=kwargs.get("min_size", MINIMUM_REQUIRED_DATAPOINTS),
8685
**kwargs)
8786

8887
def _post_process(self, **kwargs) -> None:

0 commit comments

Comments
 (0)