mcpway 0.2.0

Run MCP stdio servers over SSE, WebSocket, Streamable HTTP, and gRPC transports.
Documentation
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::field::{Field, Visit};
use tracing::{Event, Subscriber};
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::registry::LookupSpan;

const MAX_LOG_FILE_BYTES: u64 = 20 * 1024 * 1024;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredLogRecord {
    pub ts_utc: u64,
    pub level: String,
    pub target: String,
    pub message: String,
    pub mode: String,
    pub transport: String,
    #[serde(default)]
    pub fields: BTreeMap<String, String>,
}

pub struct LogFileLayer {
    writer: Arc<Mutex<BufWriter<File>>>,
    mode: String,
    transport: String,
}

pub fn default_log_path() -> PathBuf {
    if let Some(path) = std::env::var_os("MCPWAY_LOG_PATH") {
        return PathBuf::from(path);
    }
    if let Some(home) = crate::discovery::user_home_dir() {
        return home.join(".mcpway").join("logs").join("mcpway.ndjson");
    }
    PathBuf::from(".mcpway/logs/mcpway.ndjson")
}

pub fn ensure_log_file(path: &Path) -> Result<(), String> {
    let _ = prepare_log_file(path)?;
    Ok(())
}

pub fn build_log_file_layer(mode: &str, transport: &str) -> Result<LogFileLayer, String> {
    let path = default_log_path();
    let file = prepare_log_file(&path)?;
    Ok(LogFileLayer {
        writer: Arc::new(Mutex::new(BufWriter::new(file))),
        mode: mode.to_string(),
        transport: transport.to_string(),
    })
}

pub fn parse_record(line: &str) -> Option<StoredLogRecord> {
    serde_json::from_str::<StoredLogRecord>(line).ok()
}

fn prepare_log_file(path: &Path) -> Result<File, String> {
    let parent = path
        .parent()
        .ok_or_else(|| format!("Invalid log file path: {}", path.display()))?;
    std::fs::create_dir_all(parent)
        .map_err(|err| format!("Failed to create {}: {err}", parent.display()))?;

    if let Ok(meta) = std::fs::metadata(path) {
        if meta.len() > MAX_LOG_FILE_BYTES {
            OpenOptions::new()
                .create(true)
                .write(true)
                .truncate(true)
                .open(path)
                .map_err(|err| format!("Failed to truncate {}: {err}", path.display()))?;
        }
    }

    OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .map_err(|err| format!("Failed to open {}: {err}", path.display()))
}

fn unix_timestamp_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_secs())
        .unwrap_or(0)
}

#[derive(Default)]
struct EventVisitor {
    message: Option<String>,
    fields: BTreeMap<String, String>,
}

impl Visit for EventVisitor {
    fn record_str(&mut self, field: &Field, value: &str) {
        if field.name() == "message" {
            self.message = Some(value.to_string());
        } else {
            self.fields
                .insert(field.name().to_string(), value.to_string());
        }
    }

    fn record_bool(&mut self, field: &Field, value: bool) {
        self.fields
            .insert(field.name().to_string(), value.to_string());
    }

    fn record_i64(&mut self, field: &Field, value: i64) {
        self.fields
            .insert(field.name().to_string(), value.to_string());
    }

    fn record_u64(&mut self, field: &Field, value: u64) {
        self.fields
            .insert(field.name().to_string(), value.to_string());
    }

    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
        let rendered = format!("{value:?}");
        if field.name() == "message" {
            self.message = Some(rendered.clone());
        }
        self.fields.insert(field.name().to_string(), rendered);
    }
}

impl<S> Layer<S> for LogFileLayer
where
    S: Subscriber + for<'lookup> LookupSpan<'lookup>,
{
    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
        let mut visitor = EventVisitor::default();
        event.record(&mut visitor);

        let metadata = event.metadata();
        let record = StoredLogRecord {
            ts_utc: unix_timestamp_secs(),
            level: metadata.level().as_str().to_ascii_lowercase(),
            target: metadata.target().to_string(),
            message: visitor.message.unwrap_or_default(),
            mode: self.mode.clone(),
            transport: self.transport.clone(),
            fields: visitor.fields,
        };

        let Ok(line) = serde_json::to_string(&record) else {
            return;
        };

        let Ok(mut writer) = self.writer.lock() else {
            return;
        };
        if writeln!(&mut *writer, "{line}").is_ok() {
            let _ = writer.flush();
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_record_roundtrip() {
        let line = serde_json::to_string(&StoredLogRecord {
            ts_utc: 123,
            level: "info".to_string(),
            target: "test".to_string(),
            message: "hello".to_string(),
            mode: "connect".to_string(),
            transport: "sse".to_string(),
            fields: BTreeMap::new(),
        })
        .expect("serialize");

        let parsed = parse_record(&line).expect("parse record");
        assert_eq!(parsed.level, "info");
        assert_eq!(parsed.transport, "sse");
    }
}