1use std::collections::{HashMap, HashSet};
2#[cfg(feature = "mcp")]
3use std::env;
4use std::fs;
5use std::path::{Path, PathBuf};
6#[cfg(feature = "mcp")]
7use std::time::Duration;
8
9use anyhow::{Context, Result};
10#[cfg(feature = "mcp")]
11use greentic_mcp::{ExecConfig, RuntimePolicy, ToolStore, VerifyPolicy};
12use serde::Deserialize;
13use serde_yaml_bw as serde_yaml;
14
15#[derive(Debug, Clone)]
16pub struct HostConfig {
17 pub tenant: String,
18 pub bindings_path: PathBuf,
19 pub flow_type_bindings: HashMap<String, FlowBinding>,
20 pub mcp: McpConfig,
21 pub rate_limits: RateLimits,
22 pub http_enabled: bool,
23 pub secrets_policy: SecretsPolicy,
24 pub webhook_policy: WebhookPolicy,
25 pub timers: Vec<TimerBinding>,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29pub struct BindingsFile {
30 pub tenant: String,
31 #[serde(default)]
32 pub flow_type_bindings: HashMap<String, FlowBinding>,
33 pub mcp: McpConfig,
34 #[serde(default)]
35 pub rate_limits: RateLimits,
36 #[serde(default)]
37 pub timers: Vec<TimerBinding>,
38}
39
40#[derive(Debug, Clone, Deserialize)]
41pub struct FlowBinding {
42 pub adapter: String,
43 #[serde(default)]
44 pub config: serde_yaml::Value,
45 #[serde(default)]
46 pub secrets: Vec<String>,
47}
48
49#[derive(Debug, Clone, Deserialize)]
50pub struct McpConfig {
51 pub store: serde_yaml::Value,
52 #[serde(default)]
53 pub security: serde_yaml::Value,
54 #[serde(default)]
55 pub runtime: serde_yaml::Value,
56 #[serde(default)]
57 pub http_enabled: Option<bool>,
58 #[serde(default)]
59 pub retry: Option<McpRetryConfig>,
60}
61
62#[derive(Debug, Clone, Deserialize)]
63pub struct RateLimits {
64 #[serde(default = "default_messaging_qps")]
65 pub messaging_send_qps: u32,
66 #[serde(default = "default_messaging_burst")]
67 pub messaging_burst: u32,
68}
69
70#[derive(Debug, Clone)]
71pub struct SecretsPolicy {
72 allowed: HashSet<String>,
73 allow_all: bool,
74}
75
76#[derive(Debug, Clone, Deserialize)]
77pub struct McpRetryConfig {
78 #[serde(default = "default_mcp_retry_attempts")]
79 pub max_attempts: u32,
80 #[serde(default = "default_mcp_retry_base_delay_ms")]
81 pub base_delay_ms: u64,
82}
83
84#[derive(Debug, Clone, Default)]
85pub struct WebhookPolicy {
86 allow_paths: Vec<String>,
87 deny_paths: Vec<String>,
88}
89
90#[derive(Debug, Clone, Deserialize)]
91pub struct WebhookBindingConfig {
92 #[serde(default)]
93 pub allow_paths: Vec<String>,
94 #[serde(default)]
95 pub deny_paths: Vec<String>,
96}
97
98#[derive(Debug, Clone, Deserialize)]
99pub struct TimerBinding {
100 pub flow_id: String,
101 pub cron: String,
102 #[serde(default)]
103 pub schedule_id: Option<String>,
104}
105
106impl HostConfig {
107 pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
108 let path = path.as_ref();
109 let content = fs::read_to_string(path)
110 .with_context(|| format!("failed to read bindings file {path:?}"))?;
111 let bindings: BindingsFile = serde_yaml::from_str(&content)
112 .with_context(|| format!("failed to parse bindings file {path:?}"))?;
113
114 let secrets_policy = SecretsPolicy::from_bindings(&bindings);
115 let http_enabled = bindings
116 .mcp
117 .http_enabled
118 .unwrap_or(bindings.flow_type_bindings.contains_key("messaging"));
119 let webhook_policy = bindings
120 .flow_type_bindings
121 .get("webhook")
122 .and_then(|binding| {
123 serde_yaml::from_value::<WebhookBindingConfig>(binding.config.clone())
124 .map(WebhookPolicy::from)
125 .map_err(|err| {
126 tracing::warn!(error = %err, "failed to parse webhook binding config");
127 err
128 })
129 .ok()
130 })
131 .unwrap_or_default();
132
133 Ok(Self {
134 tenant: bindings.tenant.clone(),
135 bindings_path: path.to_path_buf(),
136 flow_type_bindings: bindings.flow_type_bindings.clone(),
137 mcp: bindings.mcp.clone(),
138 rate_limits: bindings.rate_limits.clone(),
139 http_enabled,
140 secrets_policy,
141 webhook_policy,
142 timers: bindings.timers.clone(),
143 })
144 }
145
146 pub fn messaging_binding(&self) -> Option<&FlowBinding> {
147 self.flow_type_bindings.get("messaging")
148 }
149
150 pub fn mcp_retry_config(&self) -> McpRetryConfig {
151 self.mcp.retry.clone().unwrap_or_default()
152 }
153
154 #[cfg(feature = "mcp")]
155 pub fn mcp_exec_config(&self) -> Result<ExecConfig> {
156 self.mcp
157 .to_exec_config(self.bindings_path.parent())
158 .context("failed to build MCP exec configuration")
159 }
160}
161
162impl SecretsPolicy {
163 fn from_bindings(bindings: &BindingsFile) -> Self {
164 let allowed = bindings
165 .flow_type_bindings
166 .values()
167 .flat_map(|binding| binding.secrets.iter().cloned())
168 .collect::<HashSet<_>>();
169 Self {
170 allowed,
171 allow_all: false,
172 }
173 }
174
175 pub fn is_allowed(&self, key: &str) -> bool {
176 self.allow_all || self.allowed.contains(key)
177 }
178
179 pub fn allow_all() -> Self {
180 Self {
181 allowed: HashSet::new(),
182 allow_all: true,
183 }
184 }
185
186 pub fn from_allowed<I, S>(iter: I) -> Self
187 where
188 I: IntoIterator<Item = S>,
189 S: Into<String>,
190 {
191 Self {
192 allowed: iter.into_iter().map(Into::into).collect(),
193 allow_all: false,
194 }
195 }
196}
197
198impl Default for RateLimits {
199 fn default() -> Self {
200 Self {
201 messaging_send_qps: default_messaging_qps(),
202 messaging_burst: default_messaging_burst(),
203 }
204 }
205}
206
207fn default_messaging_qps() -> u32 {
208 10
209}
210
211fn default_messaging_burst() -> u32 {
212 20
213}
214
215impl From<WebhookBindingConfig> for WebhookPolicy {
216 fn from(value: WebhookBindingConfig) -> Self {
217 Self {
218 allow_paths: value.allow_paths,
219 deny_paths: value.deny_paths,
220 }
221 }
222}
223
224impl WebhookPolicy {
225 pub fn is_allowed(&self, path: &str) -> bool {
226 if self
227 .deny_paths
228 .iter()
229 .any(|prefix| path.starts_with(prefix))
230 {
231 return false;
232 }
233
234 if self.allow_paths.is_empty() {
235 return true;
236 }
237
238 self.allow_paths
239 .iter()
240 .any(|prefix| path.starts_with(prefix))
241 }
242}
243
244impl TimerBinding {
245 pub fn schedule_id(&self) -> &str {
246 self.schedule_id.as_deref().unwrap_or(self.flow_id.as_str())
247 }
248}
249
250#[cfg(feature = "mcp")]
251#[derive(Debug, Deserialize)]
252#[serde(tag = "kind", rename_all = "kebab-case")]
253enum StoreBinding {
254 #[serde(rename = "http-single")]
255 HttpSingle {
256 name: String,
257 url: String,
258 #[serde(default)]
259 cache_dir: Option<String>,
260 },
261 #[serde(rename = "local-dir")]
262 LocalDir { path: String },
263}
264
265#[cfg(feature = "mcp")]
266#[derive(Debug, Default, Deserialize)]
267struct RuntimeBinding {
268 #[serde(default)]
269 max_memory_mb: Option<u64>,
270 #[serde(default)]
271 timeout_ms: Option<u64>,
272 #[serde(default)]
273 fuel: Option<u64>,
274}
275
276#[cfg(feature = "mcp")]
277#[derive(Debug, Default, Deserialize)]
278struct SecurityBinding {
279 #[serde(default)]
280 require_signature: bool,
281 #[serde(default)]
282 required_digests: HashMap<String, String>,
283 #[serde(default)]
284 trusted_signers: Vec<String>,
285}
286
287#[cfg(feature = "mcp")]
288impl McpConfig {
289 fn to_exec_config(&self, base_dir: Option<&Path>) -> Result<ExecConfig> {
290 let store_cfg: StoreBinding = serde_yaml::from_value(self.store.clone())
291 .context("invalid MCP store configuration")?;
292 let runtime_cfg: RuntimeBinding =
293 serde_yaml::from_value(self.runtime.clone()).unwrap_or_default();
294 let security_cfg: SecurityBinding =
295 serde_yaml::from_value(self.security.clone()).unwrap_or_default();
296
297 let store = match store_cfg {
298 StoreBinding::HttpSingle {
299 name,
300 url,
301 cache_dir,
302 } => ToolStore::HttpSingleFile {
303 name,
304 url,
305 cache_dir: resolve_optional_path(base_dir, cache_dir)
306 .unwrap_or_else(|| default_cache_dir(base_dir)),
307 },
308 StoreBinding::LocalDir { path } => {
309 ToolStore::LocalDir(resolve_required_path(base_dir, path))
310 }
311 };
312
313 let runtime = RuntimePolicy {
314 fuel: runtime_cfg.fuel,
315 max_memory: runtime_cfg.max_memory_mb.map(|mb| mb * 1024 * 1024),
316 wallclock_timeout: Duration::from_millis(runtime_cfg.timeout_ms.unwrap_or(30_000)),
317 };
318
319 let security = VerifyPolicy {
320 allow_unverified: !security_cfg.require_signature,
321 required_digests: security_cfg.required_digests,
322 trusted_signers: security_cfg.trusted_signers,
323 };
324
325 Ok(ExecConfig {
326 store,
327 security,
328 runtime,
329 http_enabled: self.http_enabled.unwrap_or(false),
330 })
331 }
332}
333
334impl Default for McpRetryConfig {
335 fn default() -> Self {
336 Self {
337 max_attempts: default_mcp_retry_attempts(),
338 base_delay_ms: default_mcp_retry_base_delay_ms(),
339 }
340 }
341}
342
343fn default_mcp_retry_attempts() -> u32 {
344 3
345}
346
347fn default_mcp_retry_base_delay_ms() -> u64 {
348 250
349}
350
351#[cfg(feature = "mcp")]
352fn resolve_required_path(base: Option<&Path>, value: String) -> PathBuf {
353 let candidate = PathBuf::from(&value);
354 if candidate.is_absolute() {
355 candidate
356 } else if let Some(base) = base {
357 base.join(candidate)
358 } else {
359 PathBuf::from(value)
360 }
361}
362
363#[cfg(feature = "mcp")]
364fn resolve_optional_path(base: Option<&Path>, value: Option<String>) -> Option<PathBuf> {
365 value.map(|v| resolve_required_path(base, v))
366}
367
368#[cfg(feature = "mcp")]
369fn default_cache_dir(base: Option<&Path>) -> PathBuf {
370 if let Some(dir) = env::var_os("GREENTIC_CACHE_DIR") {
371 PathBuf::from(dir)
372 } else if let Some(base) = base {
373 base.join(".greentic/tool-cache")
374 } else {
375 env::temp_dir().join("greentic-tool-cache")
376 }
377}