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
31 changes: 31 additions & 0 deletions libs/mcp/proxy/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,20 @@ impl ProxyClientHandler {
}
}

fn progress_notification_for_forwarding(
notification: ProgressNotificationParam,
) -> ProgressNotificationParam {
notification
}

impl ClientHandler for ProxyClientHandler {
async fn on_progress(
&self,
notification: ProgressNotificationParam,
_ctx: NotificationContext<RoleClient>,
) {
let notification = progress_notification_for_forwarding(notification);

// Then forward progress notification from upstream server to downstream server
let peer = self.downstream_peer.lock().await;
if let Some(ref peer) = *peer {
Expand Down Expand Up @@ -282,6 +290,7 @@ fn substitute_env_vars(s: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use rmcp::model::{NumberOrString, ProgressToken};
use std::env;
use std::sync::Mutex;

Expand Down Expand Up @@ -318,6 +327,28 @@ mod tests {
}
}

#[test]
fn progress_notification_forwarding_preserves_large_message_without_artifacting() {
let large_message = "progress line\n".repeat(400);
let notification = ProgressNotificationParam {
progress_token: ProgressToken(NumberOrString::Number(0)),
progress: 50.0,
total: None,
message: Some(large_message.clone()),
};

let forwarded = progress_notification_for_forwarding(notification);

assert_eq!(forwarded.message.as_deref(), Some(large_message.as_str()));
assert!(
!forwarded
.message
.as_deref()
.expect("message should be preserved")
.contains("Full output saved to ")
);
}

#[test]
fn test_substitute_no_vars() {
assert_eq!(substitute_env_vars("hello world"), "hello world");
Expand Down
183 changes: 183 additions & 0 deletions libs/mcp/proxy/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig
use stakpak_shared::cert_utils::CertificateChain;
use stakpak_shared::paths::stakpak_home_dir;
use stakpak_shared::secret_manager::SecretManager;
use stakpak_shared::utils::{LargeOutputLimits, handle_large_output_with_limits};
use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
Expand Down Expand Up @@ -126,6 +127,51 @@ fn restore_secrets_in_json_value(
}
}

const PROXY_LARGE_OUTPUT_MAX_LINES: usize = 300;
const PROXY_LARGE_OUTPUT_MAX_BYTES: usize = 64 * 1024;

fn artifact_final_tool_result_text(
mut result: CallToolResult,
client_name: &str,
tool_name: &str,
) -> CallToolResult {
let mut text_blocks = Vec::new();
let mut non_text_content = Vec::new();

for item in result.content {
if let Some(text_content) = item.raw.as_text() {
text_blocks.push(text_content.text.clone());
} else {
non_text_content.push(item);
}
}

if text_blocks.is_empty() {
result.content = non_text_content;
return result;
}

let flattened_text = text_blocks.join("\n");
let file_prefix = format!("tool-output.{}.{}", client_name, tool_name);
let processed_text = match handle_large_output_with_limits(
&flattened_text,
LargeOutputLimits {
file_prefix: &file_prefix,
max_lines: PROXY_LARGE_OUTPUT_MAX_LINES,
max_bytes: PROXY_LARGE_OUTPUT_MAX_BYTES,
show_head: false,
},
) {
Ok(text) => text,
Err(e) => format!("FAILED_TO_HANDLE_LARGE_OUTPUT: {}", e),
};

result.content = std::iter::once(Content::text(processed_text))
.chain(non_text_content)
.collect();
result
}

#[derive(Debug, Clone)]
struct RequestTracking {
client_name: String,
Expand Down Expand Up @@ -670,6 +716,8 @@ impl ServerHandler for ProxyServer {
.collect();
}

result = artifact_final_tool_result_text(result, &client_name, &tool_name);

Ok(result)
}

Expand Down Expand Up @@ -881,8 +929,40 @@ pub async fn start_proxy_server(
#[cfg(test)]
mod tests {
use super::*;
use rmcp::model::ResourceContents;
use serde_json::json;

fn text_content(content: &Content) -> &str {
content
.raw
.as_text()
.map(|text| text.text.as_str())
.expect("content should be text")
}

fn artifact_path_from_preview(preview: &str) -> &str {
preview
.lines()
.next()
.and_then(|line| line.split_once("Full output saved to "))
.map(|(_, path)| path)
.expect("preview should contain saved artifact path")
}

fn read_artifact_from_preview(preview: &str) -> String {
let artifact_path = artifact_path_from_preview(preview);
let artifact = std::fs::read_to_string(artifact_path).expect("artifact should be readable");
std::fs::remove_file(artifact_path).expect("artifact should be removable");
artifact
}

fn numbered_lines(prefix: &str, count: usize) -> String {
(1..=count)
.map(|line| format!("{prefix}-{line:03}"))
.collect::<Vec<_>>()
.join("\n")
}

/// Helper: build a redaction map from pairs
fn map(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
Expand All @@ -891,6 +971,109 @@ mod tests {
.collect()
}

#[test]
fn artifact_final_tool_result_previews_large_success_text() {
let output = numbered_lines("success", 301);
let result = CallToolResult::success(vec![Content::text(output.clone())]);

let processed = artifact_final_tool_result_text(result, "stakpak", "run_command");

assert_eq!(processed.is_error, Some(false));
assert_eq!(processed.content.len(), 1);
let preview = text_content(&processed.content[0]);
assert!(preview.starts_with("Showing the last 300 / 301 output lines."));
assert!(preview.contains("Full output saved to "));
assert!(!preview.contains("success-001"));
assert!(preview.contains("success-301"));

let artifact = read_artifact_from_preview(preview);
assert_eq!(artifact, output);
}

#[test]
fn artifact_final_tool_result_preserves_large_error_status() {
let output = numbered_lines("error", 301);
let result = CallToolResult::error(vec![Content::text(output.clone())]);

let processed = artifact_final_tool_result_text(result, "stakpak", "get_task_details");

assert_eq!(processed.is_error, Some(true));
assert_eq!(processed.content.len(), 1);
let preview = text_content(&processed.content[0]);
assert!(preview.starts_with("Showing the last 300 / 301 output lines."));

let artifact = read_artifact_from_preview(preview);
assert_eq!(artifact, output);
}

#[test]
fn artifact_final_tool_result_flattens_multiple_text_blocks_once() {
let first = numbered_lines("first", 200);
let second = numbered_lines("second", 200);
let flattened = format!("{first}\n{second}");
let result = CallToolResult::success(vec![
Content::text(first.clone()),
Content::text(second.clone()),
]);

let processed = artifact_final_tool_result_text(result, "docs", "search_docs");

assert_eq!(processed.content.len(), 1);
let preview = text_content(&processed.content[0]);
assert!(preview.starts_with("Showing the last 300 / 400 output lines."));

let artifact = read_artifact_from_preview(preview);
assert_eq!(artifact, flattened);
}

#[test]
fn artifact_final_tool_result_preserves_non_text_after_preview() {
let output = numbered_lines("image", 301);
let image = Content::image("abc123", "image/png");
let result = CallToolResult::success(vec![Content::text(output), image.clone()]);

let processed = artifact_final_tool_result_text(result, "vision", "inspect");

assert_eq!(processed.content.len(), 2);
assert!(text_content(&processed.content[0]).contains("Full output saved to "));
assert_eq!(processed.content[1], image);

let _ = read_artifact_from_preview(text_content(&processed.content[0]));
}

#[test]
fn artifact_final_tool_result_preserves_resource_after_small_text_normalization() {
let resource = Content::resource(ResourceContents::text("resource body", "file:///a.txt"));
let result = CallToolResult::success(vec![
Content::text("alpha"),
resource.clone(),
Content::text("beta"),
]);

let processed = artifact_final_tool_result_text(result, "files", "read_resource");

assert_eq!(processed.content.len(), 2);
assert_eq!(text_content(&processed.content[0]), "alpha\nbeta");
assert_eq!(processed.content[1], resource);
}

#[test]
fn artifact_final_tool_result_previews_large_single_line_by_bytes() {
let output = "x".repeat(65 * 1024);
let result = CallToolResult::success(vec![Content::text(output.clone())]);

let processed = artifact_final_tool_result_text(result, "json", "large_payload");

assert_eq!(processed.content.len(), 1);
let preview = text_content(&processed.content[0]);
assert!(preview.starts_with("Showing the last 65536 / 66560 output bytes."));
assert!(preview.contains("Full output saved to "));
assert!(preview.len() < output.len());

let artifact = read_artifact_from_preview(preview);
assert_eq!(artifact, output);
}

// ---------------------------------------------------------------
// restore_secrets_in_json_value — basic string restoration
// ---------------------------------------------------------------
Expand Down
46 changes: 4 additions & 42 deletions libs/mcp/server/src/local_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use stakpak_shared::models::integrations::openai::{
use stakpak_shared::task_manager::{StartTaskOptions, TaskInfo};
use stakpak_shared::tls_client::{TlsClientConfig, create_tls_client};
use stakpak_shared::utils::{
LocalFileSystemProvider, generate_directory_tree, handle_large_output, sanitize_text_output,
LocalFileSystemProvider, generate_directory_tree, sanitize_text_output,
};
use std::fs::{self};
use std::path::Path;
Expand Down Expand Up @@ -309,8 +309,6 @@ impl ToolContainer {
#[tool(
description = "Execute a shell command locally with full system access.

If the command's output exceeds 300 lines the result will be truncated and the full output will be saved to a file in the current directory.

For remote command execution via SSH, use the run_remote_command tool instead."
)]
pub async fn run_command(
Expand Down Expand Up @@ -338,8 +336,6 @@ REMOTE EXECUTION:
* 'user@server.com' (uses default port 22 and auto-discovered keys)
* 'user@server.com:2222' with password authentication

If the command's output exceeds 300 lines the result will be truncated and the full output will be saved to a file in the current directory.

For local command execution, use the run_command tool instead.")]
pub async fn run_remote_command(
&self,
Expand Down Expand Up @@ -702,8 +698,6 @@ This tool provides comprehensive details about a background task started with ru
- Complete command output
- Error information if the task failed

If the task output exceeds 300 lines the result will be truncated and the full output will be saved to a file in the current directory.

Use this tool to check the progress and results of long-running background tasks."
)]
pub async fn get_task_details(
Expand Down Expand Up @@ -760,16 +754,7 @@ Use this tool to check the progress and results of long-running background tasks
// Subagent output - use Display impl for LLM-friendly formatting
manifest.to_string()
} else {
// Regular task output - use standard handling
match handle_large_output(output, "task.output", 300, false) {
Ok(result) => result,
Err(e) => {
return Ok(CallToolResult::error(vec![
Content::text("OUTPUT_HANDLING_ERROR"),
Content::text(format!("Failed to handle task output: {}", e)),
]));
}
}
output.clone()
}
} else {
"No output available".to_string()
Expand Down Expand Up @@ -1018,9 +1003,7 @@ SECURITY FEATURES:
- Only allows HTTPS URLs for secure connections
- Follows redirects safely with limits

The tool fetches the HTML content from the specified URL and converts it to clean, readable markdown. This is useful for reading web articles, documentation, or any web content in a text-friendly format.

The response will be truncated if it exceeds 300 lines, with the full content saved to a local file."
The tool fetches the HTML content from the specified URL and converts it to clean, readable markdown. This is useful for reading web articles, documentation, or any web content in a text-friendly format."
)]
pub async fn view_web_page(
&self,
Expand Down Expand Up @@ -1097,17 +1080,7 @@ The response will be truncated if it exceeds 300 lines, with the full content sa
let markdown_content = html2md::rewrite_html(&html_content, false);
let sanitized_content = sanitize_text_output(&markdown_content);

let result = match handle_large_output(&sanitized_content, "webpage", 300, false) {
Ok(result) => result,
Err(e) => {
return Ok(CallToolResult::error(vec![
Content::text("OUTPUT_HANDLING_ERROR"),
Content::text(format!("Failed to handle output: {}", e)),
]));
}
};

let formatted_output = format!("# Web Page Content: {}\n\n{}", url, result);
let formatted_output = format!("# Web Page Content: {}\n\n{}", url, sanitized_content);

Ok(CallToolResult::success(vec![Content::text(
&formatted_output,
Expand Down Expand Up @@ -1232,17 +1205,6 @@ SAFETY NOTES:
fn format_command_result(
command_result: &mut CommandResult,
) -> Result<CallToolResult, McpError> {
command_result.output =
match handle_large_output(&command_result.output, "command.output", 300, false) {
Ok(result) => result,
Err(e) => {
return Ok(CallToolResult::error(vec![
Content::text("OUTPUT_HANDLING_ERROR"),
Content::text(format!("Failed to handle command output: {}", e)),
]));
}
};

if command_result.output.is_empty() {
return Ok(CallToolResult::success(vec![Content::text("No output")]));
}
Expand Down
Loading
Loading