Skip to main content

soma_application/providers/
static_rust.rs

1use async_trait::async_trait;
2use serde_json::{json, Map, Value};
3use soma_domain::actions::{ActionTransport, SomaAction, ACTION_SPECS};
4use soma_provider_core::{
5    CliOverlay, DocsOverlay, McpOverlay, PaletteOverlay, ProviderCatalog, ProviderIdentity,
6    ProviderKind, ProviderManifest, ProviderPrompt, ProviderTool, RestOverlay,
7};
8
9use crate::{
10    dispatch_action,
11    provider_errors::ProviderError,
12    provider_registry::{Provider, ProviderCall, ProviderOutput},
13    SomaService,
14};
15
16/// Provider exposing Soma's built-in Rust actions (from `ACTION_SPECS`) as a
17/// catalog and dispatching calls through the shared `SomaService`.
18#[derive(Clone)]
19pub struct StaticRustProvider {
20    service: SomaService,
21    catalog: ProviderCatalog,
22}
23
24impl StaticRustProvider {
25    /// Builds the provider over the given service with the built-in catalog.
26    pub fn new(service: SomaService) -> Self {
27        Self {
28            service,
29            catalog: static_catalog(),
30        }
31    }
32
33    /// Returns the built-in action catalog without constructing a provider.
34    pub fn catalog_static() -> ProviderCatalog {
35        static_catalog()
36    }
37}
38
39#[async_trait]
40impl Provider for StaticRustProvider {
41    fn catalog(&self) -> ProviderCatalog {
42        self.catalog.clone()
43    }
44
45    async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
46        let action = SomaAction::from_rest(&call.action, &call.params)
47            .or_else(|_| SomaAction::from_mcp_args(&action_params(&call.action, &call.params)))
48            .map_err(|error| {
49                ProviderError::validation(
50                    "static-rust",
51                    call.action.clone(),
52                    "invalid_static_action_input",
53                    error.to_string(),
54                )
55            })?;
56        let value = match action {
57            SomaAction::Help => crate::execute_service_action(&self.service, &action)
58                .await
59                .map_err(|error| ProviderError::execution("static-rust", call.action, error))?,
60            SomaAction::ElicitName | SomaAction::ScaffoldIntent => {
61                return Err(ProviderError::validation(
62                    "static-rust",
63                    call.action,
64                    "mcp_peer_required",
65                    "elicitation actions require a live MCP peer",
66                ));
67            }
68            other => dispatch_action(&self.service, &other, surface_label(call.surface))
69                .await
70                .map_err(|error| ProviderError::execution("static-rust", call.action, error))?,
71        };
72        Ok(ProviderOutput::json(value))
73    }
74}
75
76fn static_catalog() -> ProviderCatalog {
77    ProviderManifest {
78        schema_version: 1,
79        provider: ProviderIdentity {
80            name: "static-rust".to_owned(),
81            kind: ProviderKind::StaticRust,
82            title: Some("Built-in Rust actions".to_owned()),
83            description: Some("Native service actions compiled into Soma.".to_owned()),
84            homepage: None,
85            source: None,
86            version: None,
87            enabled: Some(true),
88        },
89        tools: ACTION_SPECS.iter().map(static_tool).collect(),
90        // Reserves `quick_start` in the directory-wide uniqueness namespace
91        // (`filesystem_uniqueness::apply_directory_wide_checks` and
92        // `provider_registry`'s own duplicate-primitive check both seed from
93        // this catalog) so a drop-in provider can't declare a same-named
94        // prompt and silently shadow the built-in one. Content lives in
95        // `soma_mcp::prompts::list_prompts`/`get_prompt` — this entry exists
96        // for name reservation only, not to serve as the source of truth.
97        prompts: vec![ProviderPrompt {
98            name: "quick_start".to_owned(),
99            description: "Check the server status and get a personalised greeting to verify \
100                the MCP connection is working end-to-end."
101                .to_owned(),
102            template: None,
103            arguments_schema: None,
104            scope: None,
105            mcp: None,
106            examples: Vec::new(),
107        }],
108        resources: Vec::new(),
109        tasks: Vec::new(),
110        elicitation: Vec::new(),
111        env: Vec::new(),
112        capabilities: Default::default(),
113        docs: Some(DocsOverlay {
114            when_to_use: Some(
115                "Use for Soma built-in Rust actions, scaffold intent collection, MCP elicitation flows, and CLI/REST action reference."
116                    .to_owned(),
117            ),
118            examples: Vec::new(),
119            troubleshooting: Vec::new(),
120        }),
121        plugin: None,
122        ui: None,
123        meta: json!({}),
124    }
125}
126
127fn static_tool(spec: &soma_domain::actions::ActionSpec) -> ProviderTool {
128    ProviderTool {
129        name: spec.name.to_owned(),
130        description: spec.description.to_owned(),
131        title: None,
132        input_schema: action_input_schema(spec),
133        output_schema: Some(action_output_schema(spec)),
134        scope: spec.required_scope.map(ToOwned::to_owned),
135        destructive: spec.destructive,
136        requires_admin: spec.requires_admin,
137        cost: Some(spec.cost.as_str().to_owned()),
138        env: Vec::new(),
139        limits: None,
140        mcp: Some(McpOverlay {
141            enabled: spec.transport.mcp(),
142            title: None,
143            annotations: json!({}),
144        }),
145        rest: static_rest_overlay(spec),
146        cli: spec.cli.map(|cli| CliOverlay {
147            enabled: spec.transport.cli(),
148            command: Some(cli.command.to_owned()),
149            aliases: Vec::new(),
150            about: Some(cli.description.to_owned()),
151            long_about: Some(cli.usage.to_owned()),
152            hidden: false,
153            flags: cli
154                .flags
155                .iter()
156                .map(|flag| {
157                    json!({
158                        "name": flag.name,
159                        "value_name": flag.value_name,
160                        "required": flag.required,
161                        "description": flag.description,
162                    })
163                })
164                .collect(),
165            default_output: None,
166            interactive: false,
167        }),
168        palette: Some(PaletteOverlay {
169            enabled: spec.transport != ActionTransport::McpOnly,
170            category: Some("Example".to_owned()),
171            icon: None,
172            tone: Some("neutral".to_owned()),
173            arg_mode: Some("schema".to_owned()),
174            result_view: Some("json".to_owned()),
175            aurora_blocks: Vec::new(),
176        }),
177        ui: None,
178        examples: Vec::new(),
179        meta: json!({
180            "returns": spec.returns,
181            "cli_usage": spec.cli.map(|cli| cli.usage),
182            "scaffold_fallback": if spec.name == "scaffold_intent" {
183                json!({
184                    "recommended_skill": "scaffold-project",
185                    "instructions": "Ask the user for the scaffold fields manually, then create the same JSON shape documented by the scaffold-project skill. Do not mutate files until the user approves the plan."
186                })
187            } else {
188                Value::Null
189            },
190        }),
191    }
192}
193
194fn static_rest_overlay(spec: &soma_domain::actions::ActionSpec) -> Option<RestOverlay> {
195    match spec.rest_path {
196        Some(path) => Some(RestOverlay {
197            enabled: spec.transport.rest(),
198            method: spec.rest_method.map(ToOwned::to_owned),
199            path: Some(path.to_owned()),
200            tags: vec!["soma".to_owned()],
201            summary: Some(spec.description.to_owned()),
202            description: Some(spec.description.to_owned()),
203            deprecated: false,
204            path_params: json!({}),
205            query_params: json!({}),
206            request_body_schema: None,
207        }),
208        None if !spec.transport.rest() => Some(RestOverlay {
209            enabled: false,
210            method: None,
211            path: None,
212            tags: vec!["soma".to_owned()],
213            summary: Some(spec.description.to_owned()),
214            description: Some(spec.description.to_owned()),
215            deprecated: false,
216            path_params: json!({}),
217            query_params: json!({}),
218            request_body_schema: None,
219        }),
220        None => None,
221    }
222}
223
224fn action_output_schema(spec: &soma_domain::actions::ActionSpec) -> Value {
225    match spec.name {
226        "greet" => json!({
227            "type": "object",
228            "additionalProperties": false,
229            "required": ["greeting", "target", "server"],
230            "properties": {
231                "greeting": { "type": "string" },
232                "target": { "type": "string" },
233                "server": { "type": "string" }
234            }
235        }),
236        "echo" => json!({
237            "type": "object",
238            "additionalProperties": false,
239            "required": ["echo"],
240            "properties": {
241                "echo": { "type": "string" }
242            }
243        }),
244        "status" => json!({
245            "type": "object",
246            "additionalProperties": true,
247            "required": ["status"],
248            "properties": {
249                "status": { "type": "string" },
250                "note": { "type": "string" },
251                "warnings": {
252                    "type": "array",
253                    "items": { "type": "string" }
254                }
255            }
256        }),
257        "elicit_name" => json!({
258            "type": "object",
259            "additionalProperties": true,
260            "properties": {
261                "greeting": { "type": "string" },
262                "name": { "type": "string" },
263                "message": { "type": "string" },
264                "note": { "type": "string" },
265                "hint": { "type": "string" },
266                "fallback_greeting": { "type": "string" }
267            }
268        }),
269        "scaffold_intent" => json!({
270            "type": "object",
271            "additionalProperties": true,
272            "properties": {
273                "kind": { "type": "string" },
274                "schema_version": { "type": "integer" },
275                "status": { "type": "string" },
276                "server_category": { "type": "string" },
277                "required_surfaces": {
278                    "type": "array",
279                    "items": { "type": "string" }
280                },
281                "project": { "type": "object" },
282                "upstream": { "type": "object" },
283                "runtime": { "type": "object" },
284                "mcp_primitives": {
285                    "type": "array",
286                    "items": { "type": "string" }
287                },
288                "handoff": { "type": "object" },
289                "policy": { "type": "object" }
290            }
291        }),
292        "help" => json!({
293            "type": "object",
294            "additionalProperties": false,
295            "required": ["actions", "mcp_only_actions", "catalog", "preferred_rest_style", "usage", "examples"],
296            "properties": {
297                "actions": {
298                    "type": "array",
299                    "items": { "type": "string" }
300                },
301                "mcp_only_actions": {
302                    "type": "array",
303                    "items": { "type": "string" }
304                },
305                "catalog": {
306                    "type": "array",
307                    "items": { "type": "object" }
308                },
309                "preferred_rest_style": { "type": "string" },
310                "usage": { "type": "string" },
311                "examples": { "type": "object" }
312            }
313        }),
314        _ => json!({
315            "type": "object",
316            "description": format!("Structured result for {}.", spec.returns),
317            "additionalProperties": true
318        }),
319    }
320}
321
322fn action_input_schema(spec: &soma_domain::actions::ActionSpec) -> Value {
323    let mut properties = Map::new();
324    let mut required = Vec::new();
325    for param in spec.params {
326        let json_type = match param.ty {
327            "integer" => "integer",
328            "number" => "number",
329            "boolean" => "boolean",
330            "object" => "object",
331            "array" => "array",
332            _ => "string",
333        };
334        let mut schema = json!({
335            "type": json_type,
336            "description": param.description,
337        });
338        if param.required && json_type == "string" {
339            schema["minLength"] = json!(1);
340        }
341        properties.insert(param.name.to_owned(), schema);
342        if param.required {
343            required.push(Value::String(param.name.to_owned()));
344        }
345    }
346    let mut schema = json!({
347        "type": "object",
348        "additionalProperties": false,
349        "properties": properties,
350    });
351    if !required.is_empty() {
352        schema["required"] = Value::Array(required);
353    }
354    schema
355}
356
357fn action_params(action: &str, params: &Value) -> Value {
358    let mut params = params.clone();
359    if let Value::Object(map) = &mut params {
360        map.insert("action".to_owned(), Value::String(action.to_owned()));
361    }
362    params
363}
364
365fn surface_label(surface: crate::provider_registry::ProviderSurface) -> &'static str {
366    match surface {
367        crate::provider_registry::ProviderSurface::Mcp => "mcp",
368        crate::provider_registry::ProviderSurface::Rest => "rest",
369        crate::provider_registry::ProviderSurface::Cli => "cli",
370        crate::provider_registry::ProviderSurface::Palette => "palette",
371    }
372}