mcp_exec/
describe.rs

1use anyhow::{Context, Result};
2use greentic_types::{SecretFormat, SecretKey, SecretRequirement, SecretScope};
3use serde_json::Value;
4use tracing::warn;
5
6use crate::{ExecConfig, ExecError, ExecRequest, exec};
7
8#[cfg(feature = "describe-v1")]
9const DESCRIBE_INTERFACE: &str = "greentic:component/describe-v1@1.0.0";
10#[cfg(feature = "describe-v1")]
11const DESCRIBE_EXPORT: &str = "greentic:component/describe-v1@1.0.0#describe-json";
12
13#[derive(Debug)]
14pub enum Maybe<T> {
15    Data(T),
16    Unsupported,
17}
18
19#[derive(Debug)]
20pub struct ToolDescribe {
21    pub describe_v1: Option<Value>,
22    pub capabilities: Maybe<Vec<String>>,
23    pub secrets: Maybe<Value>,
24    pub config_schema: Maybe<Value>,
25    pub secret_requirements: Vec<SecretRequirement>,
26}
27
28pub fn describe_tool(name: &str, cfg: &ExecConfig) -> Result<ToolDescribe> {
29    #[cfg(feature = "describe-v1")]
30    {
31        if let Some(document) = try_describe_v1(name, cfg)? {
32            let (secret_requirements, used_legacy) =
33                secret_requirements(Some(&document), &Maybe::Unsupported);
34            if used_legacy {
35                warn!(
36                    tool = name,
37                    "legacy secrets descriptors were mapped; emit `secret_requirements` in describe-json"
38                );
39            }
40            return Ok(ToolDescribe {
41                describe_v1: Some(document),
42                capabilities: Maybe::Unsupported,
43                secrets: Maybe::Unsupported,
44                config_schema: Maybe::Unsupported,
45                secret_requirements,
46            });
47        }
48    }
49
50    fn try_action(name: &str, action: &str, cfg: &ExecConfig) -> Result<Maybe<Value>> {
51        let req = ExecRequest {
52            component: name.to_string(),
53            action: action.to_string(),
54            args: Value::Object(Default::default()),
55            tenant: None,
56        };
57
58        match exec(req, cfg) {
59            Ok(v) => Ok(Maybe::Data(v)),
60            Err(ExecError::NotFound { .. }) => Ok(Maybe::Unsupported),
61            Err(ExecError::Tool { code, payload, .. }) if code == "iface-error.not-found" => {
62                let _ = payload;
63                Ok(Maybe::Unsupported)
64            }
65            Err(e) => Err(e.into()),
66        }
67    }
68
69    let capabilities_value = try_action(name, "capabilities", cfg)?;
70    let secrets = try_action(name, "list_secrets", cfg)?;
71    let config_schema = try_action(name, "config_schema", cfg)?;
72
73    let capabilities = match capabilities_value {
74        Maybe::Data(value) => {
75            if let Some(arr) = value.as_array() {
76                let list = arr
77                    .iter()
78                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
79                    .collect::<Vec<_>>();
80                Maybe::Data(list)
81            } else {
82                Maybe::Data(Vec::new())
83            }
84        }
85        Maybe::Unsupported => Maybe::Unsupported,
86    };
87
88    let (secret_requirements, used_legacy) = secret_requirements(None, &secrets);
89    if used_legacy {
90        warn!(
91            tool = name,
92            "legacy secrets descriptors were mapped; emit `secret_requirements` in tool metadata"
93        );
94    }
95
96    Ok(ToolDescribe {
97        describe_v1: None,
98        capabilities,
99        secrets,
100        config_schema,
101        secret_requirements,
102    })
103}
104
105#[cfg(feature = "describe-v1")]
106fn try_describe_v1(name: &str, cfg: &ExecConfig) -> Result<Option<Value>> {
107    use wasmtime::component::{Component, Linker};
108    use wasmtime::{Config, Engine, Store};
109
110    let resolved =
111        crate::resolve::resolve(name, &cfg.store).map_err(|err| ExecError::resolve(name, err))?;
112    let verified = crate::verify::verify(name, resolved, &cfg.security)
113        .map_err(|err| ExecError::verification(name, err))?;
114
115    let mut config = Config::new();
116    config.wasm_component_model(true);
117    config.async_support(false);
118    config.epoch_interruption(true);
119
120    let engine = Engine::new(&config)?;
121    let component = match Component::from_binary(&engine, verified.resolved.bytes.as_ref()) {
122        Ok(component) => component,
123        Err(_) => return Ok(None),
124    };
125    let linker = Linker::new(&engine);
126    let mut store = Store::new(&engine, ());
127
128    let instance = match linker.instantiate(&mut store, &component) {
129        Ok(instance) => instance,
130        Err(_) => return Ok(None),
131    };
132    if instance
133        .get_export(&mut store, None, DESCRIBE_INTERFACE)
134        .is_none()
135    {
136        return Ok(None);
137    }
138
139    let func = match instance.get_typed_func::<(), (String,)>(&mut store, DESCRIBE_EXPORT) {
140        Ok(func) => func,
141        Err(err) => {
142            let msg = err.to_string();
143            if msg.contains("unknown export") {
144                return Ok(None);
145            }
146            return Err(err);
147        }
148    };
149
150    let (raw,) = func.call(&mut store, ())?;
151    let value: Value =
152        serde_json::from_str(&raw).with_context(|| "describe-json returned invalid JSON")?;
153    Ok(Some(value))
154}
155
156pub const RUNTIME_SENTINEL: &str = "runtime";
157
158fn secret_requirements(
159    describe_v1: Option<&Value>,
160    secrets: &Maybe<Value>,
161) -> (Vec<SecretRequirement>, bool) {
162    if let Some(requirements) = describe_v1.and_then(|doc| doc.get("secret_requirements")) {
163        let parsed = normalize_requirements(requirements);
164        return (dedup(parsed), false);
165    }
166
167    if let Maybe::Data(value) = secrets {
168        let parsed = normalize_requirements(value);
169        return (dedup(parsed), true);
170    }
171
172    (Vec::new(), matches!(secrets, Maybe::Data(_)))
173}
174
175fn normalize_requirements(value: &Value) -> Vec<SecretRequirement> {
176    if let Some(arr) = value.as_array() {
177        return arr.iter().filter_map(parse_requirement).collect();
178    }
179
180    if let Some(obj) = value.as_object() {
181        if let Some(reqs) = obj.get("secret_requirements").and_then(Value::as_array) {
182            return reqs.iter().filter_map(parse_requirement).collect();
183        }
184        if let Some(reqs) = obj.get("secrets").and_then(Value::as_array) {
185            return reqs.iter().filter_map(parse_requirement).collect();
186        }
187    }
188
189    Vec::new()
190}
191
192fn parse_requirement(value: &Value) -> Option<SecretRequirement> {
193    match value {
194        Value::String(key) => {
195            let key = SecretKey::new(key).ok()?;
196            let mut req = SecretRequirement::default();
197            req.key = key;
198            req.required = true;
199            req.scope = Some(runtime_scope());
200            req.format = Some(default_format());
201            Some(req)
202        }
203        Value::Object(obj) => {
204            let key_raw = obj
205                .get("key")
206                .or_else(|| obj.get("name"))
207                .and_then(Value::as_str)?;
208            let key = SecretKey::new(key_raw).ok()?;
209            let required = obj
210                .get("required")
211                .and_then(Value::as_bool)
212                .unwrap_or_else(|| {
213                    !obj.get("optional")
214                        .and_then(Value::as_bool)
215                        .unwrap_or(false)
216                });
217
218            let scope = parse_scope(obj.get("scope"))
219                .or_else(|| parse_scope(Some(&Value::Object(obj.clone()))))
220                .unwrap_or_else(runtime_scope);
221
222            let format = obj
223                .get("format")
224                .and_then(Value::as_str)
225                .and_then(parse_format)
226                .unwrap_or_else(default_format);
227
228            let description = obj
229                .get("description")
230                .and_then(Value::as_str)
231                .map(ToOwned::to_owned);
232
233            let schema = obj.get("schema").cloned();
234            let examples = obj
235                .get("examples")
236                .and_then(Value::as_array)
237                .map(|arr| {
238                    arr.iter()
239                        .filter_map(example_to_string)
240                        .collect::<Vec<String>>()
241                })
242                .unwrap_or_default();
243
244            let mut req = SecretRequirement::default();
245            req.key = key;
246            req.required = required;
247            req.description = description;
248            req.scope = Some(scope);
249            req.format = Some(format);
250            req.schema = schema;
251            req.examples = examples;
252            Some(req)
253        }
254        _ => None,
255    }
256}
257
258fn parse_scope(value: Option<&Value>) -> Option<SecretScope> {
259    let obj = value?.as_object()?;
260    let env = obj.get("env").and_then(Value::as_str)?;
261    let tenant = obj.get("tenant").and_then(Value::as_str)?;
262    let team = obj
263        .get("team")
264        .and_then(Value::as_str)
265        .map(ToOwned::to_owned);
266
267    Some(SecretScope {
268        env: env.to_owned(),
269        tenant: tenant.to_owned(),
270        team,
271    })
272}
273
274fn parse_format(value: &str) -> Option<SecretFormat> {
275    match value.trim().to_ascii_lowercase().as_str() {
276        "json" => Some(SecretFormat::Json),
277        "text" => Some(SecretFormat::Text),
278        "opaque" => Some(SecretFormat::Bytes),
279        "binary" | "bytes" | "byte" | "bin" => Some(SecretFormat::Bytes),
280        _ => None,
281    }
282}
283
284fn runtime_scope() -> SecretScope {
285    SecretScope {
286        env: RUNTIME_SENTINEL.to_owned(),
287        tenant: RUNTIME_SENTINEL.to_owned(),
288        team: None,
289    }
290}
291
292fn default_format() -> SecretFormat {
293    SecretFormat::Text
294}
295
296fn example_to_string(value: &Value) -> Option<String> {
297    match value {
298        Value::String(s) => Some(s.clone()),
299        other => serde_json::to_string(other).ok(),
300    }
301}
302
303fn dedup(requirements: Vec<SecretRequirement>) -> Vec<SecretRequirement> {
304    use std::collections::HashSet;
305
306    let mut seen = HashSet::new();
307    let mut out = Vec::with_capacity(requirements.len());
308    for req in requirements {
309        let scope_key = req
310            .scope
311            .as_ref()
312            .map(|scope| (scope.env.clone(), scope.tenant.clone(), scope.team.clone()));
313        if seen.insert((req.key.clone(), scope_key)) {
314            out.push(req);
315        }
316    }
317    out
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use serde_json::json;
324
325    #[test]
326    fn maps_describe_v1_secret_requirements() {
327        let describe_v1 = json!({
328            "name": "demo",
329            "secret_requirements": [
330                {
331                    "key": "api-key",
332                    "required": false,
333                    "format": "json",
334                    "scope": { "env": "dev", "tenant": "acme" },
335                    "description": "auth key"
336                }
337            ]
338        });
339
340        let (reqs, used_legacy) = secret_requirements(Some(&describe_v1), &Maybe::Unsupported);
341        assert!(!used_legacy);
342        assert_eq!(reqs.len(), 1);
343        let req = &reqs[0];
344        assert_eq!(req.key.as_str(), "api-key");
345        assert!(!req.required);
346        assert_eq!(req.format, Some(SecretFormat::Json));
347        let scope = req.scope.as_ref().expect("scope set");
348        assert_eq!(scope.env, "dev");
349        assert_eq!(scope.tenant, "acme");
350        assert_eq!(req.description.as_deref(), Some("auth key"));
351    }
352
353    #[test]
354    fn maps_legacy_list_secrets_strings() {
355        let secrets = Maybe::Data(json!(["token", "secondary"]));
356        let (reqs, used_legacy) = secret_requirements(None, &secrets);
357        assert!(used_legacy);
358        assert_eq!(reqs.len(), 2);
359
360        for req in reqs {
361            assert!(req.required);
362            assert_eq!(req.format, Some(SecretFormat::Text));
363            let scope = req.scope.as_ref().expect("scope set");
364            assert_eq!(scope.env, RUNTIME_SENTINEL);
365            assert_eq!(scope.tenant, RUNTIME_SENTINEL);
366        }
367    }
368}