diff --git a/Cargo.lock b/Cargo.lock index 8cccf2d..2469cb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,6 +70,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -200,6 +206,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -265,6 +272,40 @@ dependencies = [ "libc", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.5", +] + [[package]] name = "directories" version = "6.0.0" @@ -297,6 +338,18 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -355,6 +408,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -362,6 +430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -370,6 +439,40 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -382,8 +485,13 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -415,6 +523,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -639,6 +753,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -679,6 +799,18 @@ dependencies = [ "quote", ] +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + [[package]] name = "ipnet" version = "2.12.1" @@ -832,6 +964,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" name = "ottyl" version = "0.1.0" dependencies = [ + "anyhow", "axum", "chrono", "clap", @@ -843,8 +976,10 @@ dependencies = [ "nom", "pest", "pest_derive", + "petname", "regex", "reqwest", + "rmcp", "serde", "serde_json", "tempfile", @@ -875,6 +1010,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -923,6 +1064,27 @@ dependencies = [ "pest", ] +[[package]] +name = "petname" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfb4653a82ec48e6d324c788597aadf46449bfc7b8e76d2a03b513a944325c88" +dependencies = [ + "petname-macros", + "rand", +] + +[[package]] +name = "petname-macros" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c406fb544296f598ccf69b82bcf1b12440c8f23ebf75946105f86304f16eb2" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "phf" version = "0.11.3" @@ -1082,6 +1244,26 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "regex" version = "1.13.1" @@ -1163,6 +1345,41 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b6914fac0be956fe704a38239c3f44a9f841d1b06a5713d2f638065593f5b5" +dependencies = [ + "chrono", + "futures", + "indexmap", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.5", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1238,6 +1455,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1274,6 +1517,17 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -1510,6 +1764,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -1564,9 +1832,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1624,6 +1904,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index d77a9b3..8e8a5d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,10 @@ humantime = "2" directories = "6.0.0" fs-embed = "0.2.4" include_dir = "0.7.4" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +petname = { version = "3.2.0", default-features = false, features = ["default-rng", "default-words"] } +rmcp = { version = "3.2.0", default-features = false, features = ["server", "macros", "transport-io"] } +anyhow = "1" [dev-dependencies] tempfile = "3.27.0" diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..b9a4090 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,91 @@ +--- +name: ottyl +description: Query and configure a running ottyl observability instance (logs, params, metrics, dashboards) via its MCP server. +--- + +# ottyl + +ottyl turns a piped command's stdout into a searchable log timeline plus ad-hoc metrics and +dashboards, served from a single process. This doc covers how an agent connects to and drives a +*running* instance. + +## Find a session + +Each `ottyl` process registers itself under a name (random, e.g. `comic-gazelle`, unless started +with `--name `). List known sessions: + +``` +ottyl sessions +# comic-gazelle port=51423 pid=8421 +``` + +## Connect an MCP client + +Start the MCP server (a separate short-lived process) pointed at that session, and register it +with your MCP client (e.g. Claude Code's `.mcp.json`): + +``` +ottyl mcp --session comic-gazelle +# or, if you already know the port: +ottyl mcp --port 51423 +``` + +This exposes six tools, each a thin proxy onto the target instance's HTTP API. + +## Tools + +### `query_logs` +Count or read log lines matching a FilterBar query. + +```json +{ "filter": "regex(\"error\")", "count_only": true } +``` + +### `declare_param` +Extract a named value from matching log lines. + +```json +{ + "name": "status_code", + "message_match_regex": "status=(\\d+)", + "capture_group": "1", + "kind": "number" +} +``` + +### `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"] +} +``` + +### `create_dashboard` +Create an empty dashboard in a profile (defaults to the active profile). + +```json +{ "name": "Errors" } +``` + +### `add_panel` +Add a panel to a dashboard, or update one in place by `panel_id`. + +```json +{ + "dashboard_id": "", + "title": "Errors over time", + "promql": "rate(errors_total[5m])", + "chart_type": "line" +} +``` + +### `get_dashboard` +Read a dashboard's current panels. + +```json +{ "dashboard_id": "" } +``` diff --git a/WISHLIST.txt b/WISHLIST.txt index e69de29..7880e8c 100644 --- a/WISHLIST.txt +++ b/WISHLIST.txt @@ -0,0 +1,6 @@ +Ottyl should have an CLI json-rpc mcp functionality. It should allow agents to read or count logs matching queries, create parameters, create metrics, and create dashboards and panels in the dashboards. They should also be able to read dashboards as response data. + +Ottyl CLI is aware of sessions, because each session will be stored in the settings location. Since the ottyl service knows it's binding, it can write there `{port}.json` files in say `sessions/` where the settings are. Then ottyl CLI can find sessions there by name and connect to them. Each session of ottyl is named, either via cli clap params, or randomly using the petnames crate. + +There should be a SKILL.md file, accessible from ottyl (so embedded like the frontend is) which explains how to interact with ottyl instances for agents. It should be clear and direct without using too many words. + diff --git a/src/api.rs b/src/api.rs index b48fe49..0f9250d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -26,6 +26,10 @@ use tokio::sync::oneshot; static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist"); +/// Embedded so an agent that has only discovered a port (no filesystem access into the ottyl +/// repo/binary) can still fetch it — see `SKILL.md` at the repo root and ticket 069. +const SKILL_MD: &str = include_str!("../SKILL.md"); + #[derive(Clone)] struct ApiState { engine: EngineHandle, @@ -158,6 +162,7 @@ pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: Pat }; Router::new() + .route("/SKILL.md", get(get_skill_md)) .route("/api/logs", get(get_logs)) .route("/api/timeline", get(get_timeline)) .route("/api/stats", get(get_stats)) @@ -194,6 +199,10 @@ pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: Pat .fallback(serve_webapp) } +async fn get_skill_md() -> impl IntoResponse { + ([(header::CONTENT_TYPE, "text/markdown; charset=utf-8")], SKILL_MD) +} + /// Falls back to `index.html` for unmatched paths so client-side routing works. async fn serve_webapp(uri: axum::http::Uri) -> Response { let path = uri.path().trim_start_matches('/'); diff --git a/src/main.rs b/src/main.rs index aa762ce..10f575f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,17 +8,19 @@ mod filter_parser; mod index_key; mod ingest; mod logfmt; +mod mcp_server; mod metric_primitive; mod profile; mod promql; mod rate; +mod sessions; mod settings; mod timestamp; use std::path::PathBuf; use std::sync::{Arc, LazyLock, Mutex}; -use clap::Parser; +use clap::{Parser, Subcommand}; use tokio::net::TcpListener; use tokio::sync::mpsc; @@ -38,6 +40,9 @@ static SETTINGS_PATH_HELP: LazyLock = LazyLock::new(|| { /// Observability dashboard from terminal output. #[derive(Parser, Debug)] struct Args { + #[command(subcommand)] + command: Option, + /// Port to serve the API and webapp on. Defaults to 0, letting the OS assign /// an ephemeral free port. #[arg(long, default_value_t = 0)] @@ -53,6 +58,30 @@ struct Args { /// URL of a Prometheus/OpenMetrics-compatible endpoint to poll for metrics, in addition to log ingestion. #[arg(long)] metrics_url: Option, + + /// Name to register this session under, so `ottyl mcp --session ` or `ottyl sessions` + /// can find it later. Defaults to a random human-readable name (e.g. "comic-gazelle"). + /// Starting a second instance under the same name as one still running overwrites its + /// registration (the older process keeps running, just without a discoverable session entry). + #[arg(long)] + name: Option, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Start an MCP (Model Context Protocol) server over stdio, proxying tool calls to a running + /// ottyl instance's HTTP API. Register the printed command with an MCP client (e.g. Claude + /// Code's `.mcp.json`). + Mcp { + /// Name of a running ottyl session to connect to (see `ottyl sessions`). + #[arg(long)] + session: Option, + /// Port of a running ottyl instance to connect to, instead of resolving `--session` by name. + #[arg(long)] + port: Option, + }, + /// List known ottyl sessions and the ports they're serving on. + Sessions, } #[tokio::main] @@ -63,6 +92,19 @@ async fn main() { settings::default_settings_path() .expect("could not determine a default settings path; pass --settings-path") }); + + match args.command { + Some(Command::Mcp { session, port }) => { + run_mcp(&settings_path, session, port).await; + return; + } + Some(Command::Sessions) => { + print_sessions(&settings_path); + return; + } + None => {} + } + let settings = Arc::new(Mutex::new(Settings::load(&settings_path))); let (engine_tx, engine_rx) = mpsc::channel(1024); @@ -113,7 +155,7 @@ async fn main() { tokio::spawn(ingest::run_metrics_poll(engine_tx.clone(), metrics_url)); } - let app = api::router(engine_tx, settings, settings_path); + let app = api::router(engine_tx, settings, settings_path.clone()); let listener = TcpListener::bind(("0.0.0.0", args.port)) .await .expect("failed to bind listener"); @@ -126,16 +168,63 @@ async fn main() { Some(path) => format!("tailing {}", path.display()), None => "reading logs from stdin".to_string(), }; - eprintln!( - "ottyl serving on http://localhost:{} ({})", - bound_port, source - ); + let session_name = args.name.clone().unwrap_or_else(sessions::generate_name); + match sessions::register_session(&settings_path, &session_name, bound_port) { + Ok(_) => eprintln!( + "ottyl serving on http://localhost:{} ({}), session name: {}", + bound_port, source, session_name + ), + Err(err) => eprintln!( + "ottyl serving on http://localhost:{} ({}); warning: failed to register session '{}': {}", + bound_port, source, session_name, err + ), + } axum::serve(listener, app) .await .expect("server error"); } +/// Resolves `--session`/`--port` into a target base URL and runs the MCP server over stdio until +/// the client disconnects. +async fn run_mcp(settings_path: &std::path::Path, session: Option, port: Option) { + use rmcp::ServiceExt; + + let port = match (port, session) { + (Some(port), _) => port, + (None, Some(name)) => match sessions::resolve_session(settings_path, &name) { + Some(info) => info.port, + None => { + eprintln!("error: no running ottyl session named '{name}'"); + std::process::exit(1); + } + }, + (None, None) => { + eprintln!("error: `ottyl mcp` requires --session or --port "); + std::process::exit(1); + } + }; + + let base_url = format!("http://localhost:{port}"); + let service = mcp_server::OttylMcp::new(base_url) + .serve(rmcp::transport::stdio()) + .await + .expect("failed to start MCP server"); + service.waiting().await.expect("MCP server error"); +} + +fn print_sessions(settings_path: &std::path::Path) { + let dir = sessions::sessions_dir(settings_path); + let sessions = sessions::list_sessions(&dir); + if sessions.is_empty() { + println!("no known ottyl sessions"); + return; + } + for session in sessions { + println!("{}\tport={}\tpid={}", session.name, session.port, session.pid); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/mcp_server.rs b/src/mcp_server.rs new file mode 100644 index 0000000..065921c --- /dev/null +++ b/src/mcp_server.rs @@ -0,0 +1,409 @@ +//! MCP (Model Context Protocol) server for agents (ticket 067): a short-lived process, started +//! via `ottyl mcp --session ` (see `crate::sessions`), that speaks MCP over stdio and +//! proxies each tool call to a *running* ottyl instance's existing HTTP API. Every tool here is a +//! thin translation onto `api.rs` routes/DTOs — no query logic is duplicated outside `engine.rs`. +//! +//! Uses the official `rmcp` SDK (https://github.com/modelcontextprotocol/rust-sdk) over the +//! `transport-io` (stdio) transport rather than hand-rolling the JSON-RPC/MCP handshake, since a +//! maintained implementation of the protocol's framing and lifecycle is worth the extra +//! dependency. + +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; + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct QueryLogsParams { + /// FilterBar query string, e.g. `regex("error") and ottyl_seq<5`. Omitted matches every log. + #[serde(default)] + pub filter: Option, + /// If true, return only how many logs matched instead of the log lines themselves. + #[serde(default)] + pub count_only: bool, + /// Maximum number of log lines to return. Ignored when `count_only` is set. + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct DeclareParamParams { + pub name: String, + /// Regex matched against each log line; `capture_group` extracts this param's value from it. + pub message_match_regex: String, + pub capture_group: String, + /// Dotted path into a JSON-structured log line to extract from instead of `capture_group`. + #[serde(default)] + pub json_field_path: Option, + /// One of: "string", "number", "timestamp", "duration", "boolean". + pub kind: String, + /// Extract the elapsed time since this param was last seen, instead of parsing captured text. + #[serde(default)] + pub elapsed_since_last: bool, +} + +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct DeclareMetricParams { + pub name: String, + pub message_match_regex: String, + /// Names of already-declared params whose values become this metric's bucket labels. + #[serde(default)] + pub label_params: Vec, + /// Name of an already-declared param whose value becomes this metric's per-event bucket value. + #[serde(default)] + pub value_param: Option, + /// Fixed labels layered on top of `label_params` for matching lines. + #[serde(default)] + pub matched_labels: Option>, + /// Fixed labels recorded once for a non-matching line, instead of it being invisible to this metric. + #[serde(default)] + pub unmatched_labels: Option>, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct CreateDashboardParams { + /// Profile to create the dashboard in. Defaults to the currently active profile. + #[serde(default)] + pub profile: Option, + pub name: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct AddPanelParams { + /// Profile the dashboard lives in. Defaults to the currently active profile. + #[serde(default)] + pub profile: Option, + pub dashboard_id: String, + /// Id of an existing panel to update in place; omit to add a new panel. + #[serde(default)] + pub panel_id: Option, + pub title: String, + /// PromQL-style query backing this panel (see `crate::promql`). + pub promql: String, + /// One of: "bar", "line", "pie". Defaults to "line". + #[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 { + /// Profile the dashboard lives in. Defaults to the currently active profile. + #[serde(default)] + pub profile: Option, + pub dashboard_id: String, +} + +#[derive(Clone)] +pub struct OttylMcp { + client: reqwest::Client, + base_url: String, + // Constructed fresh per call by the `#[tool_handler]`-generated `ServerHandler` methods + // (`Self::tool_router()`), not read through this field — kept only because `#[tool_router]` + // requires a field of this name/type to exist on the struct. + #[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) + } + + /// Resolves the profile a dashboard tool call should operate on: the explicit `profile` + /// argument if given, otherwise the target instance's currently active profile. + 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, + ) + }) + } + + #[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())); + } + let response = self + .client + .get(self.url("/api/logs")) + .query(&query) + .send() + .await + .map_err(|err| mcp_error("failed to reach ottyl", err))?; + if !response.status().is_success() { + return Err(mcp_error("ottyl rejected the query", response.status())); + } + let logs: Value = response + .json() + .await + .map_err(|err| mcp_error("failed to parse logs response", err))?; + 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 = "Declare a param: a named value extracted from matching log lines.")] + async fn declare_param( + &self, + Parameters(params): Parameters, + ) -> Result { + let response = self + .client + .post(self.url("/api/params")) + .json(¶ms) + .send() + .await + .map_err(|err| mcp_error("failed to reach ottyl", err))?; + let status = response.status(); + let body: Value = response + .json() + .await + .map_err(|err| mcp_error("failed to parse declare_param response", err))?; + if !status.is_success() { + return Err(mcp_error("ottyl rejected the param declaration", body)); + } + json_result(body) + } + + #[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 response = self + .client + .post(self.url("/api/metrics")) + .json(¶ms) + .send() + .await + .map_err(|err| mcp_error("failed to reach ottyl", err))?; + let status = response.status(); + let body: Value = response + .json() + .await + .map_err(|err| mcp_error("failed to parse declare_metric response", err))?; + if !status.is_success() { + return Err(mcp_error("ottyl rejected the metric declaration", body)); + } + json_result(body) + } + + #[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?; + let response = self + .client + .post(self.url(&format!( + "/api/settings/profiles/{}/dashboards", + urlencoding_path(&profile) + ))) + .json(&serde_json::json!({ "name": params.name })) + .send() + .await + .map_err(|err| mcp_error("failed to reach ottyl", err))?; + let status = response.status(); + let body: Value = response + .json() + .await + .map_err(|err| mcp_error("failed to parse create_dashboard response", err))?; + if !status.is_success() { + return Err(mcp_error("ottyl rejected the dashboard creation", body)); + } + json_result(body) + } + + #[tool(description = "Read a dashboard's current panels.")] + async fn get_dashboard( + &self, + Parameters(params): Parameters, + ) -> Result { + let profile = self.resolve_profile(params.profile).await?; + let body = self.fetch_dashboard(&profile, ¶ms.dashboard_id).await?; + json_result(body) + } + + // Read-modify-write over HTTP (no dashboard-level locking): two concurrent `add_panel` calls + // against the same dashboard can race and one's addition can be lost, same as two browser tabs + // saving the same dashboard through the webapp today. Not addressed here — out of scope for a + // thin proxy over the existing `save_dashboard` endpoint. + #[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), + } + + let response = self + .client + .put(self.url(&format!( + "/api/settings/profiles/{}/dashboards/{}", + urlencoding_path(&profile), + urlencoding_path(¶ms.dashboard_id) + ))) + .json(&serde_json::json!({ "name": dashboard.get("name"), "panels": dashboard.get("panels") })) + .send() + .await + .map_err(|err| mcp_error("failed to reach ottyl", err))?; + let status = response.status(); + let body: Value = response + .json() + .await + .map_err(|err| mcp_error("failed to parse add_panel response", err))?; + if !status.is_success() { + return Err(mcp_error("ottyl rejected the panel update", body)); + } + json_result(body) + } + + async fn fetch_dashboard(&self, profile: &str, dashboard_id: &str) -> Result { + let response = self + .client + .get(self.url(&format!( + "/api/settings/profiles/{}/dashboards/{}", + urlencoding_path(profile), + urlencoding_path(dashboard_id) + ))) + .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 dashboard response", err))?; + if !status.is_success() { + return Err(mcp_error("ottyl rejected the dashboard lookup", body)); + } + Ok(body) + } +} + +/// Percent-encodes a single path segment (profile/dashboard names/ids can contain characters that +/// aren't valid unescaped in a URL path). +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() +} + +/// Generates a new panel id by hashing title/promql/time, avoiding a `uuid` dependency (matches +/// `settings::generate_imported_css_id`'s approach for the same reason). +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. Tools: query_logs, declare_param, \ + declare_metric, create_dashboard, add_panel, get_dashboard. See the embedded \ + SKILL.md (GET /SKILL.md on the target instance) for examples." + .to_string(), + ) + } +} diff --git a/src/sessions.rs b/src/sessions.rs new file mode 100644 index 0000000..335bfcf --- /dev/null +++ b/src/sessions.rs @@ -0,0 +1,188 @@ +//! Named session registry (ticket 068): each running `ottyl` process registers itself under a +//! name by writing a small file to `/sessions/.json`, so a second CLI +//! invocation (e.g. `ottyl mcp --session `, ticket 067) can resolve a name to a port without +//! the user copying one around by hand. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process; + +use serde::{Deserialize, Serialize}; + +/// On-disk shape of a `sessions/.json` file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionInfo { + pub name: String, + pub port: u16, + pub pid: u32, +} + +/// The `sessions/` directory for a given settings file, following the same +/// sibling-directory convention as `profiles/` (see `settings::migrate_legacy_profiles_file`). +pub fn sessions_dir(settings_path: &Path) -> PathBuf { + settings_path + .parent() + .map(|parent| parent.join("sessions")) + .unwrap_or_else(|| PathBuf::from("sessions")) +} + +fn session_file_path(dir: &Path, name: &str) -> PathBuf { + dir.join(format!("{name}.json")) +} + +/// Best-effort liveness check for a PID. Shells out to `kill -0` on unix (no signal is delivered, +/// only permission/existence is checked); assumed alive on other platforms, which just means a +/// stale session file there waits for an explicit name collision to be overwritten instead of +/// being proactively cleaned up. +#[cfg(unix)] +fn is_pid_alive(pid: u32) -> bool { + process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(process::Stdio::null()) + .stderr(process::Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(true) +} + +#[cfg(not(unix))] +fn is_pid_alive(_pid: u32) -> bool { + true +} + +/// Removes session files whose owning process is no longer running. Best-effort: there's no +/// shutdown hook to unregister a session on graceful exit, so this runs on every startup instead. +/// Any I/O error reading a single entry is ignored — a future call gets another chance. +pub fn cleanup_stale(dir: &Path) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let Ok(contents) = fs::read_to_string(&path) else { + continue; + }; + let Ok(info) = serde_json::from_str::(&contents) else { + continue; + }; + if !is_pid_alive(info.pid) { + let _ = fs::remove_file(&path); + } + } +} + +/// Generates a random human-readable session name (e.g. `comic-gazelle`) for when `--name` isn't +/// given explicitly. +pub fn generate_name() -> String { + petname::petname(2, "-").unwrap_or_else(|| format!("session-{}", process::id())) +} + +/// Registers this process as session `name` on `port`: cleans up stale entries, then writes +/// `sessions/.json`, overwriting any existing file for that name (documented behavior — a +/// second instance started under the same explicit `--name` as a still-running one simply +/// replaces its registration; the older process keeps running, just without a discoverable entry). +pub fn register_session(settings_path: &Path, name: &str, port: u16) -> io::Result { + let dir = sessions_dir(settings_path); + fs::create_dir_all(&dir)?; + cleanup_stale(&dir); + let info = SessionInfo { + name: name.to_string(), + port, + pid: process::id(), + }; + let path = session_file_path(&dir, name); + fs::write(&path, serde_json::to_string_pretty(&info)?)?; + Ok(path) +} + +/// Looks up a session by name. Returns `None` if no such file exists, it fails to parse, or its +/// process is no longer alive (a stale entry left by a process that didn't exit through the normal +/// next-`register_session` cleanup path). +pub fn resolve_session(settings_path: &Path, name: &str) -> Option { + let dir = sessions_dir(settings_path); + let contents = fs::read_to_string(session_file_path(&dir, name)).ok()?; + let info: SessionInfo = serde_json::from_str(&contents).ok()?; + is_pid_alive(info.pid).then_some(info) +} + +/// Lists every session file under `dir`, live or stale (callers that only want live sessions +/// should filter on `is_pid_alive`-backed `resolve_session` instead). Used by `ottyl sessions`. +pub fn list_sessions(dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + let mut sessions: Vec = entries + .flatten() + .filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("json")) + .filter_map(|entry| fs::read_to_string(entry.path()).ok()) + .filter_map(|contents| serde_json::from_str(&contents).ok()) + .collect(); + sessions.sort_by(|a, b| a.name.cmp(&b.name)); + sessions +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn register_session_writes_correct_port() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + + let path = register_session(&settings_path, "foo", 4242).unwrap(); + assert!(path.exists()); + + let info = resolve_session(&settings_path, "foo").unwrap(); + assert_eq!(info.port, 4242); + assert_eq!(info.name, "foo"); + assert_eq!(info.pid, process::id()); + } + + #[test] + fn resolve_session_returns_none_for_unknown_name() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + assert!(resolve_session(&settings_path, "does-not-exist").is_none()); + } + + #[test] + fn stale_session_does_not_block_reregistration_under_same_name() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + let sessions_dir_path = sessions_dir(&settings_path); + fs::create_dir_all(&sessions_dir_path).unwrap(); + + // A PID essentially guaranteed not to be alive (PID 1 exists but a huge, unlikely-assigned + // PID stands in as "not alive" without depending on system PID-reuse specifics). + let stale = SessionInfo { name: "foo".to_string(), port: 1111, pid: 999_999 }; + fs::write( + session_file_path(&sessions_dir_path, "foo"), + serde_json::to_string(&stale).unwrap(), + ) + .unwrap(); + + // Registering a live session under the same name succeeds and the stale cleanup pass (run + // as part of registration) doesn't error out on the bogus pre-existing entry. + register_session(&settings_path, "foo", 5555).unwrap(); + let info = resolve_session(&settings_path, "foo").unwrap(); + assert_eq!(info.port, 5555); + assert_eq!(info.pid, process::id()); + } + + #[test] + fn list_sessions_returns_all_entries_sorted_by_name() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + register_session(&settings_path, "zeta", 1).unwrap(); + register_session(&settings_path, "alpha", 2).unwrap(); + + let sessions = list_sessions(&sessions_dir(&settings_path)); + let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "zeta"]); + } +}