Skip to main content

soma_application/providers/
static_rust.rs

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