Files
ottyl/src/api/mcp_server.rs
T
Hugh KaznowskiandClaude Sonnet 5 4a0fe92ce8 Add gauge/counter semantics to metric declarations (ticket 097)
Metrics previously only declared a data type (String/Number/...), so
rate() could be applied to a point-in-time gauge reading and sum()/avg()
could be applied directly to a cumulative counter reading with no guard
against either. Adds a MetricSemantics enum (Gauge, default; Counter)
threaded through DeclareMetricRequest, the engine's MetricDeclaration
data value, profile persistence, the API/MCP declaration routes, and the
Metrics panel's declaration form, then uses it in evaluate_promql to
reject rate() on a declared gauge and bare sum()/avg() on a declared
counter (suggesting rate() first). The guard only applies to metrics
with a captured value_param — bare occurrence-count metrics are always
safe under both operations, so existing dashboards are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeyhRfMYjMditek6geDRoQ
2026-09-07 12:30:06 +01:00

1028 lines
33 KiB
Rust

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<String>,
#[serde(default)]
pub count_only: bool,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub since_sequence: Option<u64>,
#[serde(default)]
pub start_ms: Option<i64>,
#[serde(default)]
pub end_ms: Option<i64>,
#[serde(default)]
pub sort_by: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct GetTimelineParams {
#[serde(default)]
pub filter: Option<String>,
#[serde(default)]
pub start_ms: Option<i64>,
#[serde(default)]
pub end_ms: Option<i64>,
#[serde(default)]
pub bucket_ms: Option<i64>,
}
// ---- 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<String>,
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<String>,
#[serde(default)]
pub value_param: Option<String>,
#[serde(default)]
pub matched_labels: Option<BTreeMap<String, String>>,
#[serde(default)]
pub unmatched_labels: Option<BTreeMap<String, String>>,
/// 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<String>,
}
#[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<String>,
#[serde(default)]
pub since_sequence: Option<u64>,
#[serde(default)]
pub start_ms: Option<i64>,
#[serde(default)]
pub end_ms: Option<i64>,
#[serde(default)]
pub limit: Option<usize>,
}
// ---- promql / dashboards ----
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct RunPromqlQueryParams {
pub promql: String,
#[serde(default)]
pub start_ms: Option<i64>,
#[serde(default)]
pub end_ms: Option<i64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct CreateDashboardParams {
#[serde(default)]
pub profile: Option<String>,
pub name: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct AddPanelParams {
#[serde(default)]
pub profile: Option<String>,
pub dashboard_id: String,
#[serde(default)]
pub panel_id: Option<String>,
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<String>,
pub dashboard_id: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ListDashboardsParams {
#[serde(default)]
pub profile: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DeletePanelParams {
#[serde(default)]
pub profile: Option<String>,
pub dashboard_id: String,
pub panel_id: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct RenderPanelParams {
#[serde(default)]
pub profile: Option<String>,
pub dashboard_id: String,
pub panel_id: String,
#[serde(default)]
pub start_ms: Option<i64>,
#[serde(default)]
pub end_ms: Option<i64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct RenderDashboardParams {
#[serde(default)]
pub profile: Option<String>,
pub dashboard_id: String,
#[serde(default)]
pub start_ms: Option<i64>,
#[serde(default)]
pub end_ms: Option<i64>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DeleteDashboardParams {
#[serde(default)]
pub profile: Option<String>,
pub dashboard_id: String,
}
// ---- settings / theming ----
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct UpdateSettingsParams {
#[serde(default)]
pub theme: Option<String>,
#[serde(default)]
pub max_logs: Option<Option<usize>>,
#[serde(default)]
pub ntfy_topic: Option<Option<String>>,
#[serde(default)]
pub ntfy_server: Option<Option<String>>,
}
#[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<String>,
}
// ---- 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<OttylMcp>,
}
fn mcp_error(context: &str, err: impl std::fmt::Display) -> McpError {
McpError::internal_error(format!("{context}: {err}"), None)
}
fn json_result(value: Value) -> Result<CallToolResult, McpError> {
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<String>) -> Result<String, McpError> {
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<Value, McpError> {
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<Value, McpError> {
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<Value, McpError> {
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<i64>,
end_ms: Option<i64>,
) -> Result<Value, McpError> {
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<QueryLogsParams>,
) -> Result<CallToolResult, McpError> {
let mut query: Vec<(&str, String)> = Vec::new();
if let Some(filter) = &params.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) = &params.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<GetTimelineParams>,
) -> Result<CallToolResult, McpError> {
let mut query: Vec<(&str, String)> = Vec::new();
if let Some(filter) = &params.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<CallToolResult, McpError> {
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<CallToolResult, McpError> {
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<DeclareParamParams>,
) -> Result<CallToolResult, McpError> {
let body = serde_json::to_value(&params)
.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<CallToolResult, McpError> {
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<CallToolResult, McpError> {
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<DeclareMetricParams>,
) -> Result<CallToolResult, McpError> {
let body = serde_json::to_value(&params)
.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<UpdateMetricParams>,
) -> Result<CallToolResult, McpError> {
let body = serde_json::to_value(&params.declaration)
.map_err(|err| mcp_error("failed to encode params", err))?;
json_result(
self.send_json(
reqwest::Method::PUT,
&format!("/api/metrics/{}", urlencoding_path(&params.name)),
Some(&body),
)
.await?,
)
}
#[tool(description = "Delete a declared metric by name.")]
async fn delete_metric(
&self,
Parameters(params): Parameters<DeleteMetricParams>,
) -> Result<CallToolResult, McpError> {
json_result(
self.send_json(
reqwest::Method::DELETE,
&format!("/api/metrics/{}", urlencoding_path(&params.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<GetMetricBucketValuesParams>,
) -> Result<CallToolResult, McpError> {
let mut query: Vec<(&str, String)> = Vec::new();
if let Some(labels) = &params.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(&params.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<RunPromqlQueryParams>,
) -> Result<CallToolResult, McpError> {
json_result(
self.run_promql(&params.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<ListDashboardsParams>,
) -> Result<CallToolResult, McpError> {
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<CreateDashboardParams>,
) -> Result<CallToolResult, McpError> {
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<GetDashboardParams>,
) -> Result<CallToolResult, McpError> {
let profile = self.resolve_profile(params.profile).await?;
json_result(self.fetch_dashboard(&profile, &params.dashboard_id).await?)
}
#[tool(description = "Delete a dashboard from a profile.")]
async fn delete_dashboard(
&self,
Parameters(params): Parameters<DeleteDashboardParams>,
) -> Result<CallToolResult, McpError> {
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(&params.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<AddPanelParams>,
) -> Result<CallToolResult, McpError> {
let profile = self.resolve_profile(params.profile).await?;
let mut dashboard = self.fetch_dashboard(&profile, &params.dashboard_id).await?;
let panel_id = params
.panel_id
.clone()
.unwrap_or_else(|| generate_panel_id(&params.title, &params.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, &params.dashboard_id, dashboard)
.await?,
)
}
#[tool(description = "Remove a panel from a dashboard by panel_id.")]
async fn delete_panel(
&self,
Parameters(params): Parameters<DeletePanelParams>,
) -> Result<CallToolResult, McpError> {
let profile = self.resolve_profile(params.profile).await?;
let mut dashboard = self.fetch_dashboard(&profile, &params.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, &params.dashboard_id, dashboard)
.await?,
)
}
async fn save_dashboard(
&self,
profile: &str,
dashboard_id: &str,
dashboard: Value,
) -> Result<Value, McpError> {
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<RenderPanelParams>,
) -> Result<CallToolResult, McpError> {
let profile = self.resolve_profile(params.profile).await?;
let dashboard = self.fetch_dashboard(&profile, &params.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<RenderDashboardParams>,
) -> Result<CallToolResult, McpError> {
let profile = self.resolve_profile(params.profile).await?;
let dashboard = self.fetch_dashboard(&profile, &params.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<CallToolResult, McpError> {
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<UpdateSettingsParams>,
) -> Result<CallToolResult, McpError> {
let body = serde_json::to_value(&params)
.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<CallToolResult, McpError> {
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<GetThemeContentParams>,
) -> Result<CallToolResult, McpError> {
let response = self
.client
.get(self.url(&format!("/api/themes/{}", urlencoding_path(&params.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<CallToolResult, McpError> {
json_result(self.get_json("/api/settings/alerts", &[]).await?)
}
#[tool(description = "Create an alert rule.")]
async fn create_alert_rule(
&self,
Parameters(params): Parameters<CreateAlertRuleParams>,
) -> Result<CallToolResult, McpError> {
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<UpdateAlertRuleParams>,
) -> Result<CallToolResult, McpError> {
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(&params.id)),
Some(&body),
)
.await?,
)
}
#[tool(description = "Delete an alert rule by id.")]
async fn delete_alert_rule(
&self,
Parameters(params): Parameters<DeleteAlertRuleParams>,
) -> Result<CallToolResult, McpError> {
json_result(
self.send_json(
reqwest::Method::DELETE,
&format!("/api/settings/alerts/{}", urlencoding_path(&params.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<TestAlertNotificationParams>,
) -> Result<CallToolResult, McpError> {
let body = serde_json::to_value(&params)
.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<CallToolResult, McpError> {
json_result(self.get_json("/api/settings/profiles", &[]).await?)
}
#[tool(description = "Create a new, empty profile.")]
async fn create_profile(
&self,
Parameters(params): Parameters<CreateProfileParams>,
) -> Result<CallToolResult, McpError> {
let body = serde_json::to_value(&params)
.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<ProfileNameParams>,
) -> Result<CallToolResult, McpError> {
json_result(
self.send_json(
reqwest::Method::DELETE,
&format!("/api/settings/profiles/{}", urlencoding_path(&params.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<ProfileNameParams>,
) -> Result<CallToolResult, McpError> {
json_result(
self.send_json(
reqwest::Method::POST,
&format!(
"/api/settings/profiles/{}/activate",
urlencoding_path(&params.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(),
)
}
}