use std::collections::BTreeMap; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{ CallToolResult, ContentBlock, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo, }; use rmcp::{ErrorData as McpError, ServerHandler, schemars, tool, tool_handler, tool_router}; use serde::{Deserialize, Serialize}; use serde_json::Value; // ---- logs / timeline / stats ---- #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct QueryLogsParams { #[serde(default)] pub filter: Option, #[serde(default)] 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, pub message_match_regex: String, pub capture_group: String, #[serde(default)] pub json_field_path: Option, pub kind: String, #[serde(default)] pub elapsed_since_last: bool, } // ---- metrics ---- #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] pub struct DeclareMetricParams { pub name: String, pub message_match_regex: String, #[serde(default)] pub label_params: Vec, #[serde(default)] pub value_param: Option, #[serde(default)] pub matched_labels: Option>, #[serde(default)] pub unmatched_labels: Option>, /// Whether this metric's value is a point-in-time reading ("gauge", e.g. queue depth) or a /// monotonically-increasing total ("counter", e.g. requests served). Defaults to "gauge". #[serde(default, skip_serializing_if = "Option::is_none")] pub semantics: 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)] pub profile: Option, pub name: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct AddPanelParams { #[serde(default)] pub profile: Option, pub dashboard_id: String, #[serde(default)] pub panel_id: Option, pub title: String, pub promql: String, #[serde(default = "default_chart_type")] pub chart_type: String, } fn default_chart_type() -> String { "line".to_string() } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct GetDashboardParams { #[serde(default)] pub profile: Option, 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 / theming ---- #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] pub struct UpdateSettingsParams { #[serde(default)] pub theme: Option, #[serde(default)] pub max_logs: Option>, #[serde(default)] pub ntfy_topic: Option>, #[serde(default)] pub ntfy_server: Option>, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct GetThemeContentParams { pub name: 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, base_url: String, #[allow(dead_code)] tool_router: ToolRouter, } fn mcp_error(context: &str, err: impl std::fmt::Display) -> McpError { McpError::internal_error(format!("{context}: {err}"), None) } fn json_result(value: Value) -> Result { Ok(CallToolResult::success(vec![ContentBlock::text( serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()), )])) } #[tool_router] impl OttylMcp { pub fn new(base_url: String) -> Self { Self { client: reqwest::Client::new(), base_url, tool_router: Self::tool_router(), } } fn url(&self, path: &str) -> String { format!("{}{path}", self.base_url) } async fn resolve_profile(&self, explicit: Option) -> Result { if let Some(profile) = explicit { return Ok(profile); } let settings: Value = self .client .get(self.url("/api/settings")) .send() .await .map_err(|err| mcp_error("failed to reach ottyl", err))? .json() .await .map_err(|err| mcp_error("failed to parse settings response", err))?; settings .get("active_profile") .and_then(Value::as_str) .map(str::to_string) .ok_or_else(|| { McpError::invalid_params( "no active profile on the target instance; pass `profile` explicitly", None, ) }) } 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, 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(limit) = params.limit { query.push(("limit", limit.to_string())); } 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(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 })) } else { json_result(logs) } } #[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 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." )] async fn declare_metric( &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/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())); } 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.")] async fn create_dashboard( &self, Parameters(params): Parameters, ) -> Result { let profile = self.resolve_profile(params.profile).await?; 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 (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?; 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.")] async fn add_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 panel_id = params .panel_id .clone() .unwrap_or_else(|| generate_panel_id(¶ms.title, ¶ms.promql)); let new_panel = serde_json::json!({ "id": panel_id, "title": params.title, "promql": params.promql, "chart_type": params.chart_type, }); let panels = dashboard .get_mut("panels") .and_then(Value::as_array_mut) .ok_or_else(|| mcp_error("unexpected dashboard shape", "missing panels array"))?; match panels .iter() .position(|panel| panel.get("id") == new_panel.get("id")) { Some(index) => panels[index] = new_panel, None => panels.push(new_panel), } json_result( self.save_dashboard(&profile, ¶ms.dashboard_id, dashboard) .await?, ) } #[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 (theme, 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 the names of the themes embedded in this build (see /api/themes).")] async fn list_themes(&self) -> Result { json_result(self.get_json("/api/themes", &[]).await?) } #[tool(description = "Read the raw CSS content of a theme by name.")] async fn get_theme_content( &self, Parameters(params): Parameters, ) -> Result { let response = self .client .get(self.url(&format!("/api/themes/{}", urlencoding_path(¶ms.name)))) .send() .await .map_err(|err| mcp_error("failed to reach ottyl", err))?; let status = response.status(); let text = response .text() .await .map_err(|err| mcp_error("failed to read theme content response", err))?; if !status.is_success() { return Err(mcp_error("ottyl rejected the request", text)); } 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?, ) } } fn urlencoding_path(segment: &str) -> String { segment .bytes() .map(|b| match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { (b as char).to_string() } _ => format!("%{b:02X}"), }) .collect() } fn generate_panel_id(title: &str, promql: &str) -> String { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::time::{SystemTime, UNIX_EPOCH}; let mut hasher = DefaultHasher::new(); title.hash(&mut hasher); promql.hash(&mut hasher); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); nanos.hash(&mut hasher); format!("panel-{:016x}", hasher.finish()) } #[tool_handler] impl ServerHandler for OttylMcp { fn get_info(&self) -> ServerInfo { ServerInfo::new( ServerCapabilities::builder().enable_tools().build(), ) .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. 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 & themes, \ alert rules, and profile management. See the embedded SKILL.md (GET /SKILL.md on the \ target instance) for examples." .to_string(), ) } }