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#[derive(Clone)]
19pub struct StaticRustProvider {
20 service: SomaService,
21 catalog: ProviderCatalog,
22}
23
24impl StaticRustProvider {
25 pub fn new(service: SomaService) -> Self {
27 Self {
28 service,
29 catalog: static_catalog(),
30 }
31 }
32
33 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 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}