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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
27 changes: 22 additions & 5 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
Expand Down Expand Up @@ -93,8 +93,14 @@ async fn cmd_connect(state: &mut ReplState, args: &[String]) -> Result<()> {
}
{
let resources: Vec<McpResource> = client.list_resources().await.unwrap_or_default();
let templates: Vec<McpResourceTemplate> =
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<McpPrompt> = client.list_prompts().await.unwrap_or_default();
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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]) {
Expand Down Expand Up @@ -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 <uri>");
println!();
println!("{}", "Prompts:".yellow().bold());
Expand Down
102 changes: 66 additions & 36 deletions src/display.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
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() {
println!("{}", "No tools available.".yellow());
return;
}
let mut table = Table::new();
table.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec![
Cell::new("Name")
.add_attribute(Attribute::Bold)
Expand All @@ -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),
]);
}
Expand All @@ -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)
Expand All @@ -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}");
Expand All @@ -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)
Expand All @@ -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(", ")),
]);
}
Expand Down Expand Up @@ -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);
}
Expand All @@ -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": {}}});
Expand All @@ -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);
}
}
11 changes: 10 additions & 1 deletion src/protocol/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -253,6 +253,15 @@ impl McpClient {
Ok(serde_json::from_value(resources)?)
}

pub async fn list_resource_templates(&self) -> Result<Vec<McpResourceTemplate>> {
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<Value> {
let params = json!({ "uri": uri });
self.send_request("resources/read", Some(params)).await
Expand Down
36 changes: 36 additions & 0 deletions src/protocol/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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::*;
Expand Down
Loading
Loading