From 958c7bf261a404a7df99e394997ab10e724cfca7 Mon Sep 17 00:00:00 2001 From: main Date: Thu, 19 Mar 2026 17:16:47 -0400 Subject: Sharpen MCP concise output surfaces --- crates/fidget-spinner-cli/src/mcp/output.rs | 171 ++++++++++++++++++++++------ 1 file changed, 136 insertions(+), 35 deletions(-) (limited to 'crates/fidget-spinner-cli/src/mcp/output.rs') diff --git a/crates/fidget-spinner-cli/src/mcp/output.rs b/crates/fidget-spinner-cli/src/mcp/output.rs index 58f7eb4..01b7bc1 100644 --- a/crates/fidget-spinner-cli/src/mcp/output.rs +++ b/crates/fidget-spinner-cli/src/mcp/output.rs @@ -1,16 +1,72 @@ -use libmcp::{JsonPorcelainConfig, RenderMode, render_json_porcelain}; +use libmcp::{ + DetailLevel, JsonPorcelainConfig, RenderMode, render_json_porcelain, + with_presentation_properties, +}; use serde::Serialize; -use serde_json::{Map, Value, json}; +use serde_json::{Value, json}; use crate::mcp::fault::{FaultKind, FaultRecord, FaultStage}; -pub(crate) fn split_render_mode( +const CONCISE_PORCELAIN_MAX_LINES: usize = 12; +const CONCISE_PORCELAIN_MAX_INLINE_CHARS: usize = 160; +const FULL_PORCELAIN_MAX_LINES: usize = 40; +const FULL_PORCELAIN_MAX_INLINE_CHARS: usize = 512; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Presentation { + pub render: RenderMode, + pub detail: DetailLevel, +} + +#[derive(Debug, Clone)] +pub(crate) struct ToolOutput { + concise: Value, + full: Value, + concise_text: String, + full_text: Option, +} + +impl ToolOutput { + #[must_use] + pub(crate) fn from_values( + concise: Value, + full: Value, + concise_text: impl Into, + full_text: Option, + ) -> Self { + Self { + concise, + full, + concise_text: concise_text.into(), + full_text, + } + } + + fn structured(&self, detail: DetailLevel) -> &Value { + match detail { + DetailLevel::Concise => &self.concise, + DetailLevel::Full => &self.full, + } + } + + fn porcelain_text(&self, detail: DetailLevel) -> String { + match detail { + DetailLevel::Concise => self.concise_text.clone(), + DetailLevel::Full => self + .full_text + .clone() + .unwrap_or_else(|| render_json_porcelain(&self.full, full_porcelain_config())), + } + } +} + +pub(crate) fn split_presentation( arguments: Value, operation: &str, stage: FaultStage, -) -> Result<(RenderMode, Value), FaultRecord> { +) -> Result<(Presentation, Value), FaultRecord> { let Value::Object(mut object) = arguments else { - return Ok((RenderMode::Porcelain, arguments)); + return Ok((Presentation::default(), arguments)); }; let render = object .remove("render") @@ -26,29 +82,71 @@ pub(crate) fn split_render_mode( }) .transpose()? .unwrap_or(RenderMode::Porcelain); - Ok((render, Value::Object(object))) + let detail = object + .remove("detail") + .map(|value| { + serde_json::from_value::(value).map_err(|error| { + FaultRecord::new( + FaultKind::InvalidInput, + stage, + operation, + format!("invalid detail level: {error}"), + ) + }) + }) + .transpose()? + .unwrap_or(DetailLevel::Concise); + Ok((Presentation { render, detail }, Value::Object(object))) } -pub(crate) fn tool_success( +pub(crate) fn tool_output( value: &impl Serialize, - render: RenderMode, stage: FaultStage, operation: &str, -) -> Result { +) -> Result { let structured = serde_json::to_value(value).map_err(|error| { FaultRecord::new(FaultKind::Internal, stage, operation, error.to_string()) })?; - tool_success_from_value(structured, render, stage, operation) + let concise_text = render_json_porcelain(&structured, concise_porcelain_config()); + Ok(ToolOutput::from_values( + structured.clone(), + structured, + concise_text, + None, + )) +} + +pub(crate) fn detailed_tool_output( + concise: &impl Serialize, + full: &impl Serialize, + concise_text: impl Into, + full_text: Option, + stage: FaultStage, + operation: &str, +) -> Result { + let concise = serde_json::to_value(concise).map_err(|error| { + FaultRecord::new(FaultKind::Internal, stage, operation, error.to_string()) + })?; + let full = serde_json::to_value(full).map_err(|error| { + FaultRecord::new(FaultKind::Internal, stage, operation, error.to_string()) + })?; + Ok(ToolOutput::from_values( + concise, + full, + concise_text, + full_text, + )) } -pub(crate) fn tool_success_from_value( - structured: Value, - render: RenderMode, +pub(crate) fn tool_success( + output: ToolOutput, + presentation: Presentation, stage: FaultStage, operation: &str, ) -> Result { - let text = match render { - RenderMode::Porcelain => render_json_porcelain(&structured, JsonPorcelainConfig::default()), + let structured = output.structured(presentation.detail).clone(); + let text = match presentation.render { + RenderMode::Porcelain => output.porcelain_text(presentation.detail), RenderMode::Json => crate::to_pretty_json(&structured).map_err(|error| { FaultRecord::new(FaultKind::Internal, stage, operation, error.to_string()) })?, @@ -63,26 +161,29 @@ pub(crate) fn tool_success_from_value( })) } -pub(crate) fn with_render_property(schema: Value) -> Value { - let Value::Object(mut object) = schema else { - return schema; - }; +pub(crate) fn with_common_presentation(schema: Value) -> Value { + with_presentation_properties(schema) +} + +const fn concise_porcelain_config() -> JsonPorcelainConfig { + JsonPorcelainConfig { + max_lines: CONCISE_PORCELAIN_MAX_LINES, + max_inline_chars: CONCISE_PORCELAIN_MAX_INLINE_CHARS, + } +} + +const fn full_porcelain_config() -> JsonPorcelainConfig { + JsonPorcelainConfig { + max_lines: FULL_PORCELAIN_MAX_LINES, + max_inline_chars: FULL_PORCELAIN_MAX_INLINE_CHARS, + } +} - let properties = object - .entry("properties".to_owned()) - .or_insert_with(|| Value::Object(Map::new())); - if let Value::Object(properties) = properties { - let _ = properties.insert( - "render".to_owned(), - json!({ - "type": "string", - "enum": ["porcelain", "json"], - "description": "Output mode. Defaults to porcelain for model-friendly summaries." - }), - ); +impl Default for Presentation { + fn default() -> Self { + Self { + render: RenderMode::Porcelain, + detail: DetailLevel::Concise, + } } - let _ = object - .entry("additionalProperties".to_owned()) - .or_insert(Value::Bool(false)); - Value::Object(object) } -- cgit v1.2.3