Skip to main content

soma_application/providers/
remote.rs

1use anyhow::{anyhow, Context, Result};
2use async_trait::async_trait;
3use serde_json::{json, Value};
4use soma_provider_core::{
5    CliOverlay, EnvRequirement, McpOverlay, ProviderCatalog, ProviderIdentity, ProviderKind,
6    ProviderManifest, ProviderPrompt, ProviderResource, ProviderTool, RestOverlay,
7};
8
9use crate::{
10    provider_errors::ProviderError,
11    provider_registry::{Provider, ProviderCall, ProviderOutput},
12    SomaService,
13};
14
15/// Provider serving a catalog sourced from a remote inspection report and
16/// dispatching calls through the service's REST action path.
17#[derive(Clone)]
18pub struct RemoteCatalogProvider {
19    service: SomaService,
20    catalog: ProviderCatalog,
21}
22
23impl RemoteCatalogProvider {
24    /// Builds the provider over the given service and remote catalog.
25    pub fn new(service: SomaService, catalog: ProviderCatalog) -> Self {
26        Self { service, catalog }
27    }
28}
29
30#[async_trait]
31impl Provider for RemoteCatalogProvider {
32    fn catalog(&self) -> ProviderCatalog {
33        self.catalog.clone()
34    }
35
36    async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
37        self.service
38            .call_rest_action(&call.action, call.params)
39            .await
40            .map(ProviderOutput::json)
41            .map_err(|error| ProviderError::opaque_execution(&call.provider, call.action, error))
42    }
43}
44
45/// Parses a remote inspection report's `providers` array into one catalog
46/// per provider entry.
47pub fn catalogs_from_inspection(report: &Value) -> Result<Vec<ProviderCatalog>> {
48    let providers = report
49        .get("providers")
50        .and_then(Value::as_array)
51        .ok_or_else(|| anyhow!("remote provider catalog missing providers array"))?;
52    providers.iter().map(catalog_from_provider).collect()
53}
54
55fn catalog_from_provider(provider: &Value) -> Result<ProviderCatalog> {
56    let name = string_field(provider, "name")?;
57    let kind = provider_kind(string_field(provider, "kind")?)?;
58    Ok(ProviderManifest {
59        schema_version: 1,
60        provider: ProviderIdentity {
61            name: name.to_owned(),
62            kind,
63            title: optional_string(provider, "title"),
64            description: optional_string(provider, "description"),
65            homepage: optional_string(provider, "homepage"),
66            source: optional_string(provider, "source"),
67            version: optional_string(provider, "version"),
68            enabled: provider.get("enabled").and_then(Value::as_bool),
69        },
70        tools: provider
71            .get("tools")
72            .and_then(Value::as_array)
73            .into_iter()
74            .flatten()
75            .map(tool_from_value)
76            .collect::<Result<Vec<_>>>()?,
77        prompts: provider
78            .get("prompts")
79            .and_then(Value::as_array)
80            .into_iter()
81            .flatten()
82            .map(prompt_from_value)
83            .collect::<Result<Vec<_>>>()?,
84        resources: provider
85            .get("resources")
86            .and_then(Value::as_array)
87            .into_iter()
88            .flatten()
89            .map(resource_from_value)
90            .collect::<Result<Vec<_>>>()?,
91        tasks: Vec::new(),
92        elicitation: Vec::new(),
93        env: Vec::new(),
94        capabilities: provider
95            .get("declared_capabilities")
96            .cloned()
97            .map(serde_json::from_value)
98            .transpose()
99            .context("remote provider declared_capabilities invalid")?
100            .unwrap_or_default(),
101        docs: None,
102        plugin: None,
103        ui: None,
104        meta: json!({ "remote_catalog": true }),
105    })
106}
107
108fn tool_from_value(tool: &Value) -> Result<ProviderTool> {
109    Ok(ProviderTool {
110        name: string_field(tool, "name")?.to_owned(),
111        description: optional_string(tool, "description").unwrap_or_default(),
112        title: optional_string(tool, "title"),
113        input_schema: tool
114            .get("input_schema")
115            .cloned()
116            .unwrap_or_else(|| json!({"type": "object", "properties": {}})),
117        output_schema: tool
118            .get("output_schema")
119            .cloned()
120            .filter(|value| !value.is_null()),
121        scope: optional_string(tool, "scope"),
122        destructive: tool
123            .get("destructive")
124            .and_then(Value::as_bool)
125            .unwrap_or(false),
126        requires_admin: tool
127            .get("requires_admin")
128            .and_then(Value::as_bool)
129            .unwrap_or(false),
130        cost: optional_string(tool, "cost"),
131        env: tool
132            .get("env")
133            .cloned()
134            .map(serde_json::from_value::<Vec<EnvRequirement>>)
135            .transpose()
136            .context("remote tool env invalid")?
137            .unwrap_or_default(),
138        limits: tool
139            .get("limits")
140            .cloned()
141            .filter(|value| !value.is_null())
142            .map(serde_json::from_value)
143            .transpose()
144            .context("remote tool limits invalid")?,
145        mcp: mcp_overlay(tool),
146        rest: rest_overlay(tool)?,
147        cli: tool
148            .get("cli")
149            .cloned()
150            .filter(|value| !value.is_null())
151            .map(serde_json::from_value::<CliOverlay>)
152            .transpose()
153            .context("remote tool cli overlay invalid")?,
154        palette: None,
155        ui: None,
156        examples: Vec::new(),
157        meta: json!({ "remote_catalog": true }),
158    })
159}
160
161fn prompt_from_value(prompt: &Value) -> Result<ProviderPrompt> {
162    Ok(ProviderPrompt {
163        name: string_field(prompt, "name")?.to_owned(),
164        description: optional_string(prompt, "description").unwrap_or_default(),
165        template: optional_string(prompt, "template"),
166        arguments_schema: prompt
167            .get("arguments_schema")
168            .cloned()
169            .filter(|value| !value.is_null()),
170        scope: optional_string(prompt, "scope"),
171        mcp: mcp_overlay(prompt),
172        examples: Vec::new(),
173    })
174}
175
176fn resource_from_value(resource: &Value) -> Result<ProviderResource> {
177    Ok(ProviderResource {
178        uri_template: string_field(resource, "uri_template")?.to_owned(),
179        name: string_field(resource, "name")?.to_owned(),
180        description: optional_string(resource, "description").unwrap_or_default(),
181        mime_type: optional_string(resource, "mime_type"),
182        scope: optional_string(resource, "scope"),
183        mcp: mcp_overlay(resource),
184        annotations: resource
185            .get("annotations")
186            .cloned()
187            .unwrap_or_else(|| json!({})),
188    })
189}
190
191fn mcp_overlay(value: &Value) -> Option<McpOverlay> {
192    let enabled = value
193        .get("surfaces")
194        .and_then(|surfaces| surfaces.get("mcp"))
195        .and_then(Value::as_bool)?;
196    Some(McpOverlay {
197        enabled,
198        title: None,
199        annotations: json!({}),
200    })
201}
202
203fn rest_overlay(tool: &Value) -> Result<Option<RestOverlay>> {
204    if let Some(rest) = tool.get("rest").filter(|value| !value.is_null()) {
205        return serde_json::from_value(rest.clone())
206            .map(Some)
207            .context("remote tool rest overlay invalid");
208    }
209    if tool
210        .get("surfaces")
211        .and_then(|surfaces| surfaces.get("rest"))
212        .and_then(Value::as_bool)
213        == Some(false)
214    {
215        return Ok(Some(RestOverlay {
216            enabled: false,
217            method: None,
218            path: None,
219            tags: Vec::new(),
220            summary: None,
221            description: None,
222            deprecated: false,
223            path_params: json!({}),
224            query_params: json!({}),
225            request_body_schema: None,
226        }));
227    }
228    Ok(None)
229}
230
231fn provider_kind(kind: &str) -> Result<ProviderKind> {
232    match kind {
233        "static-rust" => Ok(ProviderKind::StaticRust),
234        "openapi" => Ok(ProviderKind::Openapi),
235        "ai-sdk" => Ok(ProviderKind::AiSdk),
236        "wasm" => Ok(ProviderKind::Wasm),
237        "mcp" => Ok(ProviderKind::Mcp),
238        "python" => Ok(ProviderKind::Python),
239        "langchain" => Ok(ProviderKind::Langchain),
240        "llamaindex" => Ok(ProviderKind::Llamaindex),
241        other => Err(anyhow!("unknown remote provider kind `{other}`")),
242    }
243}
244
245fn string_field<'a>(value: &'a Value, field: &str) -> Result<&'a str> {
246    value
247        .get(field)
248        .and_then(Value::as_str)
249        .ok_or_else(|| anyhow!("remote catalog entry missing string field `{field}`"))
250}
251
252fn optional_string(value: &Value, field: &str) -> Option<String> {
253    value
254        .get(field)
255        .and_then(Value::as_str)
256        .map(ToOwned::to_owned)
257}
258
259#[cfg(test)]
260#[path = "remote_tests.rs"]
261mod tests;