Skip to main content

soma_application/
service.rs

1//! Business service layer.
2//!
3//! **All business logic lives here.** CLI and MCP are thin shims that call into this.
4//!
5//! `SomaService` owns an `SomaClient` and exposes typed methods.
6//! If you need caching, retries, data transformation, or validation, do it here —
7//! never in `cli.rs` or `mcp/tools.rs`.
8
9use anyhow::Result;
10use serde_json::{json, Value};
11
12use soma_client::SomaClient;
13
14// Unit tests live in a sidecar file — see src/service_tests.rs for the pattern.
15#[cfg(test)]
16#[path = "service_tests.rs"]
17mod tests;
18
19/// The service layer — wraps the transport client and adds business logic.
20///
21/// **Customize**: rename this to `MyServiceService` (or whatever fits).
22/// Add any fields you need: caches, config, metrics, etc.
23#[derive(Clone)]
24pub struct SomaService {
25    client: SomaClient,
26}
27
28/// Elicited requirements for scaffolding a new Soma-based project.
29///
30/// Collected from the operator (via MCP elicitation) and normalized by
31/// [`SomaService::scaffold_intent`] into the JSON handoff contract.
32#[derive(Debug, Clone)]
33pub struct ScaffoldIntent {
34    /// Human-readable project display name.
35    pub display_name: String,
36    /// Cargo crate name (kebab-case identifier).
37    pub crate_name: String,
38    /// Binary/executable name (kebab-case identifier).
39    pub binary_name: String,
40    /// Server category selector (e.g. `upstream-client` or `application-platform`).
41    pub server_category: String,
42    /// Uppercase environment-variable prefix (e.g. `UNRAID`).
43    pub env_prefix: String,
44    /// Upstream authentication kind (`none`, `api-key`, `bearer`, `oauth`, `both`).
45    pub auth_kind: String,
46    /// Bind host for the generated server.
47    pub host: String,
48    /// Bind port for the generated server.
49    pub port: u16,
50    /// MCP transport selection (`stdio`, `http`, or `dual`).
51    pub mcp_transport: String,
52    /// Comma-separated MCP primitives to enable (`tools`, `resources`, `prompts`, `elicitation`).
53    pub mcp_primitives: String,
54    /// Deployment target (`systemd`, `docker`, or `none`).
55    pub deployment: String,
56    /// Comma-separated plugin targets (`claude`, `codex`, `gemini`).
57    pub plugins: String,
58    /// Whether to publish MCP registry metadata for the project.
59    pub publish_mcp: bool,
60    /// Comma-separated documentation URLs to crawl for context.
61    pub crawl_urls: String,
62    /// Comma-separated repositories to crawl for context.
63    pub crawl_repos: String,
64    /// Comma-separated search topics to crawl for context.
65    pub crawl_search_topics: String,
66}
67
68/// Outcome of the elicited-name demo, mirroring the MCP elicitation result states.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ElicitedNameOutcome<'a> {
71    /// The user submitted a name.
72    Accepted(&'a str),
73    /// The user accepted but provided no name.
74    NoInput,
75    /// The user declined to share a name.
76    Declined,
77    /// The user cancelled the elicitation.
78    Cancelled,
79    /// The MCP client does not support elicitation.
80    Unsupported,
81}
82
83/// Validation failure for a [`ScaffoldIntent`], carrying a stable code and remediation hint.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ScaffoldIntentValidationError {
86    code: &'static str,
87    field: Option<&'static str>,
88    message: String,
89    remediation: &'static str,
90    expected_pattern: Option<&'static str>,
91}
92
93impl ScaffoldIntentValidationError {
94    fn new(
95        code: &'static str,
96        field: Option<&'static str>,
97        message: impl Into<String>,
98        remediation: &'static str,
99    ) -> Self {
100        Self {
101            code,
102            field,
103            message: message.into(),
104            remediation,
105            expected_pattern: None,
106        }
107    }
108
109    fn with_expected_pattern(mut self, expected_pattern: &'static str) -> Self {
110        self.expected_pattern = Some(expected_pattern);
111        self
112    }
113
114    /// Stable machine-readable error code.
115    pub fn code(&self) -> &'static str {
116        self.code
117    }
118
119    /// Offending field name, when the failure is attributable to one.
120    pub fn field(&self) -> Option<&'static str> {
121        self.field
122    }
123
124    /// Human-facing remediation guidance.
125    pub fn remediation(&self) -> &'static str {
126        self.remediation
127    }
128
129    /// Expected value pattern (regex), when the failure is a format mismatch.
130    pub fn expected_pattern(&self) -> Option<&'static str> {
131        self.expected_pattern
132    }
133}
134
135impl std::fmt::Display for ScaffoldIntentValidationError {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.write_str(&self.message)
138    }
139}
140
141impl std::error::Error for ScaffoldIntentValidationError {}
142
143impl SomaService {
144    /// Construct a service over the given transport client.
145    pub fn new(client: SomaClient) -> Self {
146        Self { client }
147    }
148
149    /// Return a greeting for `name`, defaulting to "World".
150    pub async fn greet(&self, name: Option<&str>) -> Result<Value> {
151        self.client.greet(name).await
152    }
153
154    /// Echo `message` back unchanged.
155    pub async fn echo(&self, message: &str) -> Result<Value> {
156        self.client.echo(message).await
157    }
158
159    /// Return the server status.
160    pub async fn status(&self) -> Result<Value> {
161        self.client.status().await
162    }
163
164    /// Call one direct REST action on a remote Soma HTTP server.
165    pub async fn call_rest_action(&self, action: &str, params: Value) -> Result<Value> {
166        self.client.call_rest_action(action, params).await
167    }
168
169    /// Read the live provider catalog from a remote Soma HTTP server.
170    pub async fn provider_catalog(&self) -> Result<Value> {
171        self.client.provider_catalog().await
172    }
173
174    /// Readiness probe: `Ok(())` when the upstream dependency is reachable.
175    /// Backs the `/readyz` route. Delegates to the client; no business logic.
176    pub async fn ready(&self) -> Result<()> {
177        self.client.ready().await
178    }
179
180    /// Build the response for the elicited-name demo after the MCP shim collects input.
181    pub fn elicited_name_greeting(&self, outcome: ElicitedNameOutcome<'_>) -> Value {
182        match outcome {
183            ElicitedNameOutcome::Accepted(name) => {
184                let name = name.trim().to_owned();
185                if name.is_empty() {
186                    json!({
187                        "greeting": "Hello, mysterious stranger!",
188                        "note": "You submitted an empty name - that's perfectly fine!",
189                    })
190                } else {
191                    json!({
192                        "greeting": format!("Hello, {name}! Welcome to the Soma MCP server."),
193                        "name": name,
194                    })
195                }
196            }
197            ElicitedNameOutcome::NoInput => json!({
198                "greeting": "Hello! (you provided no name - that's okay)",
199            }),
200            ElicitedNameOutcome::Declined => json!({
201                "message": "No problem - you chose not to share your name.",
202                "greeting": "Hello, anonymous user!",
203            }),
204            ElicitedNameOutcome::Cancelled => json!({
205                "message": "Elicitation was cancelled.",
206                "greeting": "Hello there!",
207            }),
208            ElicitedNameOutcome::Unsupported => json!({
209                "message": "Elicitation is not supported by this MCP client.",
210                "hint": "Try a client like Claude.app that supports MCP elicitation (spec 2025-06-18).",
211                "fallback_greeting": "Hello, World! (elicitation unavailable)",
212            }),
213        }
214    }
215
216    /// Convert elicited scaffold requirements into the handoff contract consumed by the skill.
217    pub fn scaffold_intent(&self, input: ScaffoldIntent) -> Result<Value> {
218        validate_scaffold_intent(&input)?;
219        let category = normalize_category(&input.server_category);
220        let required_surfaces = if category == "application-platform" {
221            vec!["api", "cli", "mcp", "web"]
222        } else {
223            vec!["mcp", "cli"]
224        };
225        let binary_profile = if category == "application-platform" {
226            "server-full"
227        } else {
228            "local-adapter"
229        };
230        let service_name = input.binary_name.trim().replace('-', "_");
231        let env_prefix = input.env_prefix.trim().to_ascii_uppercase();
232
233        Ok(json!({
234            "kind": "soma_scaffold_intent",
235            "schema_version": 1,
236            "server_category": category,
237            "required_surfaces": required_surfaces,
238            "project": {
239                "display_name": input.display_name.trim(),
240                "crate_name": input.crate_name.trim(),
241                "binary_name": input.binary_name.trim(),
242                "service_name": service_name,
243                "env_prefix": env_prefix,
244            },
245            "upstream": {
246                "base_url_env": format!("{env_prefix}_API_URL"),
247                "auth_kind": normalize_auth_kind(&input.auth_kind),
248            },
249            "runtime": {
250                "host": normalize_host(&input.host),
251                "port": input.port,
252                "binary_profile": binary_profile,
253                "mcp_transport": normalize_transport(&input.mcp_transport),
254            },
255            "mcp_primitives": normalize_primitives(&input.mcp_primitives),
256            "deployment": normalize_deployment(&input.deployment),
257            "plugins": normalize_plugins(&input.plugins),
258            "publish_mcp": input.publish_mcp,
259            "crawl_docs": {
260                "urls": split_csv(&input.crawl_urls),
261                "repos": split_csv(&input.crawl_repos),
262                "search_topics": split_csv(&input.crawl_search_topics),
263            },
264            "handoff": {
265                "recommended_skill": "scaffold-project",
266                "instructions": "Create an approval-first scaffold plan from this JSON. Do not mutate files until the user approves the plan.",
267            },
268            "policy": {
269                "business_action_minimum_surfaces": ["mcp", "cli"],
270                "upstream_client_surfaces": ["mcp", "cli"],
271                "application_platform_surfaces": ["api", "cli", "mcp", "web"],
272                "binary_profiles": {
273                    "upstream_client_default": "local-adapter",
274                    "application_platform_default": "server-full",
275                    "gateway_shared_default": "server-full"
276                },
277            }
278        }))
279    }
280}
281
282fn validate_scaffold_intent(input: &ScaffoldIntent) -> Result<()> {
283    validate_non_empty("display_name", &input.display_name)?;
284    validate_kebab_identifier("crate_name", &input.crate_name)?;
285    validate_kebab_identifier("binary_name", &input.binary_name)?;
286    validate_env_prefix(&input.env_prefix)?;
287    if input.port == 0 {
288        return Err(ScaffoldIntentValidationError::new(
289            "invalid_port",
290            Some("port"),
291            "port must be between 1 and 65535",
292            "Use a TCP port between 1 and 65535.",
293        )
294        .into());
295    }
296    validate_urls("crawl_urls", &input.crawl_urls)?;
297    validate_urls("crawl_repos", &input.crawl_repos)?;
298    Ok(())
299}
300
301fn validate_non_empty(field: &str, value: &str) -> Result<()> {
302    if value.trim().is_empty() {
303        return Err(ScaffoldIntentValidationError::new(
304            "missing_field",
305            Some(leak_field_name(field)),
306            format!("`{field}` is required and must not be empty"),
307            "Provide a non-empty value and retry scaffold_intent.",
308        )
309        .into());
310    }
311    Ok(())
312}
313
314fn validate_kebab_identifier(field: &str, value: &str) -> Result<()> {
315    let value = value.trim();
316    validate_non_empty(field, value)?;
317    let mut chars = value.chars();
318    let Some(first) = chars.next() else {
319        return Err(ScaffoldIntentValidationError::new(
320            "missing_field",
321            Some(leak_field_name(field)),
322            format!("`{field}` is required and must not be empty"),
323            "Provide a non-empty value and retry scaffold_intent.",
324        )
325        .into());
326    };
327    if !first.is_ascii_lowercase()
328        || !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
329    {
330        return Err(ScaffoldIntentValidationError::new(
331            "invalid_identifier",
332            Some(leak_field_name(field)),
333            format!("`{field}` must match ^[a-z][a-z0-9-]*$"),
334            "Use a lowercase kebab-case identifier such as `unraid-rmcp`.",
335        )
336        .with_expected_pattern("^[a-z][a-z0-9-]*$")
337        .into());
338    }
339    Ok(())
340}
341
342fn validate_env_prefix(value: &str) -> Result<()> {
343    let value = value.trim().to_ascii_uppercase();
344    validate_non_empty("env_prefix", &value)?;
345    let mut chars = value.chars();
346    let Some(first) = chars.next() else {
347        return Err(ScaffoldIntentValidationError::new(
348            "missing_field",
349            Some("env_prefix"),
350            "`env_prefix` is required and must not be empty",
351            "Provide an uppercase environment prefix such as `UNRAID`.",
352        )
353        .into());
354    };
355    if !first.is_ascii_uppercase()
356        || !chars.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
357    {
358        return Err(ScaffoldIntentValidationError::new(
359            "invalid_env_prefix",
360            Some("env_prefix"),
361            "`env_prefix` must match ^[A-Z][A-Z0-9_]*$",
362            "Use an uppercase env prefix such as `UNRAID` or `LAB_GATEWAY`.",
363        )
364        .with_expected_pattern("^[A-Z][A-Z0-9_]*$")
365        .into());
366    }
367    Ok(())
368}
369
370fn validate_urls(field: &str, value: &str) -> Result<()> {
371    for item in split_csv(value) {
372        url::Url::parse(&item).map_err(|_| {
373            ScaffoldIntentValidationError::new(
374                "invalid_url",
375                Some(leak_field_name(field)),
376                format!("`{field}` contains invalid URL: {item}"),
377                "Provide comma-separated absolute URLs such as `https://docs.example.test/`.",
378            )
379        })?;
380    }
381    Ok(())
382}
383
384fn leak_field_name(field: &str) -> &'static str {
385    match field {
386        "display_name" => "display_name",
387        "crate_name" => "crate_name",
388        "binary_name" => "binary_name",
389        "env_prefix" => "env_prefix",
390        "crawl_urls" => "crawl_urls",
391        "crawl_repos" => "crawl_repos",
392        _ => "unknown",
393    }
394}
395
396fn normalize_category(category: &str) -> &'static str {
397    let normalized = category.trim().to_ascii_lowercase();
398    if normalized.contains("application") || normalized.contains("platform") {
399        "application-platform"
400    } else {
401        "upstream-client"
402    }
403}
404
405fn normalize_auth_kind(value: &str) -> &'static str {
406    match value.trim().to_ascii_lowercase().as_str() {
407        "none" => "none",
408        "api-key" | "apikey" | "api_key" | "api key" | "key" => "api-key",
409        "bearer" | "token" => "bearer",
410        "oauth" => "oauth",
411        "both" => "both",
412        _ => "other",
413    }
414}
415
416fn normalize_host(value: &str) -> String {
417    let trimmed = value.trim();
418    if trimmed.is_empty() {
419        "127.0.0.1".to_owned()
420    } else {
421        trimmed.to_owned()
422    }
423}
424
425fn normalize_transport(value: &str) -> &'static str {
426    match value.trim().to_ascii_lowercase().as_str() {
427        "stdio" => "stdio",
428        "http" | "streamable-http" | "streamable_http" => "http",
429        _ => "dual",
430    }
431}
432
433fn normalize_deployment(value: &str) -> &'static str {
434    match value.trim().to_ascii_lowercase().as_str() {
435        "systemd" => "systemd",
436        "docker" | "container" | "containers" => "docker",
437        _ => "none",
438    }
439}
440
441fn normalize_primitives(value: &str) -> Vec<String> {
442    let requested = split_csv(value);
443    let mut primitives = Vec::new();
444    for item in requested {
445        let primitive = match item.to_ascii_lowercase().as_str() {
446            "tools" | "tool" => Some("tools"),
447            "resources" | "resource" => Some("resources"),
448            "prompts" | "prompt" => Some("prompts"),
449            "elicitation" | "elicit" => Some("elicitation"),
450            _ => None,
451        };
452        if let Some(primitive) = primitive {
453            let primitive = primitive.to_owned();
454            if !primitives.contains(&primitive) {
455                primitives.push(primitive);
456            }
457        }
458    }
459    if primitives.is_empty() {
460        primitives.push("tools".to_owned());
461    }
462    primitives
463}
464
465fn normalize_plugins(value: &str) -> Vec<String> {
466    let requested = split_csv(value);
467    let mut plugins = Vec::new();
468    for item in requested {
469        let plugin = match item.to_ascii_lowercase().as_str() {
470            "claude" | "claude-code" | "claude_code" => Some("claude"),
471            "codex" => Some("codex"),
472            "gemini" => Some("gemini"),
473            "none" => None,
474            _ => None,
475        };
476        if let Some(plugin) = plugin {
477            let plugin = plugin.to_owned();
478            if !plugins.contains(&plugin) {
479                plugins.push(plugin);
480            }
481        }
482    }
483    plugins
484}
485
486fn split_csv(value: &str) -> Vec<String> {
487    let mut items = Vec::new();
488    for item in value
489        .split(',')
490        .map(str::trim)
491        .filter(|item| !item.is_empty())
492    {
493        let item = item.to_owned();
494        if !items.contains(&item) {
495            items.push(item);
496        }
497    }
498    items
499}