Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions crates/plumb-cli/tests/mcp_stdio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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");
}
71 changes: 69 additions & 2 deletions crates/plumb-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,6 +47,7 @@ use rmcp::{
transport::stdio,
};
use serde::Deserialize;
use serde_json::{Value, json};
use thiserror::Error;

/// MCP server errors.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -175,6 +183,60 @@ impl PlumbServer {
result.structured_content = Some(structured);
Ok(result)
}

async fn list_rules(&self, _args: ListRulesArgs) -> Result<CallToolResult, ErrorData> {
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 `<category>/<name>` 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<Value> = 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 {
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -224,6 +287,10 @@ impl ServerHandler for PlumbServer {
"explain_rule",
"Return canonical documentation and metadata for a Plumb rule by id.",
),
tool_descriptor::<ListRulesArgs>(
"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)))
}
Expand Down
53 changes: 53 additions & 0 deletions crates/plumb-mcp/tests/mcp_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
1 change: 1 addition & 0 deletions docs/src/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<category>/<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:
Expand Down
Loading