Skip to main content

lash_plugin_mcp/
config.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::McpError;
8
9const DEFAULT_STARTUP_TIMEOUT_MS: u64 = 10_000;
10const DEFAULT_CALL_TIMEOUT_MS: u64 = 60_000;
11
12fn default_startup_timeout_ms() -> u64 {
13    DEFAULT_STARTUP_TIMEOUT_MS
14}
15
16fn default_call_timeout_ms() -> u64 {
17    DEFAULT_CALL_TIMEOUT_MS
18}
19
20fn is_default_startup_timeout_ms(value: &u64) -> bool {
21    *value == DEFAULT_STARTUP_TIMEOUT_MS
22}
23
24fn is_default_call_timeout_ms(value: &u64) -> bool {
25    *value == DEFAULT_CALL_TIMEOUT_MS
26}
27
28/// Connection configuration for one MCP server. Tag (`transport`) selects
29/// the wire transport; per-variant fields configure that transport.
30#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
31#[serde(tag = "transport", rename_all = "snake_case")]
32pub enum McpServerConfig {
33    /// Spawn a child process and speak JSON-RPC over stdio.
34    Stdio {
35        command: String,
36        #[serde(default, skip_serializing_if = "Vec::is_empty")]
37        args: Vec<String>,
38        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
39        env: BTreeMap<String, String>,
40        #[serde(default, skip_serializing_if = "Option::is_none")]
41        cwd: Option<PathBuf>,
42        #[serde(
43            default = "default_startup_timeout_ms",
44            skip_serializing_if = "is_default_startup_timeout_ms"
45        )]
46        startup_timeout_ms: u64,
47        #[serde(
48            default = "default_call_timeout_ms",
49            skip_serializing_if = "is_default_call_timeout_ms"
50        )]
51        call_timeout_ms: u64,
52    },
53    /// Newer MCP spec HTTP/JSON streaming transport.
54    StreamableHttp {
55        url: String,
56        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
57        headers: BTreeMap<String, String>,
58        #[serde(
59            default = "default_startup_timeout_ms",
60            skip_serializing_if = "is_default_startup_timeout_ms"
61        )]
62        startup_timeout_ms: u64,
63        #[serde(
64            default = "default_call_timeout_ms",
65            skip_serializing_if = "is_default_call_timeout_ms"
66        )]
67        call_timeout_ms: u64,
68    },
69    /// Older MCP spec HTTP+SSE transport.
70    Sse {
71        url: String,
72        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
73        headers: BTreeMap<String, String>,
74        #[serde(
75            default = "default_startup_timeout_ms",
76            skip_serializing_if = "is_default_startup_timeout_ms"
77        )]
78        startup_timeout_ms: u64,
79        #[serde(
80            default = "default_call_timeout_ms",
81            skip_serializing_if = "is_default_call_timeout_ms"
82        )]
83        call_timeout_ms: u64,
84    },
85}
86
87impl McpServerConfig {
88    /// Convenience constructor for stdio servers.
89    pub fn stdio(command: impl Into<String>, args: Vec<String>) -> Self {
90        Self::Stdio {
91            command: command.into(),
92            args,
93            env: BTreeMap::new(),
94            cwd: None,
95            startup_timeout_ms: default_startup_timeout_ms(),
96            call_timeout_ms: default_call_timeout_ms(),
97        }
98    }
99
100    /// Convenience constructor for streamable-HTTP servers.
101    pub fn streamable_http(url: impl Into<String>) -> Self {
102        Self::StreamableHttp {
103            url: url.into(),
104            headers: BTreeMap::new(),
105            startup_timeout_ms: default_startup_timeout_ms(),
106            call_timeout_ms: default_call_timeout_ms(),
107        }
108    }
109
110    /// Convenience constructor for SSE servers.
111    pub fn sse(url: impl Into<String>) -> Self {
112        Self::Sse {
113            url: url.into(),
114            headers: BTreeMap::new(),
115            startup_timeout_ms: default_startup_timeout_ms(),
116            call_timeout_ms: default_call_timeout_ms(),
117        }
118    }
119
120    pub fn startup_timeout(&self) -> Duration {
121        Duration::from_millis(match self {
122            Self::Stdio {
123                startup_timeout_ms, ..
124            }
125            | Self::StreamableHttp {
126                startup_timeout_ms, ..
127            }
128            | Self::Sse {
129                startup_timeout_ms, ..
130            } => *startup_timeout_ms,
131        })
132    }
133
134    pub fn call_timeout(&self) -> Duration {
135        Duration::from_millis(match self {
136            Self::Stdio {
137                call_timeout_ms, ..
138            }
139            | Self::StreamableHttp {
140                call_timeout_ms, ..
141            }
142            | Self::Sse {
143                call_timeout_ms, ..
144            } => *call_timeout_ms,
145        })
146    }
147
148    pub(crate) fn validate(&self, server_name: &str) -> Result<(), McpError> {
149        if server_name.trim().is_empty() {
150            return Err(McpError::Config(
151                "MCP server name cannot be empty".to_string(),
152            ));
153        }
154        if server_name.contains("__") {
155            return Err(McpError::Config(format!(
156                "MCP server `{server_name}` cannot contain `__`"
157            )));
158        }
159        match self {
160            Self::Stdio { command, .. } if command.trim().is_empty() => Err(McpError::Config(
161                format!("MCP server `{server_name}` command cannot be empty"),
162            )),
163            Self::StreamableHttp { url, .. } | Self::Sse { url, .. } if url.trim().is_empty() => {
164                Err(McpError::Config(format!(
165                    "MCP server `{server_name}` URL cannot be empty"
166                )))
167            }
168            _ => Ok(()),
169        }
170    }
171}