From 3fa0ea67ac093af0c4bb0ed0ee73c54a28f90685 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sun, 26 Apr 2026 10:28:11 -0600 Subject: [PATCH 1/3] feat(mcp): list_rules tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enumerate every built-in Plumb rule with id, default severity, and one-line summary from `Rule::summary`. Sorted by rule id (which encodes `/`) so the response is deterministic across runs and independent of `register_builtin` ordering. Adds `ListRulesArgs` (no fields), `PlumbServer::list_rules` and the `list_rules_payload` helper, plus the `tools/list` descriptor and `call_tool` branch. Helper returns `(text, structured)` where `structured` carries `{ rules: [...], count: N }` and `text` is one short line per rule — bounded well under the 10 KB token budget. Unit-tested via the helper (constructing `RequestContext` in unit scope is awkward in rmcp 1.5). Stdio protocol coverage and the `docs/src/mcp.md` table land in a follow-up PR. Refs #36 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/plumb-mcp/src/lib.rs | 75 ++++++++++++++++++++++++-- crates/plumb-mcp/tests/mcp_protocol.rs | 53 ++++++++++++++++++ 2 files changed, 124 insertions(+), 4 deletions(-) diff --git a/crates/plumb-mcp/src/lib.rs b/crates/plumb-mcp/src/lib.rs index ab71aef..661708d 100644 --- a/crates/plumb-mcp/src/lib.rs +++ b/crates/plumb-mcp/src/lib.rs @@ -3,12 +3,14 @@ //! Model Context Protocol server for Plumb, backed by the official //! [`rmcp`] Rust SDK. //! -//! The server exposes two tools to AI coding agents: +//! The server exposes three tools to AI coding agents: //! //! - `echo` — smoke-tests the transport. //! - `lint_url` — lints a URL and returns violations in the MCP-compact //! shape from `docs/local/prd.md` §14.2. Walking-skeleton accepts //! `plumb-fake://` URLs only. +//! - `list_rules` — enumerates every built-in rule with id, default +//! severity, and one-line summary. //! //! The [`PlumbServer`] type implements [`rmcp::ServerHandler`] directly. //! Extend it by adding a tool descriptor to `list_tools` and a matching @@ -21,7 +23,7 @@ use std::io; -use plumb_core::{Config, PlumbSnapshot, run}; +use plumb_core::{Config, PlumbSnapshot, rules::register_builtin, run}; use plumb_format::mcp_compact; use rmcp::{ RoleServer, ServerHandler, ServiceExt, @@ -36,6 +38,7 @@ use rmcp::{ transport::stdio, }; use serde::Deserialize; +use serde_json::{Value, json}; use thiserror::Error; /// MCP server errors. @@ -64,6 +67,11 @@ pub struct LintUrlArgs { pub url: String, } +/// Arguments to the `list_rules` tool. Currently empty — agents call +/// it without parameters. +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct ListRulesArgs {} + /// The Plumb MCP server. Cheap to construct. #[derive(Clone, Default)] pub struct PlumbServer { @@ -95,6 +103,60 @@ impl PlumbServer { result.structured_content = Some(structured); Ok(result) } + + async fn list_rules(&self, _args: ListRulesArgs) -> Result { + let (text, structured) = self.list_rules_payload(); + let mut result = CallToolResult::success(vec![Content::text(text)]); + result.structured_content = Some(structured); + Ok(result) + } + + /// Build the `list_rules` response payload — `(human text, structured JSON)`. + /// + /// Output is a deterministic function of the built-in rule registry: rules + /// are sorted by id (which encodes `/` and so sorts by + /// category first), severity is the lowercase [`Severity::label`] string, + /// and the structured block carries a `count` plus the `rules` array. + /// + /// Token budget: bounded by `register_builtin().len()` — one short line + /// per rule, well under 10 KB at the current rule count and growth rate. + /// + /// Takes `&self` for ergonomic call-site symmetry with other tool methods, + /// even though the response is purely a function of the built-in registry. + /// + /// [`Severity::label`]: plumb_core::report::Severity::label + #[must_use] + #[allow(clippy::unused_self)] + pub fn list_rules_payload(&self) -> (String, Value) { + let mut entries: Vec<(&'static str, &'static str, &'static str)> = register_builtin() + .iter() + .map(|rule| (rule.id(), rule.default_severity().label(), rule.summary())) + .collect(); + entries.sort_unstable_by(|a, b| a.0.cmp(b.0)); + + let mut text = String::new(); + for (id, severity, summary) in &entries { + use std::fmt::Write as _; + let _ = writeln!(text, "{severity} {id} — {summary}"); + } + + let rules: Vec = entries + .iter() + .map(|(id, severity, summary)| { + json!({ + "id": id, + "default_severity": severity, + "summary": summary, + }) + }) + .collect(); + let structured = json!({ + "rules": rules, + "count": entries.len(), + }); + + (text, structured) + } } impl ServerHandler for PlumbServer { @@ -104,8 +166,8 @@ impl ServerHandler for PlumbServer { info.capabilities = ServerCapabilities::builder().enable_tools().build(); info.server_info = Implementation::new("plumb", env!("CARGO_PKG_VERSION")); info.instructions = Some( - "Deterministic design-system linter. Call `lint_url` with a URL to get violations; \ - use `echo` to smoke-test the transport." + "Deterministic design-system linter. Call `lint_url` with a URL to get violations, \ + `list_rules` to enumerate every built-in rule, or `echo` to smoke-test the transport." .into(), ); info @@ -120,6 +182,7 @@ impl ServerHandler for PlumbServer { match request.name.as_ref() { "echo" => self.echo(parse_tool_args(arguments)?).await, "lint_url" => self.lint_url(parse_tool_args(arguments)?).await, + "list_rules" => self.list_rules(parse_tool_args(arguments)?).await, unknown => Err(ErrorData::invalid_params( format!("unknown tool: {unknown}"), None, @@ -138,6 +201,10 @@ impl ServerHandler for PlumbServer { "lint_url", "Lint a URL with Plumb. Walking-skeleton accepts plumb-fake:// URLs only.", ), + tool_descriptor::( + "list_rules", + "List every built-in Plumb rule with id, default severity, and one-line summary.", + ), ]; std::future::ready(Ok(ListToolsResult::with_all_items(tools))) } diff --git a/crates/plumb-mcp/tests/mcp_protocol.rs b/crates/plumb-mcp/tests/mcp_protocol.rs index 27327f7..b126839 100644 --- a/crates/plumb-mcp/tests/mcp_protocol.rs +++ b/crates/plumb-mcp/tests/mcp_protocol.rs @@ -43,3 +43,56 @@ fn default_is_equivalent_to_new() { // Smoke-test that Default::default() constructs a usable server. let _server = PlumbServer::default(); } + +#[test] +fn list_rules_returns_every_builtin_rule_sorted() { + let server = PlumbServer::new(); + let (text, structured) = server.list_rules_payload(); + + // Text block: bounded, one line per rule, deterministic. + assert!(!text.is_empty(), "list_rules text must not be empty"); + let line_count = text.lines().count(); + assert_eq!( + line_count, + plumb_core::rules::register_builtin().len(), + "list_rules text must have one line per rule" + ); + + let count = structured + .get("count") + .and_then(serde_json::Value::as_u64) + .expect("count field"); + let rules = structured + .get("rules") + .and_then(serde_json::Value::as_array) + .expect("rules array"); + let builtin_count = plumb_core::rules::register_builtin().len(); + assert_eq!(count, builtin_count as u64); + assert_eq!(rules.len(), builtin_count); + + // Sorted by id ascending. + let ids: Vec<&str> = rules + .iter() + .map(|r| r["id"].as_str().expect("id string")) + .collect(); + let mut sorted = ids.clone(); + sorted.sort_unstable(); + assert_eq!(ids, sorted, "rules must be sorted by id"); + + // First entry shape — exact id is sensitive to registry contents + // and asserted indirectly via the sort check above. + let first = &rules[0]; + assert!( + first["id"].as_str().is_some_and(|id| !id.is_empty()), + "first entry must carry a non-empty id" + ); + assert!( + matches!( + first["default_severity"].as_str(), + Some("info" | "warning" | "error") + ), + "default_severity must be a lowercase severity label" + ); + let summary = first["summary"].as_str().expect("summary string"); + assert!(!summary.is_empty(), "summary must not be empty"); +} From 3e18d1b57d428a2b5b9e3360931a7b6ece587b59 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh <59990765+aram-devdocs@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:50:21 +0000 Subject: [PATCH 2/3] test(mcp): e2e list_rules + tools/list assertions in mcp_stdio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Claude review REQUEST_CHANGES on PR #129: - Extend `mcp_initialize_and_tools_list` to assert that `list_rules` and `explain_rule` (added on main while this PR was open) are present in the tools/list response. - Add `mcp_list_rules_returns_every_rule` — full JSON-RPC round trip via the binary, asserts isError=false, count>0, and a non-empty rule id on the first entry. - Nit: bind `register_builtin().len()` once in the in-process list_rules test rather than calling the registry twice. Co-Authored-By: Claude Opus 4.7 --- crates/plumb-cli/tests/mcp_stdio.rs | 34 ++++++++++++++++++++++++++ crates/plumb-mcp/tests/mcp_protocol.rs | 6 ++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/plumb-cli/tests/mcp_stdio.rs b/crates/plumb-cli/tests/mcp_stdio.rs index 48afd3a..d07aaf4 100644 --- a/crates/plumb-cli/tests/mcp_stdio.rs +++ b/crates/plumb-cli/tests/mcp_stdio.rs @@ -110,6 +110,8 @@ fn mcp_initialize_and_tools_list() { let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect(); assert!(names.contains(&"echo")); assert!(names.contains(&"lint_url")); + assert!(names.contains(&"explain_rule")); + assert!(names.contains(&"list_rules")); let echo = tools .iter() @@ -192,3 +194,35 @@ fn mcp_lint_url_returns_structured_content() { Some("spacing/grid-conformance") ); } + +#[test] +fn mcp_list_rules_returns_every_rule() { + let list_rules = json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { "name": "list_rules", "arguments": {} } + }); + let responses = send_and_read(vec![init_request(1), initialized_notification(), list_rules]); + let resp = responses + .iter() + .find(|r| r["id"] == 2) + .unwrap_or_else(|| panic!("list_rules response missing: got {responses:?}")); + let result = &resp["result"]; + + assert_eq!(result["isError"].as_bool(), Some(false)); + + let structured = result["structuredContent"] + .as_object() + .expect("structuredContent object"); + let count = structured["count"] + .as_u64() + .expect("count must be a u64"); + assert!(count > 0, "list_rules must return at least one rule"); + let rules = structured["rules"] + .as_array() + .expect("rules must be an array"); + assert_eq!(rules.len() as u64, count); + let first_id = rules[0]["id"] + .as_str() + .expect("first rule must carry an id string"); + assert!(!first_id.is_empty(), "rule id must not be empty"); +} diff --git a/crates/plumb-mcp/tests/mcp_protocol.rs b/crates/plumb-mcp/tests/mcp_protocol.rs index a5843b3..e08f64c 100644 --- a/crates/plumb-mcp/tests/mcp_protocol.rs +++ b/crates/plumb-mcp/tests/mcp_protocol.rs @@ -135,12 +135,13 @@ fn list_rules_returns_every_builtin_rule_sorted() { let server = PlumbServer::new(); let (text, structured) = server.list_rules_payload(); + let builtin_count = register_builtin().len(); + // Text block: bounded, one line per rule, deterministic. assert!(!text.is_empty(), "list_rules text must not be empty"); let line_count = text.lines().count(); assert_eq!( - line_count, - register_builtin().len(), + line_count, builtin_count, "list_rules text must have one line per rule" ); @@ -152,7 +153,6 @@ fn list_rules_returns_every_builtin_rule_sorted() { .get("rules") .and_then(serde_json::Value::as_array) .expect("rules array"); - let builtin_count = register_builtin().len(); assert_eq!(count, builtin_count as u64); assert_eq!(rules.len(), builtin_count); From 60b3c4a6879c80edd6dd2c993a7e0d034fc753fd Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh <59990765+aram-devdocs@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:53:54 +0000 Subject: [PATCH 3/3] style(mcp): rustfmt mcp_list_rules_returns_every_rule Co-Authored-By: Claude Opus 4.7 --- crates/plumb-cli/tests/mcp_stdio.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/plumb-cli/tests/mcp_stdio.rs b/crates/plumb-cli/tests/mcp_stdio.rs index d07aaf4..902afaa 100644 --- a/crates/plumb-cli/tests/mcp_stdio.rs +++ b/crates/plumb-cli/tests/mcp_stdio.rs @@ -201,7 +201,11 @@ fn mcp_list_rules_returns_every_rule() { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "list_rules", "arguments": {} } }); - let responses = send_and_read(vec![init_request(1), initialized_notification(), list_rules]); + let responses = send_and_read(vec![ + init_request(1), + initialized_notification(), + list_rules, + ]); let resp = responses .iter() .find(|r| r["id"] == 2) @@ -213,9 +217,7 @@ fn mcp_list_rules_returns_every_rule() { let structured = result["structuredContent"] .as_object() .expect("structuredContent object"); - let count = structured["count"] - .as_u64() - .expect("count must be a u64"); + let count = structured["count"].as_u64().expect("count must be a u64"); assert!(count > 0, "list_rules must return at least one rule"); let rules = structured["rules"] .as_array()