1use anyhow::Result;
10use serde_json::{json, Value};
11
12use soma_client::SomaClient;
13
14#[cfg(test)]
16#[path = "service_tests.rs"]
17mod tests;
18
19#[derive(Clone)]
24pub struct SomaService {
25 client: SomaClient,
26}
27
28#[derive(Debug, Clone)]
33pub struct ScaffoldIntent {
34 pub display_name: String,
36 pub crate_name: String,
38 pub binary_name: String,
40 pub server_category: String,
42 pub env_prefix: String,
44 pub auth_kind: String,
46 pub host: String,
48 pub port: u16,
50 pub mcp_transport: String,
52 pub mcp_primitives: String,
54 pub deployment: String,
56 pub plugins: String,
58 pub publish_mcp: bool,
60 pub crawl_urls: String,
62 pub crawl_repos: String,
64 pub crawl_search_topics: String,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ElicitedNameOutcome<'a> {
71 Accepted(&'a str),
73 NoInput,
75 Declined,
77 Cancelled,
79 Unsupported,
81}
82
83#[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 pub fn code(&self) -> &'static str {
116 self.code
117 }
118
119 pub fn field(&self) -> Option<&'static str> {
121 self.field
122 }
123
124 pub fn remediation(&self) -> &'static str {
126 self.remediation
127 }
128
129 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 pub fn new(client: SomaClient) -> Self {
146 Self { client }
147 }
148
149 pub async fn greet(&self, name: Option<&str>) -> Result<Value> {
151 self.client.greet(name).await
152 }
153
154 pub async fn echo(&self, message: &str) -> Result<Value> {
156 self.client.echo(message).await
157 }
158
159 pub async fn status(&self) -> Result<Value> {
161 self.client.status().await
162 }
163
164 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 pub async fn provider_catalog(&self) -> Result<Value> {
171 self.client.provider_catalog().await
172 }
173
174 pub async fn ready(&self) -> Result<()> {
177 self.client.ready().await
178 }
179
180 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 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}