Increase mcp scope
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: ottyl
|
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
|
# 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
|
dashboards, served from a single process. This doc covers how an agent connects to and drives a
|
||||||
*running* instance.
|
*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
|
## Find a session
|
||||||
|
|
||||||
Each `ottyl` process registers itself under a name (random, e.g. `comic-gazelle`, unless started
|
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
|
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
|
## Get the latest SKILL.md
|
||||||
|
|
||||||
@@ -49,58 +55,100 @@ when the binary is rebuilt with a new `SKILL.md`.
|
|||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
### `query_logs`
|
Most tools that operate on a profile take an optional `profile` argument; omit it to use the
|
||||||
Count or read log lines matching a FilterBar query.
|
instance's active profile.
|
||||||
|
|
||||||
|
### Logs, timeline, stats
|
||||||
|
|
||||||
|
- **`query_logs`** — count or read log lines matching a FilterBar query, with time/sequence range
|
||||||
|
and sort options.
|
||||||
```json
|
```json
|
||||||
{ "filter": "regex(\"error\")", "count_only": true }
|
{ "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.
|
||||||
|
|
||||||
### `declare_param`
|
### Params
|
||||||
Extract a named value from matching log lines.
|
|
||||||
|
|
||||||
|
- **`list_params`** — list declared params (including auto-derived ones).
|
||||||
|
- **`declare_param`** — extract a named value from matching log lines.
|
||||||
```json
|
```json
|
||||||
{
|
{ "name": "status_code", "message_match_regex": "status=(\\d+)", "capture_group": "1", "kind": "number" }
|
||||||
"name": "status_code",
|
```
|
||||||
"message_match_regex": "status=(\\d+)",
|
- **`clear_params`** — clear all declared params and re-derive auto params from retained logs.
|
||||||
"capture_group": "1",
|
|
||||||
"kind": "number"
|
### Metrics
|
||||||
}
|
|
||||||
|
- **`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.
|
||||||
|
|
||||||
|
### PromQL
|
||||||
|
|
||||||
|
- **`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])" }
|
||||||
```
|
```
|
||||||
|
|
||||||
### `declare_metric`
|
### Dashboards & panels
|
||||||
Bucket event counts/values by label, derived from matching log lines.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "errors_total",
|
|
||||||
"message_match_regex": "level=error",
|
|
||||||
"label_params": ["status_code"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `create_dashboard`
|
|
||||||
Create an empty dashboard in a profile (defaults to the active profile).
|
|
||||||
|
|
||||||
|
- **`list_dashboards`** — list dashboards saved in a profile.
|
||||||
|
- **`create_dashboard`** — create an empty dashboard.
|
||||||
```json
|
```json
|
||||||
{ "name": "Errors" }
|
{ "name": "Errors" }
|
||||||
```
|
```
|
||||||
|
- **`get_dashboard`** — read a dashboard's panel definitions (promql/chart_type per panel, not
|
||||||
### `add_panel`
|
rendered data).
|
||||||
Add a panel to a dashboard, or update one in place by `panel_id`.
|
- **`delete_dashboard`** — delete a dashboard.
|
||||||
|
- **`add_panel`** — add a panel, or update one in place by `panel_id`.
|
||||||
```json
|
```json
|
||||||
{
|
{ "dashboard_id": "<id>", "title": "Errors over time", "promql": "rate(errors_total[5m])", "chart_type": "line" }
|
||||||
"dashboard_id": "<id from create_dashboard>",
|
|
||||||
"title": "Errors over time",
|
|
||||||
"promql": "rate(errors_total[5m])",
|
|
||||||
"chart_type": "line"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
- **`delete_panel`** — remove a panel from a dashboard by `panel_id`.
|
||||||
### `get_dashboard`
|
- **`render_panel`** — resolve one panel's promql, evaluate it, and return `{ panel, series }` as
|
||||||
Read a dashboard's current panels.
|
JSON — this is how to "see" a chart without a browser.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "dashboard_id": "<id>" }
|
{ "dashboard_id": "<id>", "panel_id": "<id>" }
|
||||||
```
|
```
|
||||||
|
- **`render_dashboard`** — same as `render_panel` but for every panel in a dashboard at once,
|
||||||
|
returning `{ dashboard, panels: [{ panel, series }, ...] }`.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
+700
-98
@@ -9,6 +9,8 @@ use rmcp::{ErrorData as McpError, ServerHandler, schemars, tool, tool_handler, t
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
// ---- logs / timeline / stats ----
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
pub struct QueryLogsParams {
|
pub struct QueryLogsParams {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -17,8 +19,30 @@ pub struct QueryLogsParams {
|
|||||||
pub count_only: bool,
|
pub count_only: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub limit: Option<usize>,
|
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)]
|
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||||
pub struct DeclareParamParams {
|
pub struct DeclareParamParams {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -31,6 +55,8 @@ pub struct DeclareParamParams {
|
|||||||
pub elapsed_since_last: bool,
|
pub elapsed_since_last: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- metrics ----
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||||
pub struct DeclareMetricParams {
|
pub struct DeclareMetricParams {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -45,6 +71,46 @@ pub struct DeclareMetricParams {
|
|||||||
pub unmatched_labels: Option<BTreeMap<String, String>>,
|
pub unmatched_labels: Option<BTreeMap<String, 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)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
pub struct CreateDashboardParams {
|
pub struct CreateDashboardParams {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -76,6 +142,124 @@ pub struct GetDashboardParams {
|
|||||||
pub dashboard_id: 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 / css ----
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct UpdateSettingsParams {
|
||||||
|
#[serde(default)]
|
||||||
|
pub color_scheme: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_logs: Option<Option<usize>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_theme_id: Option<Option<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ntfy_topic: Option<Option<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ntfy_server: Option<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<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)]
|
#[derive(Clone)]
|
||||||
pub struct OttylMcp {
|
pub struct OttylMcp {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
@@ -133,6 +317,83 @@ impl OttylMcp {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.")]
|
#[tool(description = "Count or read log lines matching a FilterBar query against the running ottyl instance.")]
|
||||||
async fn query_logs(
|
async fn query_logs(
|
||||||
&self,
|
&self,
|
||||||
@@ -145,20 +406,19 @@ impl OttylMcp {
|
|||||||
if let Some(limit) = params.limit {
|
if let Some(limit) = params.limit {
|
||||||
query.push(("limit", limit.to_string()));
|
query.push(("limit", limit.to_string()));
|
||||||
}
|
}
|
||||||
let response = self
|
if let Some(since_sequence) = params.since_sequence {
|
||||||
.client
|
query.push(("since_sequence", since_sequence.to_string()));
|
||||||
.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()));
|
|
||||||
}
|
}
|
||||||
let logs: Value = response
|
if let Some(start_ms) = params.start_ms {
|
||||||
.json()
|
query.push(("start_ms", start_ms.to_string()));
|
||||||
.await
|
}
|
||||||
.map_err(|err| mcp_error("failed to parse logs response", err))?;
|
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 {
|
if params.count_only {
|
||||||
let count = logs.as_array().map(Vec::len).unwrap_or(0);
|
let count = logs.as_array().map(Vec::len).unwrap_or(0);
|
||||||
json_result(serde_json::json!({ "count": count }))
|
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<GetTimelineParams>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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<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.")]
|
#[tool(description = "Declare a param: a named value extracted from matching log lines.")]
|
||||||
async fn declare_param(
|
async fn declare_param(
|
||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<DeclareParamParams>,
|
Parameters(params): Parameters<DeclareParamParams>,
|
||||||
) -> Result<CallToolResult, McpError> {
|
) -> Result<CallToolResult, McpError> {
|
||||||
let response = self
|
let body = serde_json::to_value(¶ms).map_err(|err| mcp_error("failed to encode params", err))?;
|
||||||
.client
|
json_result(self.send_json(reqwest::Method::POST, "/api/params", Some(&body)).await?)
|
||||||
.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)
|
|
||||||
|
#[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.")]
|
#[tool(description = "Declare a metric: named event counts/values bucketed by label, derived from matching log lines.")]
|
||||||
@@ -195,22 +486,94 @@ impl OttylMcp {
|
|||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<DeclareMetricParams>,
|
Parameters(params): Parameters<DeclareMetricParams>,
|
||||||
) -> Result<CallToolResult, McpError> {
|
) -> Result<CallToolResult, McpError> {
|
||||||
let response = self
|
let body = serde_json::to_value(¶ms).map_err(|err| mcp_error("failed to encode params", err))?;
|
||||||
.client
|
json_result(self.send_json(reqwest::Method::POST, "/api/metrics", Some(&body)).await?)
|
||||||
.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));
|
|
||||||
}
|
}
|
||||||
json_result(body)
|
|
||||||
|
#[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(¶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<DeleteMetricParams>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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<GetMetricBucketValuesParams>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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<RunPromqlQueryParams>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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<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.")]
|
#[tool(description = "Create a new, empty dashboard in a profile.")]
|
||||||
@@ -219,35 +582,43 @@ impl OttylMcp {
|
|||||||
Parameters(params): Parameters<CreateDashboardParams>,
|
Parameters(params): Parameters<CreateDashboardParams>,
|
||||||
) -> Result<CallToolResult, McpError> {
|
) -> Result<CallToolResult, McpError> {
|
||||||
let profile = self.resolve_profile(params.profile).await?;
|
let profile = self.resolve_profile(params.profile).await?;
|
||||||
let response = self
|
json_result(
|
||||||
.client
|
self.send_json(
|
||||||
.post(self.url(&format!(
|
reqwest::Method::POST,
|
||||||
"/api/settings/profiles/{}/dashboards",
|
&format!("/api/settings/profiles/{}/dashboards", urlencoding_path(&profile)),
|
||||||
urlencoding_path(&profile)
|
Some(&serde_json::json!({ "name": params.name })),
|
||||||
)))
|
)
|
||||||
.json(&serde_json::json!({ "name": params.name }))
|
.await?,
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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(
|
async fn get_dashboard(
|
||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<GetDashboardParams>,
|
Parameters(params): Parameters<GetDashboardParams>,
|
||||||
) -> Result<CallToolResult, McpError> {
|
) -> Result<CallToolResult, McpError> {
|
||||||
let profile = self.resolve_profile(params.profile).await?;
|
let profile = self.resolve_profile(params.profile).await?;
|
||||||
let body = self.fetch_dashboard(&profile, ¶ms.dashboard_id).await?;
|
json_result(self.fetch_dashboard(&profile, ¶ms.dashboard_id).await?)
|
||||||
json_result(body)
|
}
|
||||||
|
|
||||||
|
#[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(¶ms.dashboard_id)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "Add a new panel to a dashboard, or update an existing one by panel_id.")]
|
#[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),
|
None => panels.push(new_panel),
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = self
|
json_result(self.save_dashboard(&profile, ¶ms.dashboard_id, dashboard).await?)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_dashboard(&self, profile: &str, dashboard_id: &str) -> Result<Value, McpError> {
|
#[tool(description = "Remove a panel from a dashboard by panel_id.")]
|
||||||
let response = self
|
async fn delete_panel(
|
||||||
.client
|
&self,
|
||||||
.get(self.url(&format!(
|
Parameters(params): Parameters<DeletePanelParams>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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<Value, McpError> {
|
||||||
|
self.send_json(
|
||||||
|
reqwest::Method::PUT,
|
||||||
|
&format!(
|
||||||
"/api/settings/profiles/{}/dashboards/{}",
|
"/api/settings/profiles/{}/dashboards/{}",
|
||||||
urlencoding_path(profile),
|
urlencoding_path(profile),
|
||||||
urlencoding_path(dashboard_id)
|
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, ¶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<RenderDashboardParams>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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<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(¶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<CallToolResult, McpError> {
|
||||||
|
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<ImportCssParams>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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<LinkCssParams>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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<CssIdParams>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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<CssIdParams>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(self.url(&format!("/api/settings/css/{}/content", urlencoding_path(¶ms.id))))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|err| mcp_error("failed to reach ottyl", err))?;
|
.map_err(|err| mcp_error("failed to reach ottyl", err))?;
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body: Value = response
|
let text = response
|
||||||
.json()
|
.text()
|
||||||
.await
|
.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() {
|
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<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(¶ms.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(¶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<TestAlertNotificationParams>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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<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(¶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<ProfileNameParams>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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<ProfileNameParams>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
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_server_info(Implementation::from_build_env())
|
||||||
.with_protocol_version(ProtocolVersion::V_2024_11_05)
|
.with_protocol_version(ProtocolVersion::V_2024_11_05)
|
||||||
.with_instructions(
|
.with_instructions(
|
||||||
"Proxies to a running ottyl instance's HTTP API. Tools: query_logs, declare_param, \
|
"Proxies to a running ottyl instance's HTTP API. Covers everything the web UI can do: \
|
||||||
declare_metric, create_dashboard, add_panel, get_dashboard. See the embedded \
|
logs/timeline/stats, param & metric declarations, PromQL queries, dashboards & panels \
|
||||||
SKILL.md (GET /SKILL.md on the target instance) for examples."
|
(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(),
|
.to_string(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
|
||||||
Reference in New Issue
Block a user