tftsr-devops_investigation/src-tauri/src/mcp/transport/http.rs
Shaun Arman 54fe6229d6 feat: full copy from apollo_nxt-trcaa with complete sanitization
Complete backport of all features from apollo_nxt-trcaa repository:
- Three-tier shell execution safety system (Tier 1: auto, Tier 2: approve, Tier 3: deny)
- Ollama function calling with tool use support
- AI provider tool calling auto-detection
- kubectl binary bundling and management
- kubeconfig upload and context management
- Shell approval modal with real-time UI
- MCP protocol HTTP transport with custom headers
- Enhanced security audit logging
- Comprehensive test coverage (275+ tests)
- Updated CI/CD workflows for Gitea Actions
- Complete documentation (ADRs, wiki, release notes)

Sanitization applied to all files:
- Removed all MSI, Motorola, VNXT, Vesta references
- Replaced internal infrastructure references with TFTSR equivalents
- Updated all URLs and API endpoints
- Sanitized commit history references in documentation

Technical changes:
- New modules: shell/classifier, shell/executor, shell/kubectl, shell/kubeconfig
- Enhanced AI providers: ollama.rs, openai.rs with function calling
- New Tauri commands: shell execution, kubeconfig management, tool calling detection
- Database migrations: shell_execution_audit table
- Frontend: ShellApprovalModal, ShellExecution, KubeconfigManager pages
- CI/CD: kubectl bundling, multi-platform builds, Gitea Actions integration

Version: 1.0.8

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-05 14:11:00 -05:00

286 lines
10 KiB
Rust

use http::{HeaderName, HeaderValue};
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use rmcp::transport::StreamableHttpClientTransport;
use std::collections::HashMap;
use std::sync::Arc;
/// Parse and validate custom headers for MCP transport.
/// Returns a HashMap of validated HTTP headers ready for use in transport config.
///
/// Invalid headers (bad names or values) are logged and skipped.
/// Reserved headers (accept, mcp-session-id, etc.) are rejected by rmcp and should not be provided.
fn build_header_map(custom_headers: HashMap<String, String>) -> HashMap<HeaderName, HeaderValue> {
let mut http_headers = HashMap::new();
// Add custom headers from caller
for (key, value) in custom_headers.iter() {
let name_result = HeaderName::from_bytes(key.as_bytes());
let value_result = HeaderValue::from_str(value);
match (name_result, value_result) {
(Ok(name), Ok(val)) => {
// Skip reserved headers - rmcp manages these internally
if name.as_str().eq_ignore_ascii_case("accept")
|| name.as_str().eq_ignore_ascii_case("mcp-session-id")
|| name.as_str().eq_ignore_ascii_case("last-event-id")
{
tracing::warn!(
header_name = %name,
"Header is reserved by rmcp, skipping (rmcp manages it automatically)"
);
continue;
}
tracing::debug!(header_name = %name, "Added custom header");
http_headers.insert(name, val);
}
(Err(name_err), _) => {
tracing::warn!(
error = %name_err,
"Invalid header name, skipping (value: <redacted>)"
);
}
(Ok(name), Err(value_err)) => {
tracing::warn!(
header_name = %name,
error = %value_err,
"Invalid header value, skipping (value: <redacted>)"
);
}
}
}
// NOTE: Do NOT add Accept header here - rmcp automatically sends:
// "Accept: text/event-stream, application/json" which is what MCP servers need.
http_headers
}
/// Build an HTTP (Streamable HTTP) transport from a URL with optional custom headers.
/// Optionally attaches an Authorization bearer token.
///
/// Custom headers are now fully supported via rmcp's `.custom_headers()` method.
pub fn build_http_transport(
url: &str,
auth_header: Option<&str>,
custom_headers: HashMap<String, String>,
) -> impl rmcp::transport::Transport<rmcp::RoleClient> {
let http_headers = build_header_map(custom_headers);
// Build config with auth header and custom headers
let mut config = StreamableHttpClientTransportConfig::with_uri(Arc::from(url));
if let Some(token) = auth_header {
config = config.auth_header(token.to_string());
}
config = config.custom_headers(http_headers);
StreamableHttpClientTransport::from_config(config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_headers_returns_empty_map() {
let headers = HashMap::new();
let result = build_header_map(headers);
// rmcp handles Accept header automatically, so our map should be empty
assert_eq!(result.len(), 0, "Should not add any headers");
}
#[test]
fn test_rejects_reserved_accept_header() {
let mut headers = HashMap::new();
headers.insert("Accept".to_string(), "application/json".to_string());
let result = build_header_map(headers);
// Accept is reserved - should be rejected
assert_eq!(result.len(), 0, "Reserved headers should be rejected");
assert!(!result.contains_key(&HeaderName::from_static("accept")));
}
#[test]
fn test_rejects_reserved_session_id_header() {
let mut headers = HashMap::new();
headers.insert("Mcp-Session-Id".to_string(), "test123".to_string());
let result = build_header_map(headers);
// Session ID is reserved
assert_eq!(result.len(), 0);
}
#[test]
fn test_rejects_reserved_last_event_id_header() {
let mut headers = HashMap::new();
headers.insert("Last-Event-Id".to_string(), "123".to_string());
let result = build_header_map(headers);
// Last-Event-Id is reserved
assert_eq!(result.len(), 0);
}
#[test]
fn test_adds_valid_custom_header() {
let mut headers = HashMap::new();
headers.insert("X-Custom-Header".to_string(), "custom-value".to_string());
let result = build_header_map(headers);
let custom = HeaderName::from_static("x-custom-header");
assert!(result.contains_key(&custom));
assert_eq!(result.get(&custom).unwrap(), "custom-value");
}
#[test]
fn test_adds_multiple_custom_headers() {
let mut headers = HashMap::new();
headers.insert("X-Header-One".to_string(), "value1".to_string());
headers.insert("X-Header-Two".to_string(), "value2".to_string());
headers.insert("X-Header-Three".to_string(), "value3".to_string());
let result = build_header_map(headers);
// Should have exactly 3 custom headers
assert_eq!(result.len(), 3);
assert!(result.contains_key(&HeaderName::from_static("x-header-one")));
assert!(result.contains_key(&HeaderName::from_static("x-header-two")));
assert!(result.contains_key(&HeaderName::from_static("x-header-three")));
}
#[test]
fn test_skips_invalid_header_name() {
let mut headers = HashMap::new();
headers.insert("Invalid Header Name".to_string(), "value".to_string()); // spaces invalid
headers.insert("Valid-Header".to_string(), "valid".to_string());
let result = build_header_map(headers);
// Should have only valid header, invalid is skipped
assert_eq!(result.len(), 1);
assert!(result.contains_key(&HeaderName::from_static("valid-header")));
}
#[test]
fn test_skips_invalid_header_value() {
let mut headers = HashMap::new();
headers.insert("X-Valid-Name".to_string(), "invalid\nvalue".to_string()); // newline invalid
headers.insert("X-Another".to_string(), "valid".to_string());
let result = build_header_map(headers);
// Should have only valid header
assert_eq!(result.len(), 1);
assert!(result.contains_key(&HeaderName::from_static("x-another")));
assert_eq!(
result.get(&HeaderName::from_static("x-another")).unwrap(),
"valid"
);
}
#[test]
fn test_skips_header_with_null_byte_in_name() {
let mut headers = HashMap::new();
headers.insert("X-Bad\0Header".to_string(), "value".to_string());
headers.insert("X-Good-Header".to_string(), "value".to_string());
let result = build_header_map(headers);
// Should have only good header
assert_eq!(result.len(), 1);
assert!(result.contains_key(&HeaderName::from_static("x-good-header")));
}
#[test]
fn test_skips_header_with_null_byte_in_value() {
let mut headers = HashMap::new();
headers.insert("X-Header".to_string(), "bad\0value".to_string());
headers.insert("X-Good".to_string(), "goodvalue".to_string());
let result = build_header_map(headers);
// Should have only good header
assert_eq!(result.len(), 1);
assert!(result.contains_key(&HeaderName::from_static("x-good")));
}
#[test]
fn test_empty_string_value_allowed() {
let mut headers = HashMap::new();
headers.insert("X-Empty-Value".to_string(), "".to_string());
let result = build_header_map(headers);
// Empty string is valid
assert!(result.contains_key(&HeaderName::from_static("x-empty-value")));
assert_eq!(
result
.get(&HeaderName::from_static("x-empty-value"))
.unwrap(),
""
);
}
#[test]
fn test_unicode_in_header_value_accepted() {
let mut headers = HashMap::new();
headers.insert("X-Unicode".to_string(), "café".to_string()); // UTF-8 is valid in HTTP header values
headers.insert("X-Valid".to_string(), "ascii".to_string());
let result = build_header_map(headers);
// HeaderValue accepts valid UTF-8
assert_eq!(result.len(), 2); // unicode + valid
assert!(result.contains_key(&HeaderName::from_static("x-valid")));
assert!(result.contains_key(&HeaderName::from_static("x-unicode")));
}
#[test]
fn test_reserved_accept_header_rejected() {
let mut headers = HashMap::new();
headers.insert("X-Custom".to_string(), "value".to_string());
// Try to override Accept - should be rejected as reserved
headers.insert(
"Accept".to_string(),
"application/json, text/event-stream".to_string(),
);
let result = build_header_map(headers);
// Should have only custom header, Accept is reserved by rmcp
assert_eq!(result.len(), 1);
assert!(result.contains_key(&HeaderName::from_static("x-custom")));
assert!(!result.contains_key(&HeaderName::from_static("accept")));
}
// Transport building tests (verify no panics with Tokio runtime)
#[test]
fn test_builds_transport_with_http() {
let rt = tokio::runtime::Runtime::new().unwrap();
let _guard = rt.enter();
let _transport = build_http_transport("http://localhost:8080", None, HashMap::new());
}
#[test]
fn test_builds_transport_with_https() {
let rt = tokio::runtime::Runtime::new().unwrap();
let _guard = rt.enter();
let _transport = build_http_transport("https://example.com/mcp", None, HashMap::new());
}
#[test]
fn test_builds_transport_with_auth() {
let rt = tokio::runtime::Runtime::new().unwrap();
let _guard = rt.enter();
let _transport = build_http_transport(
"http://localhost:8080",
Some("Bearer token123"),
HashMap::new(),
);
}
}