diff --git a/docs/adr/0018-read-only-mcp-server-in-process.md b/docs/adr/0018-read-only-mcp-server-in-process.md new file mode 100644 index 0000000..9dd473a --- /dev/null +++ b/docs/adr/0018-read-only-mcp-server-in-process.md @@ -0,0 +1,71 @@ +# 0018. Expose the read API as a read-only MCP server, in-process on /mcp + +- Status: Accepted +- Date: 2026-07-21 +- Related: [0013](0013-auth-at-the-edge.md), [0010](0010-ui-embedded-in-server-binary.md), [0002](0002-rust-axum-sqlx-runtime-queries.md) + +## Context + +watcher already exposes its telemetry through a same-origin query API (`/api/...`) +consumed by the embedded UI. LLM agents (Claude Code, MCP Inspector, …) increasingly +speak the **Model Context Protocol** (MCP): given an MCP endpoint they can search +traces, read logs/metrics, and inspect services/alerts as tools. We want watcher to +be that endpoint without standing up a second process or duplicating query logic +(JEF-471). + +Two shapes were possible: + +1. **Official Rust MCP SDK (`rmcp`) mounted in-process** over its streamable-HTTP + transport, nested on the existing axum app, or +2. a **hand-rolled JSON-RPC-2.0-over-HTTP** axum route implementing `initialize` / + `tools/list` / `tools/call` ourselves. + +The ticket allowed the hand-rolled path only if `rmcp`'s axum integration was not +mature. + +## Decision + +- **Use `rmcp` (option 1).** Its `transport-streamable-http-server` ships a + `StreamableHttpService` that implements `tower::Service`, so it nests straight into + the axum router with `Router::nest_service("/mcp", …)` — no second listener, no + bespoke protocol code. It is the official SDK (`modelcontextprotocol/rust-sdk`), + tracks the spec (session handling, SSE framing, DNS-rebinding guards), and its axum + story is mature. The client half is a **dev-dependency only** (used by the smoke + test), so the shipped binary carries only the server transport. + +- **Tools are thin wrappers over the existing read queries.** The nine read handlers' + query bodies were refactored in `api.rs` into shared `pub async fn query_*(pool, …)` + functions; both the HTTP handler and the MCP tool call the *same* function, so every + limit clamp and default time window (e.g. traces' 24h window, logs' 2000-row cap) is + shared by construction — the MCP surface cannot introduce an unbounded scan the HTTP + surface doesn't already permit. Tools return the exact `api` response structs as JSON. + Exposed: `search_traces`, `get_trace`, `query_logs`, `list_services`, `service_map`, + `query_metrics`, `metric_series`, `list_alerts`, `alert_events`. + +- **Read-only, by construction.** There are no write/mutate tools; `/mcp` touches no + ingest (`/v1`) or alert-reconcile path. (Alert rules are already declarative and the + HTTP `/api/alerts` surface is read-only — ADR 0012.) + +- **Opt-in, default OFF, unauthenticated for now.** `/mcp` mounts only when + `WATCHER_MCP_ENABLED` is truthy. It is deliberately mounted *outside* the edge + auth that fronts the UI/`/api` (Cloudflare Access, ADR 0013): an MCP client is not a + browser and carries no Access cookie. Its own auth is a **separate** ticket + (JEF-472); until that lands the endpoint must not be exposed, so it defaults off and + the flag's doc comment says so. The transport's default loopback-only Host allow-list + (a DNS-rebinding guard aimed at locally-run servers reached by a browser) is disabled + here, since watcher's MCP is a server-to-server endpoint reached through a public + tunnel host where that list would reject every legitimate client. + +## Consequences + +- watcher is usable directly from an MCP client with zero extra deployment — one + binary, same origin — consistent with the embedded-UI posture (ADR 0010) and + Postgres-only runtime queries (ADR 0002). +- Query behavior stays identical across the HTTP and MCP surfaces because they share + the `query_*` functions; a future change to a clamp or window applies to both. +- The endpoint is inert until an operator sets `WATCHER_MCP_ENABLED` **and** (once + JEF-472 lands) configures its auth. Enabling it before then exposes read access to + anyone who can reach the host — the flag default and the ADR make that ordering + explicit, mirroring the "create the Access app first" runbook rule of ADR 0013. +- `rmcp` (and, for tests, its client + `reqwest` 0.13) enters the dependency tree; the + server transport is small and the client stays out of the release binary. diff --git a/docs/adr/README.md b/docs/adr/README.md index 11118e6..8a12e92 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -26,3 +26,4 @@ Copy [`0000-template.md`](0000-template.md) to start one. | [0015](0015-sustained-condition-alerts.md) | Sustained-condition alerts (`for: 5m`) | Accepted | | [0016](0016-self-log-instrumentation.md) | Self-instrument watcher's own logs in-process | Accepted | | [0017](0017-self-trace-instrumentation-in-process.md) | Self-instrument watcher's own traces in-process | Accepted | +| [0018](0018-read-only-mcp-server-in-process.md) | Expose the read API as a read-only MCP server, in-process on `/mcp` | Accepted | diff --git a/server/Cargo.lock b/server/Cargo.lock index 269e5c4..9a0e987 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -40,13 +40,13 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -130,9 +130,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -169,15 +169,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.65" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "shlex", @@ -191,9 +191,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -291,24 +291,24 @@ dependencies = [ [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", @@ -332,6 +332,40 @@ dependencies = [ "cmov", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.8" @@ -345,7 +379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "crypto-common 0.1.7", + "crypto-common 0.1.6", ] [[package]] @@ -368,7 +402,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -377,6 +411,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.16.0" @@ -441,9 +481,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" @@ -493,11 +533,26 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -505,15 +560,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -533,39 +588,40 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -578,9 +634,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -723,9 +779,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -767,9 +823,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -945,6 +1001,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" @@ -1059,15 +1121,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "pkg-config", "vcpkg", @@ -1127,9 +1189,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -1159,9 +1221,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -1265,7 +1327,7 @@ dependencies = [ "opentelemetry", "percent-encoding", "portable-atomic", - "rand 0.9.4", + "rand 0.9.5", "thiserror", "tokio", "tokio-stream", @@ -1300,6 +1362,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 = "pem" version = "3.0.6" @@ -1333,7 +1401,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1350,9 +1418,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "potential_utf" @@ -1380,9 +1448,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -1407,7 +1475,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1468,9 +1536,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1495,9 +1563,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core 0.9.5", @@ -1557,11 +1625,31 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -1612,6 +1700,40 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -1626,6 +1748,51 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.2", + "reqwest 0.13.4", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.119", +] + [[package]] name = "rust-embed" version = "8.12.0" @@ -1647,7 +1814,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.118", + "syn 2.0.119", "walkdir", ] @@ -1670,9 +1837,9 @@ checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "log", "once_cell", @@ -1706,9 +1873,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -1725,6 +1892,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1758,14 +1951,25 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.0", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1819,7 +2023,7 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1882,9 +2086,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simple_asn1" @@ -1915,9 +2119,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1925,9 +2129,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -1992,7 +2196,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2015,7 +2219,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.118", + "syn 2.0.119", "thiserror", "tokio", "url", @@ -2109,6 +2313,19 @@ dependencies = [ "url", ] +[[package]] +name = "sse-stream" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39f24a9b78c40b90817bbcd1821c74ddfd74916aadd29403d001532a9195532d" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2126,6 +2343,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2134,9 +2357,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -2145,9 +2368,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.0" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -2171,7 +2394,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2191,14 +2414,14 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.0", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -2245,9 +2468,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -2260,9 +2483,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -2277,13 +2500,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2309,13 +2532,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -2446,7 +2670,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2573,6 +2797,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2667,7 +2902,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -2680,6 +2915,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "watcher-server" version = "0.1.0" @@ -2697,8 +2945,10 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "reqwest", + "reqwest 0.12.28", + "rmcp", "rust-embed", + "schemars", "serde", "serde_json", "serial_test", @@ -2735,9 +2985,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -2778,7 +3028,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2789,7 +3039,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2929,28 +3179,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2970,7 +3220,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -3010,11 +3260,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/server/Cargo.toml b/server/Cargo.toml index 8b6f3a1..21cea4c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -45,8 +45,11 @@ opentelemetry_sdk = { version = "0.32", features = ["rt-tokio"] } tracing-opentelemetry = "0.33" lettre = { version = "0.11.22", default-features = false, features = ["builder", "smtp-transport", "hostname", "tokio1-rustls-tls"] } jsonwebtoken = "9" +rmcp = { version = "2.2.0", features = ["transport-streamable-http-server"] } +schemars = "1" [dev-dependencies] tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" serial_test = "3.5.0" +rmcp = { version = "2.2.0", features = ["client", "transport-streamable-http-client-reqwest"] } diff --git a/server/src/api.rs b/server/src/api.rs index 058b458..e91873e 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -37,22 +37,25 @@ pub async fn healthz(State(pool): State) -> impl IntoResponse { (status, body) } +// Query param structs carry `pub` fields so the MCP tool layer (src/mcp.rs) can +// build them and call the shared `query_*` functions below — the same code the +// HTTP handlers run, so both surfaces share every clamp and default window. #[derive(Deserialize)] pub struct TraceQuery { - limit: Option, - service: Option, + pub limit: Option, + pub service: Option, /// Substring match on the trace's root span name (operation). - name: Option, + pub name: Option, /// Attribute equality filter, `key=value`, matched against any span in the trace. - attr: Option, + pub attr: Option, /// Only traces that contain at least one error span. #[serde(default)] - errors_only: bool, + pub errors_only: bool, /// Only traces at least this long (ms) — for finding slow traces. - min_duration_ms: Option, + pub min_duration_ms: Option, /// Time window (RFC3339); both optional. Absent ends are unbounded. - from: Option>, - to: Option>, + pub from: Option>, + pub to: Option>, } #[derive(Serialize, sqlx::FromRow)] @@ -73,6 +76,12 @@ pub async fn list_traces( State(pool): State, Query(q): Query, ) -> Result>, ApiError> { + Ok(Json(query_traces(&pool, q).await.map_err(internal)?)) +} + +/// Recent-traces query shared by the HTTP handler and the MCP `search_traces` +/// tool. Applies the same limit clamp + default 24h window. +pub async fn query_traces(pool: &PgPool, q: TraceQuery) -> Result, sqlx::Error> { let limit = q.limit.unwrap_or(100).clamp(1, 1000); // `key=value` → JSONB containment, matched against any span in the trace. let attr_json = q @@ -86,7 +95,7 @@ pub async fn list_traces( // spans_attrs_gin index can serve (a HAVING bool_or couldn't use the index). // The remaining trace-level filters (name / errors / duration) are HAVING, so // the per-trace aggregates stay computed over the whole trace. - let rows = sqlx::query_as::<_, TraceSummary>( + sqlx::query_as::<_, TraceSummary>( "SELECT trace_id, max(service) AS service, (array_agg(name ORDER BY start_time))[1] AS root_name, @@ -121,11 +130,9 @@ pub async fn list_traces( .bind(attr_json) .bind(q.errors_only) .bind(q.min_duration_ms) - .fetch_all(&pool) + .fetch_all(pool) .instrument(tracing::info_span!("db.query")) .await - .map_err(internal)?; - Ok(Json(rows)) } #[derive(Serialize, sqlx::FromRow)] @@ -149,7 +156,17 @@ pub async fn get_trace( State(pool): State, Path(trace_id): Path, ) -> Result>, ApiError> { - let rows = sqlx::query_as::<_, SpanRow>( + Ok(Json( + query_trace_spans(&pool, trace_id).await.map_err(internal)?, + )) +} + +/// All spans of one trace, shared by the HTTP handler and the MCP `get_trace` tool. +pub async fn query_trace_spans( + pool: &PgPool, + trace_id: String, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, SpanRow>( "SELECT trace_id, span_id, parent_span_id, service, name, kind, start_time, end_time, duration_ms, status_code, status_message, attributes FROM spans @@ -157,25 +174,23 @@ pub async fn get_trace( ORDER BY start_time ASC", ) .bind(trace_id) - .fetch_all(&pool) + .fetch_all(pool) .instrument(tracing::info_span!("db.query")) .await - .map_err(internal)?; - Ok(Json(rows)) } #[derive(Deserialize)] pub struct LogQuery { - limit: Option, - service: Option, - trace_id: Option, + pub limit: Option, + pub service: Option, + pub trace_id: Option, /// Narrow to a single span's logs (used by the trace waterfall drill-down). - span_id: Option, - q: Option, - from: Option>, - to: Option>, + pub span_id: Option, + pub q: Option, + pub from: Option>, + pub to: Option>, /// Attribute equality filter, `key=value` (e.g. `k8s.pod.name=api-7f`). - attr: Option, + pub attr: Option, } #[derive(Serialize, sqlx::FromRow)] @@ -197,6 +212,11 @@ pub async fn list_logs( State(pool): State, Query(q): Query, ) -> Result>, ApiError> { + Ok(Json(query_logs(&pool, q).await.map_err(internal)?)) +} + +/// Recent-logs query shared by the HTTP handler and the MCP `query_logs` tool. +pub async fn query_logs(pool: &PgPool, q: LogQuery) -> Result, sqlx::Error> { let limit = q.limit.unwrap_or(200).clamp(1, 2000); // `key=value` → JSONB containment `attributes @> {"key":"value"}`, which the // logs_attrs_gin index serves. @@ -206,7 +226,7 @@ pub async fn list_logs( .and_then(|s| s.split_once('=')) .filter(|(k, _)| !k.is_empty()) .map(|(k, v)| serde_json::json!({ k: v })); - let rows = sqlx::query_as::<_, LogRow>( + sqlx::query_as::<_, LogRow>( "SELECT id, time, trace_id, span_id, service, severity_number, severity_text, body, attributes FROM logs WHERE ($1::text IS NULL OR service = $1) @@ -227,19 +247,17 @@ pub async fn list_logs( .bind(q.to) .bind(attr_json) .bind(limit) - .fetch_all(&pool) + .fetch_all(pool) .instrument(tracing::info_span!("db.query")) .await - .map_err(internal)?; - Ok(Json(rows)) } #[derive(Deserialize)] pub struct MetricQuery { - limit: Option, - service: Option, - from: Option>, - to: Option>, + pub limit: Option, + pub service: Option, + pub from: Option>, + pub to: Option>, } #[derive(Serialize, sqlx::FromRow)] @@ -270,12 +288,21 @@ pub async fn list_metrics( State(pool): State, Query(q): Query, ) -> Result>, ApiError> { + Ok(Json(query_metrics(&pool, q).await.map_err(internal)?)) +} + +/// Per-metric latest-value summary shared by the HTTP handler and the MCP +/// `query_metrics` tool. +pub async fn query_metrics( + pool: &PgPool, + q: MetricQuery, +) -> Result, sqlx::Error> { let limit = q.limit.unwrap_or(200).clamp(1, 2000); // Reading every point in the window to GROUP BY name was ~minutes at scale. // Instead: enumerate distinct names with a loose index scan (recursive CTE), // then per name read only the 30 most-recent points (via metrics_name_time_idx) // for the latest value + sparkline. Names with no points in the window drop out. - let rows = sqlx::query_as::<_, MetricSummary>( + sqlx::query_as::<_, MetricSummary>( "WITH RECURSIVE names AS ( SELECT min(name) AS name FROM metrics UNION ALL @@ -325,11 +352,9 @@ pub async fn list_metrics( .bind(q.from) .bind(q.to) .bind(limit) - .fetch_all(&pool) + .fetch_all(pool) .instrument(tracing::info_span!("db.query")) .await - .map_err(internal)?; - Ok(Json(rows)) } /// Time-bucket width (seconds) used for rollups and raw-series bucketing. @@ -344,9 +369,9 @@ fn rollup_bucket_secs() -> f64 { #[derive(Deserialize)] pub struct SeriesQuery { - name: String, - service: Option, - hours: Option, + pub name: String, + pub service: Option, + pub hours: Option, } #[derive(Serialize, sqlx::FromRow)] @@ -363,8 +388,17 @@ pub async fn metric_series( State(pool): State, Query(q): Query, ) -> Result>, ApiError> { + Ok(Json(query_metric_series(&pool, q).await.map_err(internal)?)) +} + +/// One metric's collapsed time series, shared by the HTTP handler and the MCP +/// `metric_series` tool. Applies the same hours clamp. +pub async fn query_metric_series( + pool: &PgPool, + q: SeriesQuery, +) -> Result, sqlx::Error> { let hours = q.hours.unwrap_or(24).clamp(1, 24 * 90); - let rows = sqlx::query_as::<_, SeriesPoint>( + sqlx::query_as::<_, SeriesPoint>( "SELECT bucket AS t, sum(sum) / nullif(sum(count), 0) AS v FROM metric_series_rollups WHERE name = $1 AND ($2::text IS NULL OR service = $2) @@ -375,11 +409,9 @@ pub async fn metric_series( .bind(q.name) .bind(q.service) .bind(hours) - .fetch_all(&pool) + .fetch_all(pool) .instrument(tracing::info_span!("db.query")) .await - .map_err(internal)?; - Ok(Json(rows)) } #[derive(Deserialize)] @@ -860,8 +892,8 @@ pub async fn metric_hist_facet( #[derive(Deserialize)] pub struct RedQuery { - from: Option>, - to: Option>, + pub from: Option>, + pub to: Option>, } #[derive(Serialize, sqlx::FromRow)] @@ -882,7 +914,13 @@ pub async fn service_red( State(pool): State, Query(q): Query, ) -> Result>, ApiError> { - let rows = sqlx::query_as::<_, ServiceRed>( + Ok(Json(query_service_red(&pool, q).await.map_err(internal)?)) +} + +/// Per-service RED query shared by the HTTP handler and the MCP `list_services` +/// tool. Keeps the same default 24h window. +pub async fn query_service_red(pool: &PgPool, q: RedQuery) -> Result, sqlx::Error> { + sqlx::query_as::<_, ServiceRed>( "SELECT service, count(*) AS spans, count(*) FILTER (WHERE status_code = 2) AS errors, @@ -902,11 +940,9 @@ pub async fn service_red( ) .bind(q.from) .bind(q.to) - .fetch_all(&pool) + .fetch_all(pool) .instrument(tracing::info_span!("db.query")) .await - .map_err(internal)?; - Ok(Json(rows)) } #[derive(Serialize, sqlx::FromRow)] @@ -925,6 +961,12 @@ pub struct ServiceMap { /// GET /api/servicemap — service dependency graph derived from span parent/child links. #[tracing::instrument(skip_all)] pub async fn service_map(State(pool): State) -> Result, ApiError> { + Ok(Json(query_service_map(&pool).await.map_err(internal)?)) +} + +/// Service dependency graph shared by the HTTP handler and the MCP `service_map` +/// tool. +pub async fn query_service_map(pool: &PgPool) -> Result { // Current topology only: bound to the recent window so this is a tiny // index scan, not a self-join over the whole (retention-deep) spans table. let nodes: Vec = sqlx::query_scalar( @@ -932,10 +974,9 @@ pub async fn service_map(State(pool): State) -> Result, WHERE service IS NOT NULL AND start_time > now() - interval '1 hour' ORDER BY 1", ) - .fetch_all(&pool) + .fetch_all(pool) .instrument(tracing::info_span!("db.query")) - .await - .map_err(internal)?; + .await?; let edges = sqlx::query_as::<_, ServiceEdge>( "SELECT parent.service AS source, child.service AS target, count(*) AS calls @@ -950,12 +991,11 @@ pub async fn service_map(State(pool): State) -> Result, GROUP BY parent.service, child.service ORDER BY calls DESC", ) - .fetch_all(&pool) + .fetch_all(pool) .instrument(tracing::info_span!("db.query")) - .await - .map_err(internal)?; + .await?; - Ok(Json(ServiceMap { nodes, edges })) + Ok(ServiceMap { nodes, edges }) } // --------------------------------------------------------------------------- @@ -990,7 +1030,13 @@ pub struct AlertRuleView { /// GET /api/alerts — all rules with their current firing state. pub async fn list_alerts(State(pool): State) -> Result>, ApiError> { - let rows = sqlx::query_as::<_, AlertRuleView>( + Ok(Json(query_alerts(&pool).await.map_err(internal)?)) +} + +/// Alert rules with firing state, shared by the HTTP handler and the MCP +/// `list_alerts` tool. +pub async fn query_alerts(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, AlertRuleView>( "SELECT r.id, r.name, r.metric, r.service, r.comparator, r.threshold, r.agg, r.window_secs, r.enabled, r.created_at, (e.id IS NOT NULL) AS firing, m.kind, m.unit @@ -1011,11 +1057,9 @@ pub async fn list_alerts(State(pool): State) -> Result) -> Result, + pub limit: Option, } #[derive(Serialize, sqlx::FromRow)] @@ -1046,8 +1090,17 @@ pub async fn list_alert_events( State(pool): State, Query(q): Query, ) -> Result>, ApiError> { + Ok(Json(query_alert_events(&pool, q).await.map_err(internal)?)) +} + +/// Recent alert transitions, shared by the HTTP handler and the MCP +/// `alert_events` tool. Applies the same limit clamp. +pub async fn query_alert_events( + pool: &PgPool, + q: EventQuery, +) -> Result, sqlx::Error> { let limit = q.limit.unwrap_or(100).clamp(1, 1000); - let rows = sqlx::query_as::<_, AlertEventView>( + sqlx::query_as::<_, AlertEventView>( // Same metric-metadata LATERAL join as list_alerts (see note there). "SELECT e.id, e.rule_id, r.name AS rule_name, r.metric, e.value, e.fired_at, e.resolved_at, m.kind, m.unit @@ -1069,9 +1122,7 @@ pub async fn list_alert_events( LIMIT $1", ) .bind(limit) - .fetch_all(&pool) + .fetch_all(pool) .instrument(tracing::info_span!("db.query")) .await - .map_err(internal)?; - Ok(Json(rows)) } diff --git a/server/src/lib.rs b/server/src/lib.rs index d539fb7..e2949b6 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -3,6 +3,7 @@ pub mod alerts; pub mod api; pub mod db; pub mod grpc; +pub mod mcp; pub mod otlp; pub mod retention; pub mod selflog; @@ -205,7 +206,8 @@ pub fn app_with_access(pool: PgPool, access: Option>) -> Router { .layer(tower_http::trace::TraceLayer::new_for_http().make_span_with(otel_request_span)); // The guarded surface: `/api` plus the SPA fallback (the UI shell). When Access - // is configured, the JWT middleware wraps exactly these — not ingest or healthz. + // is configured, the JWT middleware wraps exactly these — not ingest, healthz, + // or /mcp. let guarded = api.fallback(ui_handler); let guarded = match access { Some(verifier) => { @@ -214,12 +216,23 @@ pub fn app_with_access(pool: PgPool, access: Option>) -> Router { None => guarded, }; - Router::new() + let mut router = Router::new() // Never gated: kubelet hits /healthz and in-cluster collectors hit /v1 // directly, neither carrying an Access token. .route("/healthz", get(api::healthz)) .merge(ingest) - .merge(guarded) - .layer(CorsLayer::permissive()) - .with_state(pool) + .merge(guarded); + + // Read-only MCP server (JEF-471), opt-in via WATCHER_MCP_ENABLED (default + // OFF). Nested as its own tower service *outside* the `/api` router — and + // therefore outside the Access guard above. It is not a browser surface and + // gets its own auth in JEF-472, so it must not ride the browser-cookie edge + // auth the UI/`/api` sit behind. Nesting before the state is applied keeps it + // off `with_state` (it carries its own pool). The startup log lives in `main` + // (this fn runs per-request in tests). + if mcp::enabled() { + router = router.nest_service("/mcp", mcp::service(pool.clone())); + } + + router.layer(CorsLayer::permissive()).with_state(pool) } diff --git a/server/src/main.rs b/server/src/main.rs index ccfe9a5..17ef110 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -7,7 +7,7 @@ use tracing_subscriber::filter::dynamic_filter_fn; use tracing_subscriber::prelude::*; use tracing_subscriber::EnvFilter; use watcher_server::{ - access_jwt, alerts, app_with_access, db, grpc, retention, selflog, selfmon, selftrace, + access_jwt, alerts, app_with_access, db, grpc, mcp, retention, selflog, selfmon, selftrace, }; /// Self-instrumentation: capture watcher's own traces **in-process**, tagged @@ -188,6 +188,15 @@ async fn main() -> anyhow::Result<()> { ); } + // Read-only MCP server (JEF-471): mounted at /mcp by `app_with_access` only when + // WATCHER_MCP_ENABLED is set (default OFF). It carries no auth of its own yet + // (JEF-472), so it must not be exposed unauthenticated — hence opt-in. + if mcp::enabled() { + tracing::info!("MCP server (read-only) enabled at /mcp"); + } else { + tracing::debug!("MCP server disabled (set WATCHER_MCP_ENABLED=1 to enable /mcp)"); + } + let http = { let pool = pool.clone(); let bind = http_bind.clone(); diff --git a/server/src/mcp.rs b/server/src/mcp.rs new file mode 100644 index 0000000..03dc56a --- /dev/null +++ b/server/src/mcp.rs @@ -0,0 +1,348 @@ +//! Read-only MCP server (JEF-471) mounted in-process on the axum app at `/mcp`. +//! +//! Exposes watcher's read API as Model Context Protocol tools over the official +//! streamable-HTTP transport (`rmcp`), so an MCP client (MCP Inspector, Claude +//! Code, …) can search traces/logs/metrics and read services/alerts against the +//! same Postgres the UI queries. Every tool is a thin wrapper over an existing +//! `api::query_*` function — the *same* code the HTTP handlers run — so both +//! surfaces share every limit clamp and default time window. There are **no** +//! write/mutate tools; `/mcp` touches no ingest or reconcile path (ADR 0018). +//! +//! `/mcp` is gated behind `WATCHER_MCP_ENABLED` (default OFF) and only mounted +//! when enabled — it carries no auth of its own yet (JEF-472), so it must not be +//! exposed unauthenticated. It mounts *outside* the browser-cookie edge auth the +//! UI/`/api` sit behind (Cloudflare Access, ADR 0013): an MCP client is not a +//! browser and will get its own auth in JEF-472. + +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use rmcp::{ + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{CallToolResult, ContentBlock, ServerCapabilities, ServerInfo}, + tool, tool_handler, tool_router, + transport::streamable_http_server::{ + session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, + }, + ErrorData as McpError, ServerHandler, +}; +use schemars::JsonSchema; +use serde::Deserialize; +use sqlx::PgPool; + +use crate::api; + +/// Env flag gating `/mcp`. Default OFF (opt-in) — unlike the self-telemetry +/// opt-outs — because the endpoint is unauthenticated until JEF-472 lands. +const ENABLE_FLAG: &str = "WATCHER_MCP_ENABLED"; + +/// Whether the MCP endpoint should be mounted. Only an explicit truthy value +/// enables it; anything else (including unset) leaves `/mcp` off. +pub fn enabled() -> bool { + std::env::var(ENABLE_FLAG) + .map(|v| matches!(v.as_str(), "1" | "true" | "on")) + .unwrap_or(false) +} + +/// The MCP server: holds the shared `PgPool` and the generated tool router. +#[derive(Clone)] +pub struct WatcherMcp { + pool: PgPool, + tool_router: ToolRouter, +} + +// --- Tool argument schemas ------------------------------------------------- +// These mirror the useful fields of the `api::*Query` structs. Time bounds are +// accepted as RFC3339 strings (LLM-friendly) and parsed to `DateTime`. + +#[derive(Debug, Deserialize, JsonSchema, Default)] +pub struct SearchTracesArgs { + /// Max traces to return (clamped 1–1000, default 100). + pub limit: Option, + /// Filter to a single service name. + pub service: Option, + /// Substring match on the trace's root span (operation) name. + pub name: Option, + /// Attribute equality filter `key=value`, matched against any span in the trace. + pub attr: Option, + /// Only traces containing at least one error span. + #[serde(default)] + pub errors_only: bool, + /// Only traces at least this many milliseconds long (find slow traces). + pub min_duration_ms: Option, + /// Start of the time window (RFC3339). Defaults to 24h ago when omitted. + pub from: Option, + /// End of the time window (RFC3339). Unbounded when omitted. + pub to: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetTraceArgs { + /// Trace id (hex string) whose spans to fetch, ordered for a waterfall. + pub trace_id: String, +} + +#[derive(Debug, Deserialize, JsonSchema, Default)] +pub struct QueryLogsArgs { + /// Max log rows to return (clamped 1–2000, default 200). + pub limit: Option, + /// Filter to a single service name. + pub service: Option, + /// Filter to one trace's logs. + pub trace_id: Option, + /// Filter to one span's logs. + pub span_id: Option, + /// Case-insensitive substring match on the log body. + pub q: Option, + /// Start of the time window (RFC3339). + pub from: Option, + /// End of the time window (RFC3339). + pub to: Option, + /// Attribute equality filter `key=value` (e.g. `k8s.pod.name=api-7f`). + pub attr: Option, +} + +#[derive(Debug, Deserialize, JsonSchema, Default)] +pub struct ServicesArgs { + /// Start of the RED window (RFC3339). Defaults to 24h ago when omitted. + pub from: Option, + /// End of the RED window (RFC3339). + pub to: Option, +} + +#[derive(Debug, Deserialize, JsonSchema, Default)] +pub struct QueryMetricsArgs { + /// Max metric series to return (clamped 1–2000, default 200). + pub limit: Option, + /// Filter to a single service name. + pub service: Option, + /// Start of the sample window (RFC3339). + pub from: Option, + /// End of the sample window (RFC3339). + pub to: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MetricSeriesArgs { + /// The metric name to plot. + pub name: String, + /// Filter to a single service name. + pub service: Option, + /// Lookback window in hours (clamped 1–2160, default 24). + pub hours: Option, +} + +#[derive(Debug, Deserialize, JsonSchema, Default)] +pub struct AlertEventsArgs { + /// Max transitions to return (clamped 1–1000, default 100). + pub limit: Option, +} + +// --- Helpers --------------------------------------------------------------- + +/// Serialize a typed result as the tool's JSON content. All response shapes are +/// the exact `api` structs, so the JSON matches the `/api` responses. +fn json_result(value: &T) -> Result { + Ok(CallToolResult::success(vec![ContentBlock::json(value)?])) +} + +/// A DB failure is an infrastructure (protocol) error, not a tool-level one. +fn db_error(e: sqlx::Error) -> McpError { + McpError::internal_error(format!("query failed: {e}"), None) +} + +/// Parse an optional RFC3339 time bound, mapping a bad value to invalid_params. +fn parse_time(field: &str, v: Option) -> Result>, McpError> { + match v { + None => Ok(None), + Some(s) => DateTime::parse_from_rfc3339(&s) + .map(|d| Some(d.with_timezone(&Utc))) + .map_err(|e| { + McpError::invalid_params( + format!("`{field}` must be an RFC3339 timestamp: {e}"), + None, + ) + }), + } +} + +#[tool_router] +impl WatcherMcp { + pub fn new(pool: PgPool) -> Self { + Self { + pool, + tool_router: Self::tool_router(), + } + } + + #[tool( + description = "Search recent distributed traces (one row per trace) with \ + optional service, root-operation-name, attribute, error-only, min-duration and \ + time-window filters. Defaults to the last 24 hours." + )] + async fn search_traces( + &self, + Parameters(a): Parameters, + ) -> Result { + let q = api::TraceQuery { + limit: a.limit, + service: a.service, + name: a.name, + attr: a.attr, + errors_only: a.errors_only, + min_duration_ms: a.min_duration_ms, + from: parse_time("from", a.from)?, + to: parse_time("to", a.to)?, + }; + let rows = api::query_traces(&self.pool, q).await.map_err(db_error)?; + json_result(&rows) + } + + #[tool( + description = "Fetch every span of a single trace by trace id, ordered by \ + start time for waterfall rendering." + )] + async fn get_trace( + &self, + Parameters(a): Parameters, + ) -> Result { + let rows = api::query_trace_spans(&self.pool, a.trace_id) + .await + .map_err(db_error)?; + json_result(&rows) + } + + #[tool( + description = "Query recent logs with optional service, trace id, span id, \ + body substring, attribute and time-window filters." + )] + async fn query_logs( + &self, + Parameters(a): Parameters, + ) -> Result { + let q = api::LogQuery { + limit: a.limit, + service: a.service, + trace_id: a.trace_id, + span_id: a.span_id, + q: a.q, + from: parse_time("from", a.from)?, + to: parse_time("to", a.to)?, + attr: a.attr, + }; + let rows = api::query_logs(&self.pool, q).await.map_err(db_error)?; + json_result(&rows) + } + + #[tool( + description = "List per-service RED metrics (request count, error count + \ + rate, latency p50/p95/p99) over a time window. Defaults to the last 24 hours." + )] + async fn list_services( + &self, + Parameters(a): Parameters, + ) -> Result { + let q = api::RedQuery { + from: parse_time("from", a.from)?, + to: parse_time("to", a.to)?, + }; + let rows = api::query_service_red(&self.pool, q) + .await + .map_err(db_error)?; + json_result(&rows) + } + + #[tool( + description = "Return the service dependency graph (nodes + call-count \ + edges) derived from span parent/child links over the last hour." + )] + async fn service_map(&self) -> Result { + let map = api::query_service_map(&self.pool).await.map_err(db_error)?; + json_result(&map) + } + + #[tool( + description = "List metric series with their latest value and a short \ + sparkline, with optional service and time-window filters." + )] + async fn query_metrics( + &self, + Parameters(a): Parameters, + ) -> Result { + let q = api::MetricQuery { + limit: a.limit, + service: a.service, + from: parse_time("from", a.from)?, + to: parse_time("to", a.to)?, + }; + let rows = api::query_metrics(&self.pool, q).await.map_err(db_error)?; + json_result(&rows) + } + + #[tool( + description = "Return a time series (bucket-averaged) for one metric name \ + over a lookback window in hours (default 24), optionally filtered by service." + )] + async fn metric_series( + &self, + Parameters(a): Parameters, + ) -> Result { + let q = api::SeriesQuery { + name: a.name, + service: a.service, + hours: a.hours, + }; + let rows = api::query_metric_series(&self.pool, q) + .await + .map_err(db_error)?; + json_result(&rows) + } + + #[tool( + description = "List all configured alert rules with their current firing \ + state and the watched metric's kind/unit." + )] + async fn list_alerts(&self) -> Result { + let rows = api::query_alerts(&self.pool).await.map_err(db_error)?; + json_result(&rows) + } + + #[tool(description = "List recent alert firing/resolved transitions, newest first.")] + async fn alert_events( + &self, + Parameters(a): Parameters, + ) -> Result { + let q = api::EventQuery { limit: a.limit }; + let rows = api::query_alert_events(&self.pool, q) + .await + .map_err(db_error)?; + json_result(&rows) + } +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for WatcherMcp { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions( + "watcher: a Postgres-native OpenTelemetry traces/logs/metrics backend. \ + All tools are READ-ONLY queries over the same data the UI shows.", + ) + } +} + +/// Build the streamable-HTTP MCP service to nest at `/mcp`. A fresh +/// [`WatcherMcp`] is created per session, each sharing the same `PgPool`. +pub fn service(pool: PgPool) -> StreamableHttpService { + // The transport defaults to a loopback-only Host allow-list (DNS-rebinding + // protection for locally-run servers reached by a browser). watcher's MCP is + // a server-to-server endpoint reached through a public tunnel host and gated + // by the enable flag (and, per JEF-472, its own auth), so that default would + // reject every legitimate client. Disable the Host allow-list here; Origin + // validation stays off since MCP clients are not browsers. + let config = StreamableHttpServerConfig::default().disable_allowed_hosts(); + StreamableHttpService::new( + move || Ok(WatcherMcp::new(pool.clone())), + Arc::new(LocalSessionManager::default()), + config, + ) +} diff --git a/server/tests/smoke.rs b/server/tests/smoke.rs index 74b6954..4b6708c 100644 --- a/server/tests/smoke.rs +++ b/server/tests/smoke.rs @@ -2654,3 +2654,101 @@ async fn access_unconfigured_leaves_api_open() { "unconfigured Access must not gate /api" ); } + +// --- MCP server (JEF-471) -------------------------------------------------- + +/// End-to-end MCP smoke test: enable `/mcp`, serve the real app on an ephemeral +/// port, and drive it with the official rmcp streamable-HTTP client — list the +/// tools and call `list_services` + `query_logs`, asserting the JSON shape. +/// Multi-thread runtime so the server accept-loop and client run concurrently. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn mcp_lists_tools_and_calls_read_queries() { + use rmcp::{ + model::CallToolRequestParams, transport::StreamableHttpClientTransport, ServiceExt, + }; + + let Some(pool) = pool_or_skip().await else { + eprintln!("skipping: DATABASE_URL not set"); + return; + }; + // Seed one service (span) + one log so list_services / query_logs return data. + insert_span_at(&pool, "checkout", "mcp-tr", "mcp-s", 2.0).await; + insert_log_at(&pool, "checkout", 2.0).await; + + // Enable the endpoint for this test only (default OFF). #[serial] keeps this + // process-global env change from racing the other tests. + std::env::set_var("WATCHER_MCP_ENABLED", "1"); + let router = app(pool); + std::env::remove_var("WATCHER_MCP_ENABLED"); + + // Serve on an ephemeral port so a real MCP client can drive the transport. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + + let transport = StreamableHttpClientTransport::from_uri(format!("http://{addr}/mcp")); + let client = ().serve(transport).await.expect("mcp handshake"); + + // Tools list: exactly the nine read tools are advertised (no write/mutate tool). + let tools = client.list_all_tools().await.expect("list_tools"); + let names: Vec = tools.iter().map(|t| t.name.to_string()).collect(); + for expected in [ + "search_traces", + "get_trace", + "query_logs", + "list_services", + "service_map", + "query_metrics", + "metric_series", + "list_alerts", + "alert_events", + ] { + assert!( + names.iter().any(|n| n == expected), + "missing tool {expected} in {names:?}" + ); + } + assert_eq!(names.len(), 9, "exactly the nine read tools: {names:?}"); + + // list_services (→ service_red): the seeded service with its RED shape. + let res = client + .call_tool(CallToolRequestParams::new("list_services")) + .await + .expect("call list_services"); + assert_ne!(res.is_error, Some(true), "list_services errored"); + let text = res + .content + .first() + .and_then(|c| c.as_text()) + .expect("text content"); + let services: serde_json::Value = serde_json::from_str(&text.text).expect("services JSON"); + let svc = services + .as_array() + .expect("services array") + .iter() + .find(|s| s["service"] == "checkout") + .expect("checkout present"); + assert!(svc["spans"].as_i64().unwrap() >= 1); + + // query_logs (→ list_logs): the seeded log with its typed fields, no filters. + let res = client + .call_tool(CallToolRequestParams::new("query_logs")) + .await + .expect("call query_logs"); + assert_ne!(res.is_error, Some(true), "query_logs errored"); + let text = res + .content + .first() + .and_then(|c| c.as_text()) + .expect("text content"); + let logs: serde_json::Value = serde_json::from_str(&text.text).expect("logs JSON"); + let larr = logs.as_array().expect("logs array"); + assert_eq!(larr.len(), 1); + assert_eq!(larr[0]["service"], "checkout"); + + client.cancel().await.ok(); + server.abort(); +}