From 3b3ace146af67288eb33a091b12c486c9f1f9808 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sun, 6 Sep 2026 17:34:36 +0100 Subject: [PATCH] Increase mcp scope --- SKILL.md | 140 ++- src/api/mcp_server.rs | 800 +++++++++++++++--- ...70-cli-functionality-needs-fleshing-out.md | 43 - 3 files changed, 795 insertions(+), 188 deletions(-) delete mode 100644 tickets/070-cli-functionality-needs-fleshing-out.md diff --git a/SKILL.md b/SKILL.md index 86df36b..019dcad 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: ottyl -description: Query and configure a running ottyl observability instance (logs, params, metrics, dashboards) via its MCP server. +description: Query and configure a running ottyl observability instance (logs, params, metrics, dashboards, settings, alerts, profiles) via its MCP server. --- # ottyl @@ -9,6 +9,11 @@ ottyl turns a piped command's stdout into a searchable log timeline plus ad-hoc dashboards, served from a single process. This doc covers how an agent connects to and drives a *running* instance. +Everything the web UI can do to change the running instance's state is exposed as an MCP tool — +declarations, dashboards, settings, alerts, profiles. Chart panels aren't screenshotted: use +`render_panel` / `render_dashboard` to get a chart's underlying series data back as JSON, so you +can reason about it directly instead of needing a browser. + ## Find a session Each `ottyl` process registers itself under a name (random, e.g. `comic-gazelle`, unless started @@ -30,7 +35,8 @@ ottyl mcp --session comic-gazelle ottyl mcp --port 51423 ``` -This exposes six tools, each a thin proxy onto the target instance's HTTP API. +This is a thin proxy onto the target instance's HTTP API — each tool below maps to one or more +`/api/...` routes. ## Get the latest SKILL.md @@ -49,58 +55,100 @@ when the binary is rebuilt with a new `SKILL.md`. ## Tools -### `query_logs` -Count or read log lines matching a FilterBar query. +Most tools that operate on a profile take an optional `profile` argument; omit it to use the +instance's active profile. -```json -{ "filter": "regex(\"error\")", "count_only": true } -``` +### Logs, timeline, stats -### `declare_param` -Extract a named value from matching log lines. +- **`query_logs`** — count or read log lines matching a FilterBar query, with time/sequence range + and sort options. + ```json + { "filter": "regex(\"error\")", "count_only": true } + ``` +- **`get_timeline`** — bucketed log-count histogram matching an optional filter (the data behind + the Log Timeline chart). +- **`get_stats`** — overall ingest stats: log count, sequence/timestamp bounds, ingest lag. -```json -{ - "name": "status_code", - "message_match_regex": "status=(\\d+)", - "capture_group": "1", - "kind": "number" -} -``` +### Params -### `declare_metric` -Bucket event counts/values by label, derived from matching log lines. +- **`list_params`** — list declared params (including auto-derived ones). +- **`declare_param`** — extract a named value from matching log lines. + ```json + { "name": "status_code", "message_match_regex": "status=(\\d+)", "capture_group": "1", "kind": "number" } + ``` +- **`clear_params`** — clear all declared params and re-derive auto params from retained logs. -```json -{ - "name": "errors_total", - "message_match_regex": "level=error", - "label_params": ["status_code"] -} -``` +### Metrics -### `create_dashboard` -Create an empty dashboard in a profile (defaults to the active profile). +- **`list_metrics`** — list declared metrics. +- **`declare_metric`** — bucket event counts/values by label, derived from matching log lines. + ```json + { "name": "errors_total", "message_match_regex": "level=error", "label_params": ["status_code"] } + ``` +- **`update_metric`** — update an existing metric declaration in place (`name` plus the same + fields as `declare_metric`). +- **`delete_metric`** — delete a declared metric by name. +- **`get_metric_bucket_values`** — read a metric's raw bucket values, optionally filtered by + label (`labels: "status_code=500"`) and time/sequence range. -```json -{ "name": "Errors" } -``` +### PromQL -### `add_panel` -Add a panel to a dashboard, or update one in place by `panel_id`. +- **`run_promql_query`** — evaluate a PromQL-style query directly, returning raw series data + (`labels` + timestamped `points`) — the same evaluation dashboards use. + ```json + { "promql": "rate(errors_total[5m])" } + ``` -```json -{ - "dashboard_id": "", - "title": "Errors over time", - "promql": "rate(errors_total[5m])", - "chart_type": "line" -} -``` +### Dashboards & panels -### `get_dashboard` -Read a dashboard's current panels. +- **`list_dashboards`** — list dashboards saved in a profile. +- **`create_dashboard`** — create an empty dashboard. + ```json + { "name": "Errors" } + ``` +- **`get_dashboard`** — read a dashboard's panel definitions (promql/chart_type per panel, not + rendered data). +- **`delete_dashboard`** — delete a dashboard. +- **`add_panel`** — add a panel, or update one in place by `panel_id`. + ```json + { "dashboard_id": "", "title": "Errors over time", "promql": "rate(errors_total[5m])", "chart_type": "line" } + ``` +- **`delete_panel`** — remove a panel from a dashboard by `panel_id`. +- **`render_panel`** — resolve one panel's promql, evaluate it, and return `{ panel, series }` as + JSON — this is how to "see" a chart without a browser. + ```json + { "dashboard_id": "", "panel_id": "" } + ``` +- **`render_dashboard`** — same as `render_panel` but for every panel in a dashboard at once, + returning `{ dashboard, panels: [{ panel, series }, ...] }`. -```json -{ "dashboard_id": "" } -``` +### Settings & theming + +- **`get_settings`** / **`update_settings`** — read/update color scheme, active profile, + `max_logs`, custom theme, ntfy topic/server. +- **`list_css`** — list imported/linked custom CSS theme entries. +- **`import_css`** — import a CSS theme by file content. +- **`link_css`** — link an external CSS file/URL as a theme instead of importing content. +- **`remove_css`** / **`get_css_content`** — remove a CSS entry / read its raw content by id. + +### Alerts + +- **`list_alert_rules`** — list configured alert rules. +- **`create_alert_rule`** / **`update_alert_rule`** — `condition` is one of: + - `{"kind":"immediate","query":"..."}` + - `{"kind":"threshold","query":"...","op":"gt|lt|ge|le|eq|ne","threshold":N,"window_ms":N}` + - `{"kind":"newvalue","field_regex":"...","capture_group":"..."}` + - `{"kind":"ratio","query_a":"...","query_b":"...","op":"...","ratio":N,"window_ms":N}` +- **`delete_alert_rule`** — delete a rule by id. +- **`test_alert_notification`** — send a test ntfy notification to verify delivery config. + +### Profiles + +- **`list_profiles`** — list saved profiles with summary counts. +- **`create_profile`** — create a new, empty profile. + ```json + { "name": "my-service" } + ``` +- **`delete_profile`** — delete a profile by name. +- **`activate_profile`** — activate a profile, swapping in its declarations and dashboards as the + running instance's live state. diff --git a/src/api/mcp_server.rs b/src/api/mcp_server.rs index d8a6b80..06e7090 100644 --- a/src/api/mcp_server.rs +++ b/src/api/mcp_server.rs @@ -9,6 +9,8 @@ use rmcp::{ErrorData as McpError, ServerHandler, schemars, tool, tool_handler, t use serde::{Deserialize, Serialize}; use serde_json::Value; +// ---- logs / timeline / stats ---- + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct QueryLogsParams { #[serde(default)] @@ -17,8 +19,30 @@ pub struct QueryLogsParams { pub count_only: bool, #[serde(default)] pub limit: Option, + #[serde(default)] + pub since_sequence: Option, + #[serde(default)] + pub start_ms: Option, + #[serde(default)] + pub end_ms: Option, + #[serde(default)] + pub sort_by: Option, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct GetTimelineParams { + #[serde(default)] + pub filter: Option, + #[serde(default)] + pub start_ms: Option, + #[serde(default)] + pub end_ms: Option, + #[serde(default)] + pub bucket_ms: Option, +} + +// ---- params ---- + #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] pub struct DeclareParamParams { pub name: String, @@ -31,6 +55,8 @@ pub struct DeclareParamParams { pub elapsed_since_last: bool, } +// ---- metrics ---- + #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] pub struct DeclareMetricParams { pub name: String, @@ -45,6 +71,46 @@ pub struct DeclareMetricParams { pub unmatched_labels: Option>, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct UpdateMetricParams { + /// Existing metric name to update. + pub name: String, + #[serde(flatten)] + pub declaration: DeclareMetricParams, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DeleteMetricParams { + pub name: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct GetMetricBucketValuesParams { + pub name: String, + /// Comma-separated `label=value` pairs to filter buckets by, e.g. "status_code=500". + #[serde(default)] + pub labels: Option, + #[serde(default)] + pub since_sequence: Option, + #[serde(default)] + pub start_ms: Option, + #[serde(default)] + pub end_ms: Option, + #[serde(default)] + pub limit: Option, +} + +// ---- promql / dashboards ---- + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct RunPromqlQueryParams { + pub promql: String, + #[serde(default)] + pub start_ms: Option, + #[serde(default)] + pub end_ms: Option, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct CreateDashboardParams { #[serde(default)] @@ -76,6 +142,124 @@ pub struct GetDashboardParams { pub dashboard_id: String, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ListDashboardsParams { + #[serde(default)] + pub profile: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DeletePanelParams { + #[serde(default)] + pub profile: Option, + pub dashboard_id: String, + pub panel_id: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct RenderPanelParams { + #[serde(default)] + pub profile: Option, + pub dashboard_id: String, + pub panel_id: String, + #[serde(default)] + pub start_ms: Option, + #[serde(default)] + pub end_ms: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct RenderDashboardParams { + #[serde(default)] + pub profile: Option, + pub dashboard_id: String, + #[serde(default)] + pub start_ms: Option, + #[serde(default)] + pub end_ms: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DeleteDashboardParams { + #[serde(default)] + pub profile: Option, + pub dashboard_id: String, +} + +// ---- settings / css ---- + +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct UpdateSettingsParams { + #[serde(default)] + pub color_scheme: Option, + #[serde(default)] + pub max_logs: Option>, + #[serde(default)] + pub custom_theme_id: Option>, + #[serde(default)] + pub ntfy_topic: Option>, + #[serde(default)] + pub ntfy_server: Option>, +} + +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ImportCssParams { + pub filename: String, + pub content: String, +} + +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct LinkCssParams { + pub path_or_url: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct CssIdParams { + pub id: String, +} + +// ---- alerts ---- + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct CreateAlertRuleParams { + pub name: String, + pub enabled: bool, + /// One of: {"kind":"immediate","query":"..."}, {"kind":"threshold","query":"...","op":"gt|lt|ge|le|eq|ne","threshold":N,"window_ms":N}, {"kind":"newvalue","field_regex":"...","capture_group":"..."}, {"kind":"ratio","query_a":"...","query_b":"...","op":"...","ratio":N,"window_ms":N} + pub condition: Value, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct UpdateAlertRuleParams { + pub id: String, + pub name: String, + pub enabled: bool, + pub condition: Value, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DeleteAlertRuleParams { + pub id: String, +} + +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TestAlertNotificationParams { + pub topic: String, + #[serde(default)] + pub server: Option, +} + +// ---- profiles ---- + +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct CreateProfileParams { + pub name: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ProfileNameParams { + pub name: String, +} + #[derive(Clone)] pub struct OttylMcp { client: reqwest::Client, @@ -133,6 +317,83 @@ impl OttylMcp { }) } + async fn get_json(&self, path: &str, query: &[(&str, String)]) -> Result { + let response = self + .client + .get(self.url(path)) + .query(query) + .send() + .await + .map_err(|err| mcp_error("failed to reach ottyl", err))?; + let status = response.status(); + let body: Value = response + .json() + .await + .map_err(|err| mcp_error("failed to parse response", err))?; + if !status.is_success() { + return Err(mcp_error("ottyl rejected the request", body)); + } + Ok(body) + } + + async fn send_json( + &self, + method: reqwest::Method, + path: &str, + body: Option<&Value>, + ) -> Result { + let mut request = self.client.request(method, self.url(path)); + if let Some(body) = body { + request = request.json(body); + } + let response = request + .send() + .await + .map_err(|err| mcp_error("failed to reach ottyl", err))?; + let status = response.status(); + if status == reqwest::StatusCode::NO_CONTENT { + return Ok(Value::Null); + } + let body: Value = response + .json() + .await + .map_err(|err| mcp_error("failed to parse response", err))?; + if !status.is_success() { + return Err(mcp_error("ottyl rejected the request", body)); + } + Ok(body) + } + + async fn fetch_dashboard(&self, profile: &str, dashboard_id: &str) -> Result { + self.get_json( + &format!( + "/api/settings/profiles/{}/dashboards/{}", + urlencoding_path(profile), + urlencoding_path(dashboard_id) + ), + &[], + ) + .await + } + + async fn run_promql( + &self, + promql: &str, + start_ms: Option, + end_ms: Option, + ) -> Result { + let mut query: Vec<(&str, String)> = vec![("promql", promql.to_string())]; + if let Some(start_ms) = start_ms { + query.push(("start_ms", start_ms.to_string())); + } + if let Some(end_ms) = end_ms { + query.push(("end_ms", end_ms.to_string())); + } + self.get_json("/api/query", &query).await + } + + // ---- logs / timeline / stats ---- + #[tool(description = "Count or read log lines matching a FilterBar query against the running ottyl instance.")] async fn query_logs( &self, @@ -145,20 +406,19 @@ impl OttylMcp { if let Some(limit) = params.limit { query.push(("limit", limit.to_string())); } - let response = self - .client - .get(self.url("/api/logs")) - .query(&query) - .send() - .await - .map_err(|err| mcp_error("failed to reach ottyl", err))?; - if !response.status().is_success() { - return Err(mcp_error("ottyl rejected the query", response.status())); + if let Some(since_sequence) = params.since_sequence { + query.push(("since_sequence", since_sequence.to_string())); } - let logs: Value = response - .json() - .await - .map_err(|err| mcp_error("failed to parse logs response", err))?; + if let Some(start_ms) = params.start_ms { + query.push(("start_ms", start_ms.to_string())); + } + if let Some(end_ms) = params.end_ms { + query.push(("end_ms", end_ms.to_string())); + } + if let Some(sort_by) = ¶ms.sort_by { + query.push(("sort_by", sort_by.clone())); + } + let logs = self.get_json("/api/logs", &query).await?; if params.count_only { let count = logs.as_array().map(Vec::len).unwrap_or(0); json_result(serde_json::json!({ "count": count })) @@ -167,27 +427,58 @@ impl OttylMcp { } } + #[tool(description = "Read the log-count timeline (bucketed histogram) matching an optional FilterBar query — the same data backing the Log Timeline chart.")] + async fn get_timeline( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut query: Vec<(&str, String)> = Vec::new(); + if let Some(filter) = ¶ms.filter { + query.push(("filter", filter.clone())); + } + if let Some(start_ms) = params.start_ms { + query.push(("start_ms", start_ms.to_string())); + } + if let Some(end_ms) = params.end_ms { + query.push(("end_ms", end_ms.to_string())); + } + if let Some(bucket_ms) = params.bucket_ms { + query.push(("bucket_ms", bucket_ms.to_string())); + } + json_result(self.get_json("/api/timeline", &query).await?) + } + + #[tool(description = "Read overall ingest stats: log count, sequence/timestamp bounds, ingest lag.")] + async fn get_stats(&self) -> Result { + json_result(self.get_json("/api/stats", &[]).await?) + } + + // ---- params ---- + + #[tool(description = "List all currently declared params (name, regex, kind, etc.), including auto-derived ones.")] + async fn list_params(&self) -> Result { + json_result(self.get_json("/api/params", &[]).await?) + } + #[tool(description = "Declare a param: a named value extracted from matching log lines.")] async fn declare_param( &self, Parameters(params): Parameters, ) -> Result { - let response = self - .client - .post(self.url("/api/params")) - .json(¶ms) - .send() - .await - .map_err(|err| mcp_error("failed to reach ottyl", err))?; - let status = response.status(); - let body: Value = response - .json() - .await - .map_err(|err| mcp_error("failed to parse declare_param response", err))?; - if !status.is_success() { - return Err(mcp_error("ottyl rejected the param declaration", body)); - } - json_result(body) + let body = serde_json::to_value(¶ms).map_err(|err| mcp_error("failed to encode params", err))?; + json_result(self.send_json(reqwest::Method::POST, "/api/params", Some(&body)).await?) + } + + #[tool(description = "Clear all declared params and re-derive auto params from retained log lines.")] + async fn clear_params(&self) -> Result { + json_result(self.send_json(reqwest::Method::DELETE, "/api/params", None).await?) + } + + // ---- metrics ---- + + #[tool(description = "List all currently declared metrics.")] + async fn list_metrics(&self) -> Result { + json_result(self.get_json("/api/metrics", &[]).await?) } #[tool(description = "Declare a metric: named event counts/values bucketed by label, derived from matching log lines.")] @@ -195,22 +486,94 @@ impl OttylMcp { &self, Parameters(params): Parameters, ) -> Result { - let response = self - .client - .post(self.url("/api/metrics")) - .json(¶ms) - .send() - .await - .map_err(|err| mcp_error("failed to reach ottyl", err))?; - let status = response.status(); - let body: Value = response - .json() - .await - .map_err(|err| mcp_error("failed to parse declare_metric response", err))?; - if !status.is_success() { - return Err(mcp_error("ottyl rejected the metric declaration", body)); + let body = serde_json::to_value(¶ms).map_err(|err| mcp_error("failed to encode params", err))?; + json_result(self.send_json(reqwest::Method::POST, "/api/metrics", Some(&body)).await?) + } + + #[tool(description = "Update an existing metric declaration in place by name.")] + async fn update_metric( + &self, + Parameters(params): Parameters, + ) -> Result { + let body = serde_json::to_value(¶ms.declaration) + .map_err(|err| mcp_error("failed to encode params", err))?; + json_result( + self.send_json( + reqwest::Method::PUT, + &format!("/api/metrics/{}", urlencoding_path(¶ms.name)), + Some(&body), + ) + .await?, + ) + } + + #[tool(description = "Delete a declared metric by name.")] + async fn delete_metric( + &self, + Parameters(params): Parameters, + ) -> Result { + json_result( + self.send_json( + reqwest::Method::DELETE, + &format!("/api/metrics/{}", urlencoding_path(¶ms.name)), + None, + ) + .await?, + ) + } + + #[tool(description = "Read a metric's raw bucket values (labels, sequence, timestamp, value), optionally filtered by label and time range.")] + async fn get_metric_bucket_values( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut query: Vec<(&str, String)> = Vec::new(); + if let Some(labels) = ¶ms.labels { + query.push(("labels", labels.clone())); } - json_result(body) + if let Some(since_sequence) = params.since_sequence { + query.push(("since_sequence", since_sequence.to_string())); + } + if let Some(start_ms) = params.start_ms { + query.push(("start_ms", start_ms.to_string())); + } + if let Some(end_ms) = params.end_ms { + query.push(("end_ms", end_ms.to_string())); + } + if let Some(limit) = params.limit { + query.push(("limit", limit.to_string())); + } + json_result( + self.get_json(&format!("/api/metrics/{}", urlencoding_path(¶ms.name)), &query) + .await?, + ) + } + + // ---- promql ---- + + #[tool(description = "Evaluate a PromQL-style query directly and get back raw series data (labels + timestamped points) — the same evaluation dashboards use to render charts.")] + async fn run_promql_query( + &self, + Parameters(params): Parameters, + ) -> Result { + json_result(self.run_promql(¶ms.promql, params.start_ms, params.end_ms).await?) + } + + // ---- dashboards / panels ---- + + #[tool(description = "List dashboards saved in a profile (defaults to the active profile).")] + async fn list_dashboards( + &self, + Parameters(params): Parameters, + ) -> Result { + let profile = self.resolve_profile(params.profile).await?; + json_result( + self.get_json( + &format!("/api/settings/profiles/{}/dashboards", urlencoding_path(&profile)), + &[], + ) + .await?, + ) } #[tool(description = "Create a new, empty dashboard in a profile.")] @@ -219,35 +582,43 @@ impl OttylMcp { Parameters(params): Parameters, ) -> Result { let profile = self.resolve_profile(params.profile).await?; - let response = self - .client - .post(self.url(&format!( - "/api/settings/profiles/{}/dashboards", - urlencoding_path(&profile) - ))) - .json(&serde_json::json!({ "name": params.name })) - .send() - .await - .map_err(|err| mcp_error("failed to reach ottyl", err))?; - let status = response.status(); - let body: Value = response - .json() - .await - .map_err(|err| mcp_error("failed to parse create_dashboard response", err))?; - if !status.is_success() { - return Err(mcp_error("ottyl rejected the dashboard creation", body)); - } - json_result(body) + json_result( + self.send_json( + reqwest::Method::POST, + &format!("/api/settings/profiles/{}/dashboards", urlencoding_path(&profile)), + Some(&serde_json::json!({ "name": params.name })), + ) + .await?, + ) } - #[tool(description = "Read a dashboard's current panels.")] + #[tool(description = "Read a dashboard's current panels (definitions only — use render_panel or render_dashboard for the data a panel would chart).")] async fn get_dashboard( &self, Parameters(params): Parameters, ) -> Result { let profile = self.resolve_profile(params.profile).await?; - let body = self.fetch_dashboard(&profile, ¶ms.dashboard_id).await?; - json_result(body) + json_result(self.fetch_dashboard(&profile, ¶ms.dashboard_id).await?) + } + + #[tool(description = "Delete a dashboard from a profile.")] + async fn delete_dashboard( + &self, + Parameters(params): Parameters, + ) -> Result { + let profile = self.resolve_profile(params.profile).await?; + json_result( + self.send_json( + reqwest::Method::DELETE, + &format!( + "/api/settings/profiles/{}/dashboards/{}", + urlencoding_path(&profile), + urlencoding_path(¶ms.dashboard_id) + ), + None, + ) + .await?, + ) } #[tool(description = "Add a new panel to a dashboard, or update an existing one by panel_id.")] @@ -278,48 +649,276 @@ impl OttylMcp { None => panels.push(new_panel), } - let response = self - .client - .put(self.url(&format!( - "/api/settings/profiles/{}/dashboards/{}", - urlencoding_path(&profile), - urlencoding_path(¶ms.dashboard_id) - ))) - .json(&serde_json::json!({ "name": dashboard.get("name"), "panels": dashboard.get("panels") })) - .send() - .await - .map_err(|err| mcp_error("failed to reach ottyl", err))?; - let status = response.status(); - let body: Value = response - .json() - .await - .map_err(|err| mcp_error("failed to parse add_panel response", err))?; - if !status.is_success() { - return Err(mcp_error("ottyl rejected the panel update", body)); - } - json_result(body) + json_result(self.save_dashboard(&profile, ¶ms.dashboard_id, dashboard).await?) } - async fn fetch_dashboard(&self, profile: &str, dashboard_id: &str) -> Result { - let response = self - .client - .get(self.url(&format!( + #[tool(description = "Remove a panel from a dashboard by panel_id.")] + async fn delete_panel( + &self, + Parameters(params): Parameters, + ) -> Result { + let profile = self.resolve_profile(params.profile).await?; + let mut dashboard = self.fetch_dashboard(&profile, ¶ms.dashboard_id).await?; + let panels = dashboard + .get_mut("panels") + .and_then(Value::as_array_mut) + .ok_or_else(|| mcp_error("unexpected dashboard shape", "missing panels array"))?; + panels.retain(|panel| panel.get("id").and_then(Value::as_str) != Some(params.panel_id.as_str())); + json_result(self.save_dashboard(&profile, ¶ms.dashboard_id, dashboard).await?) + } + + async fn save_dashboard( + &self, + profile: &str, + dashboard_id: &str, + dashboard: Value, + ) -> Result { + self.send_json( + reqwest::Method::PUT, + &format!( "/api/settings/profiles/{}/dashboards/{}", urlencoding_path(profile), urlencoding_path(dashboard_id) - ))) + ), + Some(&serde_json::json!({ "name": dashboard.get("name"), "panels": dashboard.get("panels") })), + ) + .await + } + + #[tool(description = "Render one panel: resolve its promql from the dashboard definition, evaluate it, and return the chart's underlying series data (labels + timestamped points) as JSON — this is how an agent should 'see' a chart without a browser.")] + async fn render_panel( + &self, + Parameters(params): Parameters, + ) -> Result { + let profile = self.resolve_profile(params.profile).await?; + let dashboard = self.fetch_dashboard(&profile, ¶ms.dashboard_id).await?; + let panel = dashboard + .get("panels") + .and_then(Value::as_array) + .and_then(|panels| panels.iter().find(|panel| panel.get("id").and_then(Value::as_str) == Some(params.panel_id.as_str()))) + .ok_or_else(|| McpError::invalid_params("no panel with that panel_id in the dashboard", None))?; + let promql = panel + .get("promql") + .and_then(Value::as_str) + .ok_or_else(|| mcp_error("unexpected panel shape", "missing promql"))?; + let series = self.run_promql(promql, params.start_ms, params.end_ms).await?; + json_result(serde_json::json!({ + "panel": panel, + "series": series, + })) + } + + #[tool(description = "Render every panel in a dashboard: evaluate each panel's promql and return all their chart data as JSON, so an agent can reason about the whole dashboard without a browser.")] + async fn render_dashboard( + &self, + Parameters(params): Parameters, + ) -> Result { + let profile = self.resolve_profile(params.profile).await?; + let dashboard = self.fetch_dashboard(&profile, ¶ms.dashboard_id).await?; + let panels = dashboard + .get("panels") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut rendered = Vec::with_capacity(panels.len()); + for panel in panels { + let promql = panel.get("promql").and_then(Value::as_str).unwrap_or_default(); + let series = self.run_promql(promql, params.start_ms, params.end_ms).await?; + rendered.push(serde_json::json!({ "panel": panel, "series": series })); + } + json_result(serde_json::json!({ + "dashboard": { "id": dashboard.get("id"), "name": dashboard.get("name") }, + "panels": rendered, + })) + } + + // ---- settings / theming ---- + + #[tool(description = "Read the running instance's global settings (color scheme, active profile, max_logs, ntfy config).")] + async fn get_settings(&self) -> Result { + json_result(self.get_json("/api/settings", &[]).await?) + } + + #[tool(description = "Update global settings. Only fields provided are changed; pass null for an Option field to clear it.")] + async fn update_settings( + &self, + Parameters(params): Parameters, + ) -> Result { + let body = serde_json::to_value(¶ms).map_err(|err| mcp_error("failed to encode params", err))?; + json_result(self.send_json(reqwest::Method::PATCH, "/api/settings", Some(&body)).await?) + } + + #[tool(description = "List imported/linked custom CSS theme entries.")] + async fn list_css(&self) -> Result { + json_result(self.get_json("/api/settings/css", &[]).await?) + } + + #[tool(description = "Import a custom CSS theme file by content.")] + async fn import_css( + &self, + Parameters(params): Parameters, + ) -> Result { + let body = serde_json::to_value(¶ms).map_err(|err| mcp_error("failed to encode params", err))?; + json_result(self.send_json(reqwest::Method::POST, "/api/settings/css", Some(&body)).await?) + } + + #[tool(description = "Link an external CSS file or URL as a custom theme, instead of importing its content.")] + async fn link_css( + &self, + Parameters(params): Parameters, + ) -> Result { + let body = serde_json::to_value(¶ms).map_err(|err| mcp_error("failed to encode params", err))?; + json_result(self.send_json(reqwest::Method::POST, "/api/settings/css/link", Some(&body)).await?) + } + + #[tool(description = "Remove a custom CSS entry by id.")] + async fn remove_css( + &self, + Parameters(params): Parameters, + ) -> Result { + json_result( + self.send_json( + reqwest::Method::DELETE, + &format!("/api/settings/css/{}", urlencoding_path(¶ms.id)), + None, + ) + .await?, + ) + } + + #[tool(description = "Read the raw content of an imported CSS entry by id.")] + async fn get_css_content( + &self, + Parameters(params): Parameters, + ) -> Result { + let response = self + .client + .get(self.url(&format!("/api/settings/css/{}/content", urlencoding_path(¶ms.id)))) .send() .await .map_err(|err| mcp_error("failed to reach ottyl", err))?; let status = response.status(); - let body: Value = response - .json() + let text = response + .text() .await - .map_err(|err| mcp_error("failed to parse dashboard response", err))?; + .map_err(|err| mcp_error("failed to read css content response", err))?; if !status.is_success() { - return Err(mcp_error("ottyl rejected the dashboard lookup", body)); + return Err(mcp_error("ottyl rejected the request", text)); } - Ok(body) + json_result(serde_json::json!({ "content": text })) + } + + // ---- alerts ---- + + #[tool(description = "List configured alert rules.")] + async fn list_alert_rules(&self) -> Result { + json_result(self.get_json("/api/settings/alerts", &[]).await?) + } + + #[tool(description = "Create an alert rule.")] + async fn create_alert_rule( + &self, + Parameters(params): Parameters, + ) -> Result { + let body = serde_json::json!({ + "name": params.name, + "enabled": params.enabled, + "condition": params.condition, + }); + json_result(self.send_json(reqwest::Method::POST, "/api/settings/alerts", Some(&body)).await?) + } + + #[tool(description = "Update an existing alert rule by id.")] + async fn update_alert_rule( + &self, + Parameters(params): Parameters, + ) -> Result { + let body = serde_json::json!({ + "name": params.name, + "enabled": params.enabled, + "condition": params.condition, + }); + json_result( + self.send_json( + reqwest::Method::PUT, + &format!("/api/settings/alerts/{}", urlencoding_path(¶ms.id)), + Some(&body), + ) + .await?, + ) + } + + #[tool(description = "Delete an alert rule by id.")] + async fn delete_alert_rule( + &self, + Parameters(params): Parameters, + ) -> Result { + json_result( + self.send_json( + reqwest::Method::DELETE, + &format!("/api/settings/alerts/{}", urlencoding_path(¶ms.id)), + None, + ) + .await?, + ) + } + + #[tool(description = "Send a test ntfy notification to verify alert delivery is configured correctly.")] + async fn test_alert_notification( + &self, + Parameters(params): Parameters, + ) -> Result { + let body = serde_json::to_value(¶ms).map_err(|err| mcp_error("failed to encode params", err))?; + json_result( + self.send_json(reqwest::Method::POST, "/api/settings/alerts/test", Some(&body)) + .await?, + ) + } + + // ---- profiles ---- + + #[tool(description = "List all saved profiles with summary counts (params, metrics, dashboards).")] + async fn list_profiles(&self) -> Result { + json_result(self.get_json("/api/settings/profiles", &[]).await?) + } + + #[tool(description = "Create a new, empty profile.")] + async fn create_profile( + &self, + Parameters(params): Parameters, + ) -> Result { + let body = serde_json::to_value(¶ms).map_err(|err| mcp_error("failed to encode params", err))?; + json_result(self.send_json(reqwest::Method::POST, "/api/settings/profiles", Some(&body)).await?) + } + + #[tool(description = "Delete a profile by name.")] + async fn delete_profile( + &self, + Parameters(params): Parameters, + ) -> Result { + json_result( + self.send_json( + reqwest::Method::DELETE, + &format!("/api/settings/profiles/{}", urlencoding_path(¶ms.name)), + None, + ) + .await?, + ) + } + + #[tool(description = "Activate a profile: swaps in its param/metric declarations and dashboards as the running instance's live state.")] + async fn activate_profile( + &self, + Parameters(params): Parameters, + ) -> Result { + json_result( + self.send_json( + reqwest::Method::POST, + &format!("/api/settings/profiles/{}/activate", urlencoding_path(¶ms.name)), + None, + ) + .await?, + ) } } @@ -360,9 +959,12 @@ impl ServerHandler for OttylMcp { .with_server_info(Implementation::from_build_env()) .with_protocol_version(ProtocolVersion::V_2024_11_05) .with_instructions( - "Proxies to a running ottyl instance's HTTP API. Tools: query_logs, declare_param, \ - declare_metric, create_dashboard, add_panel, get_dashboard. See the embedded \ - SKILL.md (GET /SKILL.md on the target instance) for examples." + "Proxies to a running ottyl instance's HTTP API. Covers everything the web UI can do: \ + logs/timeline/stats, param & metric declarations, PromQL queries, dashboards & panels \ + (including render_panel/render_dashboard, which return a chart's underlying series data \ + as JSON so an agent can reason about it without a browser), settings & custom CSS themes, \ + alert rules, and profile management. See the embedded SKILL.md (GET /SKILL.md on the \ + target instance) for examples." .to_string(), ) } diff --git a/tickets/070-cli-functionality-needs-fleshing-out.md b/tickets/070-cli-functionality-needs-fleshing-out.md deleted file mode 100644 index b6eda89..0000000 --- a/tickets/070-cli-functionality-needs-fleshing-out.md +++ /dev/null @@ -1,43 +0,0 @@ -# 070 — CLI functionality needs to be greater — scope this out - -## Summary - -Wishlist feedback: "CLI functionality needs to be greater - todo -fleshout." This is a scoping ticket, not an implementation one — the -request as given doesn't say which CLI surface is thin (flags to -`ottyl` itself vs. profile/dashboard management vs. something -scriptable/headless) or what "greater" should mean in practice. - -Current CLI surface, for reference: `main.rs`'s arg parsing wires -stdin/`--file` tailing, `--metrics-url` polling, and profile -selection/loading (see `settings.rs`/`profile.rs`) into engine startup. -Anything beyond piping a command and viewing the dashboard in a browser -(inspecting state, managing profiles, exporting data, scripting against -a running instance) has no CLI path today — it's all through the web -UI's API calls. - -## Scope - -- Talk to Hugh about what's actually missing before writing an - implementation ticket. Candidate directions worth raising: - - A way to inspect current state (params/metrics/alert rules, log - counts) from the terminal without opening the browser. - - Profile management from the CLI (list/create/switch profiles) - rather than only through the web UI's settings. - - A non-interactive/headless mode for CI-type use (e.g. run for N - seconds against piped input, dump a summary, exit) — distinct from - today's "serve until the piped process exits" model. - - Flags to declare params/metrics/dashboards at launch without - hand-editing the profile file first. -- Once scoped, split into one or more concrete tickets with specific - acceptance criteria — this ticket should be closed by replacement, - not by a partial implementation guessing at the ask. - -## Acceptance criteria - -- N/A — this ticket's output is a conversation and a follow-up - ticket (or tickets), not code. - -## Depends on - -None.