From ee1e6d89c7075c984bd4c00831bafb20e0975cdb Mon Sep 17 00:00:00 2001 From: oshea00 Date: Sat, 14 Mar 2026 10:27:27 -0700 Subject: [PATCH] Add resource templates support and fix clear command terminal flush - List and display resource templates alongside resources - Include resource template URIs in tab completion - Fix clear command not flushing stdout (escape codes were buffered) --- CLAUDE.md | 2 + Cargo.lock | 2 +- Cargo.toml | 2 +- src/commands.rs | 27 +++++++++-- src/display.rs | 102 ++++++++++++++++++++++++++--------------- src/protocol/client.rs | 11 ++++- src/protocol/mod.rs | 36 +++++++++++++++ tests/client_mock.rs | 55 +++++++++++++++++++++- 8 files changed, 192 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cdcd56e..5ec558c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,7 @@ ## Development Workflow +Update README.md to reflect changes in UX as a result of altered or added features. + After making any code changes, always run the following commands and fix any issues before considering the work complete: ```bash diff --git a/Cargo.lock b/Cargo.lock index 233b9dc..6daa047 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -806,7 +806,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "mcpi" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "bytes", diff --git a/Cargo.toml b/Cargo.toml index e012103..f92162b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mcpi" -version = "0.1.3" +version = "0.1.4" edition = "2021" description = "A command-line tool REPL for inspecting MCP servers" license = "MIT" diff --git a/src/commands.rs b/src/commands.rs index a3a274a..044b959 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -7,7 +7,7 @@ use tokio::sync::mpsc; use crate::config::{write_config, ReplState, TransportType}; use crate::display; use crate::protocol::client::McpClient; -use crate::protocol::{McpPrompt, McpResource, McpTool, Notification}; +use crate::protocol::{McpPrompt, McpResource, McpResourceTemplate, McpTool, Notification}; use crate::transport::{http::HttpTransport, stdio::StdioTransport}; pub async fn handle_command(state: &mut ReplState, line: &str) -> Result { @@ -93,8 +93,14 @@ async fn cmd_connect(state: &mut ReplState, args: &[String]) -> Result<()> { } { let resources: Vec = client.list_resources().await.unwrap_or_default(); + let templates: Vec = + client.list_resource_templates().await.unwrap_or_default(); let mut r = state.completer_state.resources.lock().await; - *r = resources.iter().map(|r| r.uri.clone()).collect(); + *r = resources + .iter() + .map(|r| r.uri.clone()) + .chain(templates.iter().map(|t| t.uri_template.clone())) + .collect(); } { let prompts: Vec = client.list_prompts().await.unwrap_or_default(); @@ -339,13 +345,23 @@ async fn cmd_resources(state: &mut ReplState) -> Result<()> { .as_ref() .ok_or_else(|| anyhow!("Not connected"))?; let resources = client.list_resources().await?; + let templates = client.list_resource_templates().await.unwrap_or_default(); { let mut r = state.completer_state.resources.lock().await; - *r = resources.iter().map(|r| r.uri.clone()).collect(); + *r = resources + .iter() + .map(|r| r.uri.clone()) + .chain(templates.iter().map(|t| t.uri_template.clone())) + .collect(); } - display::print_resources(&resources); + if resources.is_empty() && templates.is_empty() { + println!("{}", "No resources available.".yellow()); + } else { + display::print_resources(&resources); + display::print_resource_templates(&templates); + } Ok(()) } @@ -525,6 +541,7 @@ fn cmd_history(state: &ReplState) { fn cmd_clear() { print!("\x1B[2J\x1B[H"); + let _ = std::io::Write::flush(&mut std::io::stdout()); } fn cmd_help(args: &[String]) { @@ -562,7 +579,7 @@ fn print_full_help() { ); println!(); println!("{}", "Resources:".yellow().bold()); - println!(" {:30} List all resources", "resources"); + println!(" {:30} List resources and resource templates", "resources"); println!(" {:30} Read resource content", "read "); println!(); println!("{}", "Prompts:".yellow().bold()); diff --git a/src/display.rs b/src/display.rs index 8b2b9d3..9548ec5 100644 --- a/src/display.rs +++ b/src/display.rs @@ -1,8 +1,10 @@ use colored::Colorize; -use comfy_table::{Attribute, Cell, Color, Table}; +use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table}; use serde_json::Value; -use crate::protocol::{McpPrompt, McpResource, McpTool, Notification, ServerCapabilities}; +use crate::protocol::{ + McpPrompt, McpResource, McpResourceTemplate, McpTool, Notification, ServerCapabilities, +}; pub fn print_tools(tools: &[McpTool]) { if tools.is_empty() { @@ -10,6 +12,7 @@ pub fn print_tools(tools: &[McpTool]) { return; } let mut table = Table::new(); + table.set_content_arrangement(ContentArrangement::Dynamic); table.set_header(vec![ Cell::new("Name") .add_attribute(Attribute::Bold) @@ -25,7 +28,7 @@ pub fn print_tools(tools: &[McpTool]) { let keys = extract_schema_keys(&tool.input_schema); table.add_row(vec![ Cell::new(&tool.name).fg(Color::Green), - Cell::new(truncate(&tool.description, 60)), + Cell::new(&tool.description), Cell::new(keys), ]); } @@ -38,6 +41,7 @@ pub fn print_resources(resources: &[McpResource]) { return; } let mut table = Table::new(); + table.set_content_arrangement(ContentArrangement::Dynamic); table.set_header(vec![ Cell::new("URI") .add_attribute(Attribute::Bold) @@ -57,7 +61,39 @@ pub fn print_resources(resources: &[McpResource]) { Cell::new(&r.uri).fg(Color::Green), Cell::new(&r.name), Cell::new(&r.mime_type), - Cell::new(truncate(&r.description, 50)), + Cell::new(&r.description), + ]); + } + println!("{table}"); +} + +pub fn print_resource_templates(templates: &[McpResourceTemplate]) { + if templates.is_empty() { + return; + } + println!("{}", "Resource Templates:".bold()); + let mut table = Table::new(); + table.set_content_arrangement(ContentArrangement::Dynamic); + table.set_header(vec![ + Cell::new("URI Template") + .add_attribute(Attribute::Bold) + .fg(Color::Cyan), + Cell::new("Name") + .add_attribute(Attribute::Bold) + .fg(Color::Cyan), + Cell::new("MIME Type") + .add_attribute(Attribute::Bold) + .fg(Color::Cyan), + Cell::new("Description") + .add_attribute(Attribute::Bold) + .fg(Color::Cyan), + ]); + for t in templates { + table.add_row(vec![ + Cell::new(&t.uri_template).fg(Color::Green), + Cell::new(&t.name), + Cell::new(&t.mime_type), + Cell::new(&t.description), ]); } println!("{table}"); @@ -69,6 +105,7 @@ pub fn print_prompts(prompts: &[McpPrompt]) { return; } let mut table = Table::new(); + table.set_content_arrangement(ContentArrangement::Dynamic); table.set_header(vec![ Cell::new("Name") .add_attribute(Attribute::Bold) @@ -94,7 +131,7 @@ pub fn print_prompts(prompts: &[McpPrompt]) { .collect(); table.add_row(vec![ Cell::new(&p.name).fg(Color::Green), - Cell::new(truncate(&p.description, 60)), + Cell::new(&p.description), Cell::new(args.join(", ")), ]); } @@ -278,14 +315,6 @@ pub(crate) fn extract_schema_keys(schema: &Value) -> String { .unwrap_or_default() } -pub(crate) fn truncate(s: &str, max: usize) -> String { - if s.len() <= max { - s.to_string() - } else { - format!("{}…", &s[..max.saturating_sub(1)]) - } -} - pub fn print_error(msg: &str) { eprintln!("{} {}", "Error:".red().bold(), msg); } @@ -303,29 +332,6 @@ mod tests { use super::*; use serde_json::json; - #[test] - fn truncate_short_string_unchanged() { - assert_eq!(truncate("hello", 10), "hello"); - } - - #[test] - fn truncate_exact_length_unchanged() { - assert_eq!(truncate("hello", 5), "hello"); - } - - #[test] - fn truncate_long_string_adds_ellipsis() { - let result = truncate("hello world", 6); - assert!(result.starts_with("hello")); - assert!(result.contains('…')); - assert!(result.chars().count() <= 6); - } - - #[test] - fn truncate_empty_string() { - assert_eq!(truncate("", 5), ""); - } - #[test] fn extract_schema_keys_with_properties() { let schema = json!({"properties": {"a": {}, "b": {}}}); @@ -352,4 +358,28 @@ mod tests { fn print_tools_empty_no_panic() { print_tools(&[]); } + + #[test] + fn print_resource_templates_empty_no_panic() { + print_resource_templates(&[]); + } + + #[test] + fn print_resource_templates_with_entries_no_panic() { + let templates = vec![ + McpResourceTemplate { + uri_template: "weather://{location}".to_string(), + name: "Weather".to_string(), + mime_type: "text/plain".to_string(), + description: "Weather data".to_string(), + }, + McpResourceTemplate { + uri_template: "file://{path}".to_string(), + name: String::new(), + mime_type: String::new(), + description: String::new(), + }, + ]; + print_resource_templates(&templates); + } } diff --git a/src/protocol/client.rs b/src/protocol/client.rs index fe4b1b6..3e4f8f3 100644 --- a/src/protocol/client.rs +++ b/src/protocol/client.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use crate::protocol::{ JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, JsonRpcServerRequest, McpPrompt, - McpPromptMessage, McpResource, McpTool, Notification, ServerCapabilities, + McpPromptMessage, McpResource, McpResourceTemplate, McpTool, Notification, ServerCapabilities, }; pub struct McpClient { @@ -253,6 +253,15 @@ impl McpClient { Ok(serde_json::from_value(resources)?) } + pub async fn list_resource_templates(&self) -> Result> { + let result = self.send_request("resources/templates/list", None).await?; + let templates = result + .get("resourceTemplates") + .cloned() + .unwrap_or(json!([])); + Ok(serde_json::from_value(templates)?) + } + pub async fn read_resource(&self, uri: &str) -> Result { let params = json!({ "uri": uri }); self.send_request("resources/read", Some(params)).await diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 3247ceb..cb30b77 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -68,6 +68,18 @@ pub struct McpResource { pub description: String, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct McpResourceTemplate { + #[serde(rename = "uriTemplate")] + pub uri_template: String, + #[serde(default)] + pub name: String, + #[serde(rename = "mimeType", default)] + pub mime_type: String, + #[serde(default)] + pub description: String, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct McpPromptArgument { pub name: String, @@ -170,6 +182,30 @@ impl Notification { pub mod client; +#[cfg(test)] +mod template_tests { + use super::*; + + #[test] + fn mcp_resource_template_deserialization() { + let json_str = r#"{"uriTemplate":"weather://{location}","name":"Weather","mimeType":"text/plain","description":"Weather data"}"#; + let t: McpResourceTemplate = serde_json::from_str(json_str).unwrap(); + assert_eq!(t.uri_template, "weather://{location}"); + assert_eq!(t.name, "Weather"); + assert_eq!(t.mime_type, "text/plain"); + } + + #[test] + fn mcp_resource_template_defaults() { + let json_str = r#"{"uriTemplate":"foo://{id}"}"#; + let t: McpResourceTemplate = serde_json::from_str(json_str).unwrap(); + assert_eq!(t.uri_template, "foo://{id}"); + assert!(t.name.is_empty()); + assert!(t.mime_type.is_empty()); + assert!(t.description.is_empty()); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/client_mock.rs b/tests/client_mock.rs index 13d40d0..b73c916 100644 --- a/tests/client_mock.rs +++ b/tests/client_mock.rs @@ -1,6 +1,6 @@ mod helpers; -use mcpi::protocol::{McpTool, Notification}; +use mcpi::protocol::{McpResourceTemplate, McpTool, Notification}; use serde_json::json; use tokio::time::Duration; @@ -257,6 +257,59 @@ async fn list_resources_parses_response() { assert_eq!(resources[0].mime_type, "text/plain"); } +#[tokio::test] +async fn list_resource_templates_parses_response() { + let (client, mut server, _notif_rx) = helpers::make_client_with_mock(); + + tokio::spawn(async move { + respond_with( + &mut server, + json!({ + "resourceTemplates": [{ + "uriTemplate": "weather://{location}", + "name": "Weather", + "mimeType": "text/plain", + "description": "Weather data for a location" + }] + }), + ) + .await; + }); + + let templates: Vec = client.list_resource_templates().await.unwrap(); + assert_eq!(templates.len(), 1); + assert_eq!(templates[0].uri_template, "weather://{location}"); + assert_eq!(templates[0].name, "Weather"); + assert_eq!(templates[0].mime_type, "text/plain"); +} + +#[tokio::test] +async fn list_resource_templates_empty_response() { + let (client, mut server, _notif_rx) = helpers::make_client_with_mock(); + + tokio::spawn(async move { + respond_with(&mut server, json!({"resourceTemplates": []})).await; + }); + + let templates = client.list_resource_templates().await.unwrap(); + assert!(templates.is_empty()); +} + +#[tokio::test] +async fn list_resource_templates_sends_correct_method() { + let (client, mut server, _notif_rx) = helpers::make_client_with_mock(); + + let capture = + tokio::spawn( + async move { respond_with(&mut server, json!({"resourceTemplates": []})).await }, + ); + + client.list_resource_templates().await.unwrap(); + + let request = capture.await.unwrap(); + assert_eq!(request["method"], "resources/templates/list"); +} + #[tokio::test] async fn initialize_sends_initialized_notification() { let (client, mut server, _notif_rx) = helpers::make_client_with_mock();