diff --git a/crates/plumb-cli/tests/mcp_stdio.rs b/crates/plumb-cli/tests/mcp_stdio.rs index 48afd3a..902afaa 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,37 @@ 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/src/lib.rs b/crates/plumb-mcp/src/lib.rs index c0a9cf5..2b98437 100644 --- a/crates/plumb-mcp/src/lib.rs +++ b/crates/plumb-mcp/src/lib.rs @@ -12,6 +12,8 @@ //! (served from the canned snapshot). //! - `explain_rule` — returns the canonical markdown documentation and //! metadata for a built-in rule by id. +//! - `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 @@ -45,6 +47,7 @@ use rmcp::{ transport::stdio, }; use serde::Deserialize; +use serde_json::{Value, json}; use thiserror::Error; /// MCP server errors. @@ -80,6 +83,11 @@ pub struct ExplainRuleArgs { pub rule_id: 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 { @@ -175,6 +183,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 { @@ -185,8 +247,8 @@ impl ServerHandler for PlumbServer { 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 `explain_rule` for canonical rule documentation; use `echo` to smoke-test the \ - transport." + use `explain_rule` for canonical rule documentation; use `list_rules` to enumerate \ + every built-in rule; use `echo` to smoke-test the transport." .into(), ); info @@ -202,6 +264,7 @@ impl ServerHandler for PlumbServer { "echo" => self.echo(parse_tool_args(arguments)?).await, "lint_url" => self.lint_url(parse_tool_args(arguments)?).await, "explain_rule" => self.explain_rule(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, @@ -224,6 +287,10 @@ impl ServerHandler for PlumbServer { "explain_rule", "Return canonical documentation and metadata for a Plumb rule by id.", ), + 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 6b69b46..e08f64c 100644 --- a/crates/plumb-mcp/tests/mcp_protocol.rs +++ b/crates/plumb-mcp/tests/mcp_protocol.rs @@ -129,3 +129,56 @@ fn every_builtin_rule_has_doc_entry() { "the explain_rule doc table must cover every rule in register_builtin() and nothing more", ); } + +#[test] +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, builtin_count, + "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"); + 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"); +} diff --git a/docs/src/mcp.md b/docs/src/mcp.md index 63d3f9e..ef1a816 100644 --- a/docs/src/mcp.md +++ b/docs/src/mcp.md @@ -39,6 +39,7 @@ For local development against a source checkout: | `echo` | Smoke-test the transport. Echoes the `message` arg back. | | `lint_url` | Lint a URL. Accepts `http(s)://` URLs (driven by the bundled Chromium driver) and `plumb-fake://hello` (canned snapshot for tests). On a Chromium launch failure the response is returned with `isError: true` and a single text block carrying the typed driver error. | | `explain_rule` | Return canonical documentation and metadata for a Plumb rule by id. Args: `{ "rule_id": "/" }`. | +| `list_rules` | List every built-in Plumb rule with id, default severity, and one-line summary. No args. | The response shape follows the MCP `content` + `structuredContent` convention: